Multi-Stage Docker Builds for Production Node.js Apps
Why the single-stage Dockerfile that runs locally breaks in production — and how a multi-stage build, a non-root user, and a distroless base image shrink your image and your attack surface.
On this page
The relationship between a Dockerfile and a Node.js app mirrors the relationship between a teenager and a weekend: everyone starts with the simplest thing that works, and nobody wants to confront the consequences. A typical "works on my machine" Dockerfile installs devDependencies, runs as root, packs the source, and calls it shipped. It works on your machine. It is a security and performance liability on a production cluster.
This article shows the multi-stage build pattern I use for production Node.js services: a builder stage for everything heavy, then a slim runtime stage with a non-root user and production-only dependencies. The result is smaller, faster to pull, and throws away the tools attackers would use against you.
Why single-stage builds end badly
The single-stage problem isn't just image size — it's that the image contains everything used to build it:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci # pulls dev deps: TS, test runner, linters
COPY . .
RUN npm run build # compiles TS → dist/, leaves ts-node & tsc installed
CMD ["npm", "start"] # npm as PID 1, root user, dev deps on diskConsequences stack up quickly:
- Size.
node:20alone is ~1.1GB uncompressed. DevDependencies double it. Every deploy pays the fat-pull latency across your registry and every node. - Attack surface. A vulnerable transitive dep inside
devDependenciesis now copy-pasted into production artifact, and it ships with at least one known-abused toolchain (a compiler, a bundler, an explorer). - No user isolation. Containers default to
root. A RCE in your app that runs as root is a host compromise waiting for a misconfigured volume or capability.
The multi-stage shape
Here's the pattern — three intended stages, one runtime:
# ── 1. Base: shared dependency metadata ──────────────
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
# ── 2. Builder: everything heavy ─────────────────────
FROM base AS build
RUN apk add --no-cache python3 make g++ # native modules
COPY . .
RUN npm ci
RUN npm run build
# ── 3. Runtime: only what we run with ────────────────
FROM node:20-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
# A throwaway build stage that installs ONLY production deps,
# so devDependencies (tsc, jest, eslint…) never reach runtime.
FROM base AS deps
RUN npm ci --omit=dev
FROM runtime AS final
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# Non-root user crafters ship minimal images
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]Wait — I merged two ideas in that file, so let me be explicit about the better version: the deps stage exists because final halts on native modules (better-sqlite3, sharp, argon2) that need compiler tooling in their install step. Installing --omit=dev in a deps stage on the alpine toolchain, then copying the finished node_modules into the runtime image, means the runtime image never contains a compiler at all.
Note
Two details people miss: npm ci (not npm install) installs exactly what the lockfile pins, and NODE_ENV=production must be set before npm ci --omit=dev, since some packages branch their install behavior on it.
Pruned output and PNG-shaped health checks
A few refinements that end up in production-grade files:
- Trim the dependencies you copy. If
dist/bundles everything through a bundler, you can often ship with zero runtimenode_modules. Vercel-style services do exactly this:next buildemits a self-contained.next/— copy only that, pluspublic/andpackage.json. - Health checks in the image, not the orchestrator only. Docker's
HEALTHCHECKinstruction lets the platform kill a wedged container; the orchestrator liveness probe duplicates it at the orchestration layer.
FROM node:20-alpine AS final
COPY --from=build /app/dist ./dist
USER node
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD \
node --conditions=development -e "fetch('http://127.0.0.1:3000/health').then(r => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"
EXPOSE 3000
CMD ["node", "dist/index.js"]Why distroless (and when to skip it)
gcr.io/distroless/nodejs20-debian12 is a Node base image with no shell, no package manager, no compiler — just the runtime and the OS libs your binary needs. Combined with USER 1000 and a COPY'd node_modules, your running container has almost nothing an attacker can chain: no sh, no curl, no apt.
The trade-offs are real:
- Debugging on the box gets painful. No
docker exec <container> bash— you can use--entrypointoverrides, but interactive debugging requires your logging/metrics to be excellent before you adopt distroless. - Some native modules need exact Debian libs. If your stack uses
sharp,bcrypt, orpg-native, verify the deps resolve on the distroless Debian flavor you pick, or prepare a Debian-slim fallback. - If you're on a managed platform (Vercel, Railway, Render), the platform already layers most of this for you — follow their recommended image rather than fighting for distroless parity.
My default for a self-hosted Node service is: distroless + non-root + multi-stage, and I keep a debian-slim variant in the repo for the day a debugging session needs a shell.
Caution
Running a non-root container is necessary but not sufficient: a non-root USER doesn't stop a container from mounting a spear-readable secret volume or being granted extra capabilities. Pair the non-root user with a read-only root filesystem (--read-only) and a blocked NET_RAW capability onto the file, then validate once with docker run --rm --security-opt no-new-privileges.
The measurable outcome
The multi-stage version of the naive file above typically ends up at 30–45MB compressed versus 500MB+ for the naive one, and every copy—node_modules from deps, dist from build—is a layer Docker can reuse across deploys. Layers that didn't change don't re-upload, so deployments become faster than the CI job that built them.
When a PR author says "my Dockerfile is fine," ask three questions: Can I exec a shell in your container? (if yes, an attacker can too). Does your image contain tsc and jest in production? (if yes, you re-deployed your build environment). Is PID 1 your app process? (if it's npm, your signals arrive late and your shutdown grace is fiction). Get those to "no, no, and it's my app," and your containers will survive objections from both the performance and the security review.
Related reading
PostgreSQL Connection Pooling With PgBouncer: A Practical Guide
Why Node.js apps exhaust PostgreSQL connections, how transaction pooling with PgBouncer fixes it, and the gotchas — prepared statements, set_config, and monitoring — that trip teams up.
Caching Strategies for SaaS Backends: Redis, HTTP & CDN
Where to put the cache so it actually helps: application vs. HTTP vs. edge, cache invalidation by version not by prayer, and the metrics that tell you the cache is lying.
Work together
Building something similar?
I design and build production-ready web applications, SaaS platforms and backend APIs — from data modelling to deployment. If a project or technical challenge is on your roadmap, let's talk it through.