DockerAdvanced

Docker Multi-Stage Builds: A Step-by-Step Tutorial

Learn how to shrink Docker images and speed up builds using multi-stage builds, with a complete Node.js example.

DevFieldGuideJuly 8, 2026 (updated July 18, 2026)6 min read
Share:

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:

dockerfile
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

dockerfile
# --- 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:

dockerfile
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:

depsnpm ci --omit=dev
builderFull deps, runs the build
runnerOnly prod deps + dist copied in

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

bash
docker build -t myapp:multistage .
docker images myapp:multistage

It'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 --target builds.
  • Only copy what the runtime actually needs — build tools, source files, and caches should never leave the builder stage.
  • Use a -slim or -alpine base 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:

dockerfile
# 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:

bash
docker history myapp:multistage --no-trunc

A 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.

bash
docker images node:22 node:22-slim

Targeting 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:

bash
docker build --target builder -t myapp:debug .
docker run -it myapp:debug sh

Dropping 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

Common mistakes
  • Copying the entire node_modules from a stage that ran npm ci (full dev dependencies) instead of a dedicated --omit=dev stage — 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 before npm ci means every code change busts the dependency-install cache too, even though dependencies didn't change.
  • Forgetting a .dockerignore file. Without one, COPY . . also copies node_modules, .git, and local env files into the build context, bloating build time and occasionally leaking local secrets into an image layer.
  • Using latest tags for base images in a production Dockerfile. FROM node:22 today isn't guaranteed to be the same image next month — pin to a specific version (or a digest) for reproducible builds.
Advertisement

Frequently Asked Questions

Advertisement
DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Docker

View all