Docker Multi-Stage Builds: Shipping Only What Your Application Needs
Production Docker images often carry compilers and source code they never use. Multi-stage builds separate build-time tools from runtime artifacts, shrinking images and reducing attack surface—if you choose the right base and verify the result.
12 Mar 2026, 07:28 UTC

The Problem: Build Tools in Production
If your production Docker image contains a compiler, package manager, or source code tree, you are paying for bytes and attack surface you never use at runtime. A single-stage build installs everything needed to compile the application and leaves those tools in the final image. The result is a container that is larger, slower to pull, and harder to audit.
Multi-stage builds solve this by letting you use multiple FROM statements in one Dockerfile. You compile in one stage and copy only the resulting binary or assets into a clean, minimal final stage. The build tools stay behind.
Building in Two Stages
A multi-stage Dockerfile defines at least two stages. The first—often named builder—contains your full toolchain. The final stage starts from a minimal base image and copies artifacts from the builder using COPY --from=builder. Anything not explicitly copied is discarded.
Naming stages improves readability and lets you build up to a specific point during development:
# syntax=docker/dockerfile:1
# Build stage: includes Go toolchain and source
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
# Runtime stage: minimal Alpine base
FROM alpine:3.19
COPY --from=builder /app/server /usr/local/bin/server
ENTRYPOINT ["server"]
Run the build from the directory containing the Dockerfile. If you use BuildKit (the default builder in modern Docker Engine installations), the build runs with your standard user permissions; no sudo is required unless your Docker daemon restricts socket access.
To stop after the builder stage and inspect it, use the --target flag:
docker build --target builder -t myapp:builder .
Cache efficiency depends on instruction order. Docker caches each layer, so place rarely changing steps before frequently changing ones. In the example above, dependency files are copied and downloaded before the full source tree. Changing application code then reuses the cached dependency layer instead of triggering a full rebuild.
The Base Image Trade-off
The final stage choice determines both size and compatibility. Alpine Linux is small and popular, but it uses musl libc instead of the more common glibc. If your application depends on glibc—many C, C++, or Python wheels do—an Alpine runtime may fail with missing library errors.
Distroless images go further: they contain only the application and its runtime dependencies, with no shell or package manager. This reduces attack surface but makes debugging inside the container significantly harder.
A safer middle ground for glibc-dependent binaries is a slim variant of a standard distribution, such as debian:bookworm-slim, which keeps the expected C libraries while dropping unnecessary tools.
Verifying the Result
After building, compare sizes against a single-stage equivalent:
docker build -t myapp:multi-stage .
docker images myapp
Inspect the final image layers to confirm build tools are absent:
docker history myapp:multi-stage
Look for layers that install compilers or package managers. If you see them, your COPY --from command may be pulling too much, or your final stage is inheriting from the wrong base.
Finally, verify the application actually runs:
docker run --rm myapp:multi-stage
If the container exits immediately with a library loading error, the runtime base image likely lacks a required shared library. This is the most common symptom of a glibc/musl mismatch.
What to Watch For
Multi-stage builds are not free. The primary risk is accidental leakage: a broad COPY --from=builder /app /app can carry source code, environment files, or build-time secrets into the production image. Copy only the specific artifact paths you need.
Too many stages also hurt maintainability. If your Dockerfile reads like a Rube Goldberg machine, collapse related steps. Two or three well-named stages are usually clearer than eight.
Minimal images also remove the shell you might use for debugging. When something breaks in production, you cannot docker exec into a distroless container to poke around. Plan to reproduce issues locally or use ephemeral debug containers.
Ship Less, Debug Deliberately
Use multi-stage builds whenever your build process requires tools your runtime does not: compilers, dev headers, test frameworks, or bundlers. The payoff is a smaller image, faster pulls, and a narrower attack surface.
Start with two stages, name them clearly, and verify with docker history and a runtime smoke test. If your binary is statically linked, Alpine or distroless is usually safe. If it links against glibc, prefer a slim Debian base until you have confirmed compatibility.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.