Multi-Stage Docker Builds: Keeping Compilers Out of Production Images
Production containers often ship compilers and source code they never use. Multi-stage Docker builds let you compile in one stage and copy only the final binary into a minimal runtime image, cutting size and attack surface.
21 May 2026, 10:36 UTC

If your production container still carries a C compiler, a full language SDK, or the original source tree, you are shipping attack surface and megabytes you do not need. Multi-stage Docker builds fix this by letting one Dockerfile compile artifacts in an early stage and copy only the final results into a clean, minimal runtime image.
The bloat you do not see
A single-stage Dockerfile accumulates everything. When you run RUN apt-get install -y build-essential, those compiler packages become part of the image. When you COPY . ., the source code stays. The final image is not just your application; it is your application plus every tool you used to build it.
That matters for two reasons. First, size: larger images slow down pulls, waste registry storage, and increase node disk pressure. Second, security: every extra package is another potential CVE—a publicly catalogued known vulnerability. A compiler or a package manager sitting in a production container gives an attacker more to work with if they ever achieve code execution.
How multiple FROM statements change the build
A multi-stage Dockerfile uses more than one FROM instruction. Each FROM starts a new stage with its own filesystem and its own layer cache—the mechanism Docker uses to reuse unchanged instruction results. Earlier stages can install compilers, download dependencies, and run tests. The final stage starts from a minimal base image and copies only the artifacts it needs.
You name stages with the AS keyword, then pull specific paths with COPY --from=stage-name. Because each stage caches independently, Docker can reuse an unchanged builder stage even if the final stage changes. The reverse is also true: if a copied artifact changes, only the layers after that COPY are invalidated.
Multi-stage builds have been part of Docker since version 17.05. BuildKit—the improved builder that supports secret mounts and better caching—is the default in Docker 23 and later.
A worked example with Go and Alpine
Suppose you have a Go service. Go compiles to a single static binary, which makes it a good candidate for a tiny runtime image. The example below assumes Docker 23+ with BuildKit enabled.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder
WORKDIR /src
# Cache module downloads separately from source changes
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /out/server /usr/local/bin/server
ENTRYPOINT ["/usr/local/bin/server"]
The builder stage uses the full Go toolchain. It downloads modules before copying the entire source tree, so changing application code does not invalidate the module cache. CGO_ENABLED=0 produces a static binary with no dynamic libc dependency, which avoids the musl-versus-glibc compatibility trap that Alpine can introduce for C-linked programs.
The final stage starts from alpine:3.20, adds only CA certificates so the service can make TLS connections, and copies the single binary. No source code, no Go toolchain, no module cache.
Build and verify
Run these commands from the directory containing the Dockerfile. You need access to the Docker daemon; no special host privileges are required beyond normal Docker usage.
# Build the image
docker build -t myservice:1.0 .
# Compare sizes against your previous single-stage tag
docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' myservice
# Inspect layers; you should not see apt-get, go, or source COPY layers
docker history myservice:1.0
# Confirm the Go toolchain is absent
docker run --rm myservice:1.0 sh -c "command -v go || echo 'go not found'"
The docker history output should show only the Alpine base, the apk add layer, and the binary copy. If you see compiler installation layers, your final stage is inheriting them from an earlier stage incorrectly.
The trade-off: smaller images, harder debugging
Minimal images are deliberately sparse. Alpine includes a shell, but it lacks most debugging tools. Distroless images go further and ship no shell at all, which is excellent for security but painful during an incident. If your service crashes at 2 a.m., you cannot docker exec into a distroless container to poke around.
A common compromise is to keep the production image minimal and maintain a separate debug variant—same application, but based on a fuller image with strace, curl, or a shell—for ephemeral troubleshooting.
Two other limitations deserve attention. First, cache staleness: if your build depends on files outside the COPY context, Docker may reuse a stale layer and produce an old binary. Make sure every input is captured by a COPY, or use --no-cache when you suspect drift. Second, secrets: passing a token via ARG stores it in the image metadata and docker history. Use BuildKit secret mounts instead, which expose the value only during the specific RUN command and do not persist in the final image.
What to do next
Pick one service with a known-large image. Split its Dockerfile into a builder stage and a runtime stage. Build both tags, run docker history on each, and compare the layer lists. If the final image still contains build tools, check that your COPY --from targets only the artifact path, not the whole builder filesystem. If debugging becomes a problem, publish a debug tag alongside the minimal one so on-call engineers have a way in.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.