Open chat

← All posts

DockerDevOps

Docker multi-stage builds for production Node.js images

Separating build-time dependencies from runtime artifacts, running as non-root, and keeping images small enough to pull quickly at scale.

Production images should contain only what the process needs to run: runtime Node modules, compiled output, and static assets—not devDependencies, test harnesses, or the full monorepo context from a developer machine.

Multi-stage pattern

The build stage installs tools and compiles; the runtime stage copies artifacts and production node_modules (or a pruned tree) into a minimal base.

# build
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev

# runtime
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system app && adduser --system --ingroup app app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]

Adjust paths to match your bundler output (next start vs a custom dist server).

Hardening

  • Prefer slim bases; add ca-certificates explicitly when you make outbound TLS.
  • Run as non-root; ensure file permissions on copied assets.
  • Define a readiness check that matches real dependencies (e.g. database), not a static 200 on /.

Takeaway: Smaller, non-root images reduce attack surface, speed cold starts, and cut registry transfer costs.

← Back to portfolio