CodePark/ questions / Multi-stage builds & image size
START PRACTICINGPRACTICE
Backend question · Docker › Multi-stage builds & image size

Explain how this Dockerfile ends up with only production dependencies in the final image, and why npm prune is there.

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]

The build stage installs everything, then removes what it does not need. The final stage copies only the result.

The build stage:

  • runs npm ci with all dependencies, because compiling needs TypeScript, the Nest CLI and the type packages
  • runs npm run build, which produces dist
  • runs npm prune --omit=dev, which removes the dev dependencies from node_modules, so only what the app needs at runtime is left

The final stage starts again from a clean base image. It copies three things:

  • the package files
  • the pruned node_modules
  • dist

The source code, the dev dependencies and npm's cache from the build stage never reach the final image.

An alternative is a separate deps stage. It runs npm ci --omit=dev from the lock file, and the final stage copies its node_modules. This way, Docker can cache the production install on its own, and source code changes do not invalidate it.

The final stage runs as the node user. It starts node directly in exec form (a JSON array, with no shell in between), so signals like SIGTERM reach the app.

  • The build stage needs dev dependencies to compile the code.
  • npm prune --omit=dev removes dev dependencies after the build.
  • The final stage copies only the package files, node_modules and dist.
  • An alternative is a separate deps stage that runs npm ci --omit=dev.