Learn how multi-stage Docker builds work and why they matter.
Published 7th Jun, 2026
If you’ve been writing Dockerfiles for a while, you’ve probably run into the classic problem - your image ends up way larger than it needs to be. You install compilers, build tools, dev dependencies, all just to produce a single binary or a bundle of static files. And then all that stuff just… sits there in your final image, doing nothing, bloating it.
Multi-stage builds are Docker’s answer to that. The idea is simple: you can define multiple FROM statements in a single Dockerfile, each starting a fresh stage. You build your app in one stage, then copy only the output into a clean, minimal final stage. The intermediate stages never make it into the final image.
Say you have a Go application. The typical naive approach looks something like this:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]This works, but your final image is based on golang:1.22, which is around 800MB-1GB. It has the entire Go toolchain, source files, and all the build cache baked in.
With multi-stage builds, you can do this instead:
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o server .
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=builder /app/server .
CMD ["./server"]The first stage (builder) compiles the binary. The second stage starts fresh from debian:bookworm-slim and only pulls in the compiled binary using COPY --from=builder. The Go toolchain, source code, and everything else from the builder stage are discarded entirely.
The final image goes from ~900MB down to around 80-100MB.
A few concrete reasons:
Smaller attack surface - Every package you include in a production image is a potential vulnerability. If your runtime only needs a binary, why ship a C compiler with it?
Faster pulls and deploys - Smaller images transfer faster. If you’re pulling images frequently across nodes in a cluster, this adds up quickly.
Cleaner separation of concerns - Your build environment and your runtime environment have different requirements. Multi-stage builds make that explicit in the Dockerfile itself, rather than relying on .dockerignore hacks or cleanup scripts inside the image.
No more “clean up in the same layer” gymnastics - People used to chain RUN commands with && and rm -rf just to avoid layer bloat. Multi-stage builds make that mostly unnecessary.
If you want to go further, you can target scratch (a completely empty base image) or Google’s distroless images.
For a Go binary with no external dependencies, scratch works well:
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]CGO_ENABLED=0 is important here - it disables cgo so the binary is statically linked and doesn’t need glibc at runtime.
The resulting image is just the binary. Nothing else. Final size is whatever the binary itself is.
distroless is a middle ground - it strips out package managers, shells, and most utilities, but still includes things like glibc and SSL certificates, which many apps actually need:
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
CMD ["/server"]You’re not limited to two stages. You can have as many as you need. A common pattern is to have separate stages for dependencies and the actual build, which helps with caching:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["node", "server.js"]Here, deps installs node modules, builder runs the Next.js build, and runner is the production image with only the standalone output. If you change source files but not package.json, Docker cache skips the deps stage entirely.
You can build up to a specific stage using --target:
docker build --target builder -t myapp:dev .This is useful for local development or debugging - you can build just the builder stage and get a shell into it to inspect what went wrong.
One thing to keep in mind: the COPY --from instruction does not invalidate cache in the target stage if the source stage rebuilt. Docker looks at the content being copied, not which stage it came from. So if your binary didn’t actually change, the layers in the runtime stage stay cached. This is generally the behavior you want.
Multi-stage builds are one of those features that seem optional until you actually use them, and then going back feels uncomfortable. Once your production images are clean and minimal, it’s hard to justify shipping 1GB images with full build environments in them.
If you’re not using them already, start with any Dockerfile that installs a compiler or build toolchain, and refactor it. The size difference alone is usually convincing enough.
You can write to me at [email protected]. Email services are insecure, consider encrypting emails with my PGP Key if you're sending me something sensitive.
Loading comments