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-certificatesexplicitly 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.