Multi-Stage Builds
Separate build and runtime stages so TypeScript compilers, test runners, and devDependencies never ship to production.
Search across all documentation pages
Separate build and runtime stages so TypeScript compilers, test runners, and devDependencies never ship to production.
Quick-reference recipe card - copy-paste ready.
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:24-bookworm-slim AS prod
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]When to reach for this: Every Node.js API image. Multi-stage is the default pattern for TypeScript services on Node 24.
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS base
WORKDIR /app
# ---- dependencies (cached layer) ----
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
# ---- compile TypeScript ----
FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
# ---- production runtime ----
FROM node:24-bookworm-slim AS prod
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
RUN chown -R node:node /app
USER node
HEALTHCHECK CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/main.js"]{
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js"
}
}What this demonstrates:
deps stage caches npm ci when only source changesbuild stage compiles TypeScript with full devDependenciesprod stage copies only dist/ and production node_modulesUSER node after chown for non-root runtimeFROM starts a new stage; only COPY --from= artifacts cross stage boundariesnpm ci) before fast-changing steps (COPY src)| Stage | Purpose | In final image? |
|---|---|---|
deps | Install all dependencies | No |
build | tsc, bundlers, tests | No |
prod / runtime | Run node dist/main.js | Yes |
dev | tsx watch for local compose | Only with --target dev |
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=devSpeeds CI rebuilds when lockfile is unchanged. Requires BuildKit (DOCKER_BUILDKIT=1).
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/api/package.json packages/api/
COPY packages/shared/package.json packages/shared/
RUN npm ci
COPY . .
RUN npm run build -w packages/api
FROM node:24-bookworm-slim AS prod
WORKDIR /app
COPY --from=build /app/packages/api/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
CMD ["node", "dist/main.js"]Copy only the workspace package you deploy. Do not copy the entire monorepo into the runtime stage.
npm ci then delete devDeps manually - easy to miss files. Fix: use a dedicated prod stage with npm ci --omit=dev./app from build stage - drags src/, tests, and caches into prod. Fix: COPY --from=build /app/dist ./dist only.bcrypt compiled on macOS, run on Linux. Fix: compile inside the Linux build stage.package-lock.json in prod stage - npm ci fails or installs wrong versions. Fix: copy lockfile to every stage that runs npm ci.chown + USER node - see Non-Root Containers..dockerignore - see Image Slimming.| Alternative | Use When | Don't Use When |
|---|---|---|
CI builds dist/, Docker only copies artifacts | Fast Docker builds; TS compile already in pipeline | You need hermetic builds entirely inside Docker |
| esbuild/swc bundle to single file | Tiny images; serverless bundles | You need dynamic import() of many local files |
| Distroless final stage | Minimal attack surface | You need a shell for debugging |
| Single-stage dev image | Quick prototypes | Production deploys |
Minimum two: build and prod. Three (deps, build, prod) improves cache hit rate on large projects.
Run tests in CI before docker build. Optionally add a test stage that fails the build, but most teams gate in GitHub Actions instead.
Yes in the build stage. For prod, a fresh npm ci --omit=dev is clearer and avoids prune mistakes.
Same pattern: nest build in build stage, node dist/main.js in prod. See NestJS deployment docs for monorepo variants.
Replace npm ci with pnpm install --frozen-lockfile or yarn install --immutable. Keep the same stage separation.
docker build --target build -t api:debug .
docker run -it --entrypoint sh api:debug.dockerignore and layer dietStack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 18, 2026