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 ciwith all dependencies, because compiling needs TypeScript, the Nest CLI and the type packages - runs
npm run build, which producesdist - runs
npm prune --omit=dev, which removes the dev dependencies fromnode_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.
Read more