Docker Multi-Stage Builds: How to Separate Build and Runtime
Practice3 minInnovialabs editorial team
In short
A single Dockerfile defines several stages: a heavy stage with all the tools builds the app, and only the finished output is copied into a small final image, without compilers, sources or dev dependencies.
The short answer
A multi-stage build is a Dockerfile with more than one FROM instruction. Each FROM starts a new stage with its own base image. Only the last stage becomes the final image, and you explicitly pull the files you need from earlier stages with COPY --from=<stage>.
Why it matters:
- Smaller images — compilers, package managers, sources and test dependencies never reach production.
- Smaller attack surface — fewer tools in the image means fewer findings from vulnerability scanners.
- One Dockerfile instead of a build script plus a separate runtime Dockerfile.
- Per-stage caching — Docker reuses each stage's layers when its inputs have not changed.
Go: from a compiler to an almost empty image
Go compiles to a static binary, so the final image can be minimal.
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot
ENTRYPOINT ["/app"]
Two details do the heavy lifting: CGO_ENABLED=0 produces a binary with no libc dependency, and copying go.mod before the sources keeps the dependency layer cached.
Node.js: separate build and runtime dependencies
A Node.js app has no single binary, but splitting still pays off: TypeScript, bundlers and test packages are only needed while building.
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
The final stage installs production dependencies only and copies the compiled dist folder from the build stage.
Frontend: build in Node, serve with Nginx
For an SPA (React, Vue, Svelte), Node is only needed to produce static files. Serving them is a job for a lightweight web server.
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
The resulting image contains no Node.js and no node_modules — just Nginx and the build output. The output folder depends on your bundler; some use build instead of dist.
How to measure the gain
Do not rely on someone else's numbers; size depends on your dependencies. Compare it yourself:
- Build a naive single-stage version:
docker build -t app:single -f Dockerfile.single . - Build the multi-stage version:
docker build -t app:multi . - Compare them:
docker images app - See which layers take space:
docker history app:multi
The biggest difference usually shows up with compiled languages (Go, Rust), where the final image holds a single binary. For Node.js the gain is smaller but still visible, thanks to dropping dev dependencies and the full base image.
Common mistakes
- Copying the whole context without a
.dockerignore—node_modules,.gitand local.envfiles end up in the build. COPY . .before installing dependencies — every code change invalidates thenpm ciorgo mod downloadcache.- A dynamically linked binary in distroless or scratch — the container fails with a "not found" error.
- Running as root in the final stage when a single
USERline would fix it. - Unnamed stages —
COPY --from=0breaks once you add a stage. Name stages withAS.