If your Docker images are bloated with build tools that have no business being in production, multi-stage builds fix that in a few lines.
The problem
A naive Dockerfile for a Node.js app often looks like this:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["node", "dist/server.js"]This image ships npm, dev dependencies, source maps, and your entire node_modules — often 800MB+ for what should be a 100MB runtime image.
Step 1: Split the build and runtime stages
# --- Stage 1: build ---
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Stage 2: runtime ---
FROM node:22-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
CMD ["node", "dist/server.js"]Only the builder stage installs full dev dependencies and runs the build. The runner stage copies just the compiled output.
Step 2: Prune dependencies before copying
Go further by installing only production dependencies in a dedicated stage:
FROM node:22 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]Step 3: Measure the difference
docker build -t myapp:multistage .
docker images myapp:multistageIt's common to see a 60-80% size reduction, which directly improves pull times, cold starts, and CI cache efficiency.
Key takeaways
- Name your stages (
AS builder,AS runner) for readability and targeted--targetbuilds. - Only copy what the runtime actually needs — build tools, source files, and caches should never leave the builder stage.
- Use a
-slimor-alpinebase for the final stage where possible.