Multi-Stage Builds
Separate build and runtime stages so TypeScript compilers, test runners, and devDependencies never ship to production.
Recipe
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.
Working Example
# 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:
depsstage cachesnpm ciwhen only source changesbuildstage compiles TypeScript with full devDependenciesprodstage copies onlydist/and productionnode_modulesUSER nodeafterchownfor non-root runtime
Deep Dive
How It Works
- Each
FROMstarts a new stage; onlyCOPY --from=artifacts cross stage boundaries - Earlier stages are discarded from the final image except copied files
- Layer caching: put slow steps (
npm ci) before fast-changing steps (COPY src)
Stage Naming Patterns
| 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 |
BuildKit Cache Mounts (Optional)
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=devSpeeds CI rebuilds when lockfile is unchanged. Requires BuildKit (DOCKER_BUILDKIT=1).
Monorepo Variant
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.
Gotchas
- Single-stage
npm cithen delete devDeps manually - easy to miss files. Fix: use a dedicatedprodstage withnpm ci --omit=dev. - Copying entire
/appfrom build stage - dragssrc/, tests, and caches into prod. Fix:COPY --from=build /app/dist ./distonly. - Native modules built in wrong stage -
bcryptcompiled on macOS, run on Linux. Fix: compile inside the Linux build stage. - Forgetting
package-lock.jsonin prod stage -npm cifails or installs wrong versions. Fix: copy lockfile to every stage that runsnpm ci. - Running as root in final stage - security scanners flag it. Fix:
chown+USER node- see Non-Root Containers. - Huge build context - slow CI. Fix:
.dockerignore- see Image Slimming.
Alternatives
| 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 |
FAQs
How many stages do I need?
Minimum two: build and prod. Three (deps, build, prod) improves cache hit rate on large projects.
Should I run tests in the Docker build?
Run tests in CI before docker build. Optionally add a test stage that fails the build, but most teams gate in GitHub Actions instead.
Can I use npm prune instead of a second npm ci?
Yes in the build stage. For prod, a fresh npm ci --omit=dev is clearer and avoids prune mistakes.
Does NestJS need a different pattern?
Same pattern: nest build in build stage, node dist/main.js in prod. See NestJS deployment docs for monorepo variants.
What about pnpm or yarn?
Replace npm ci with pnpm install --frozen-lockfile or yarn install --immutable. Keep the same stage separation.
How do I debug a failed build stage?
docker build --target build -t api:debug .
docker run -it --entrypoint sh api:debugRelated
- Docker Basics - first container
- Image Slimming -
.dockerignoreand layer diet - Distroless & Alpine Trade-offs - minimal runtime bases
- Non-Root Containers - permissions in final stage
- Docker Best Practices - section checklist
Stack 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.