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"]The three stages, and what actually makes it into the final image:
Only runner ships — deps and builder exist purely to produce inputs for it, and neither their layers nor their tools end up in the final image.
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.
- 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.
A multi-stage build produces one service's image — Docker Compose is the natural next step once you need that image running alongside a database and cache locally, before it ever reaches a real deployment pipeline.
Using BuildKit cache mounts for even faster rebuilds
Multi-stage builds solve image size. BuildKit cache mounts solve build speed — a separate, complementary problem. By default, RUN npm ci re-downloads every package on any change to package.json, even though npm's own local cache could avoid most of that work if it persisted between builds:
# syntax=docker/dockerfile:1
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build--mount=type=cache persists /root/.npm across builds on the same machine (or CI runner, if the cache is preserved between jobs) without that cache ever becoming part of the final image layers — it's invisible to docker build -t myapp . in terms of output size, purely a build-time speedup. The # syntax=docker/dockerfile:1 line at the top is required to opt into these newer BuildKit-only features; without it, Docker falls back to the legacy builder and silently ignores the cache mount syntax.
Debugging a multi-stage build that isn't shrinking as expected
If the final image is still large after splitting into stages, the fastest diagnostic is docker history on the final image, which lists every layer and its size:
docker history myapp:multistage --no-truncA layer that's unexpectedly large almost always traces back to one of two causes: a COPY --from=builder that copied more than intended (the whole /app directory instead of just /app/dist), or a base image itself that's heavier than assumed (node:22 versus node:22-slim differ by several hundred megabytes before any application code is even added). Comparing docker images output for the base image alone against the final image gives a quick sense of how much size the application layers actually added versus how much came from the base.
docker images node:22 node:22-slimTargeting a specific stage for local debugging
--target builds only up to a named stage, stopping before later stages run — useful for debugging the builder stage directly without waiting for (or even having a working) final runner stage:
docker build --target builder -t myapp:debug .
docker run -it myapp:debug shDropping into a shell in the builder stage this way lets you inspect exactly what the build produced before it gets pared down for the runner stage — genuinely useful when a build succeeds but the runtime image behaves unexpectedly, since it isolates whether the problem is in the build itself or in what got left out of the final copy.
Common mistakes
- Copying the entire
node_modulesfrom a stage that rannpm ci(full dev dependencies) instead of a dedicated--omit=devstage — ships dev-only tooling into the runtime image, quietly undoing most of the size win. - Running
COPY . .before installing dependencies. Docker's layer cache invalidates from the first changed layer onward — copying source code beforenpm cimeans every code change busts the dependency-install cache too, even though dependencies didn't change. - Forgetting a
.dockerignorefile. Without one,COPY . .also copiesnode_modules,.git, and local env files into the build context, bloating build time and occasionally leaking local secrets into an image layer. - Using
latesttags for base images in a production Dockerfile.FROM node:22today isn't guaranteed to be the same image next month — pin to a specific version (or a digest) for reproducible builds.
Related reading
- Docker Compose for Local Development Environments — shares tags: docker, devops (same category).
- CI/CD Pipelines Explained: From Commit to Production — shares tags: devops, cloud.
- Understanding Cloud Cost Optimization Basics — shares tags: cloud, devops.
- Infrastructure as Code: Why Terraform Won — shares tags: devops, cloud.
- Kubernetes ConfigMaps and Secrets: A Practical Guide — shares tags: devops, cloud.