Shipping a 1 GB Image for a 20 MB Binary: Why Multi-Stage Builds Are Worth the Extra FROM Line
Multi-stage Docker builds let you compile in a full toolchain image and ship only the artifact. Here's a worked Go example, the ARG scoping gotchas, and how to verify nothing leaked.
11 Dec 2025, 12:19 UTC

Your CI pipeline just pushed a Go service image that weighs 900 MB. The compiled binary is 20 MB. The other 880 MB is the Go toolchain, module cache, and your entire source tree — none of which the container needs at runtime, and all of which expands your attack surface and slows every pull on every node.
Docker's multi-stage builds solve this with a simple idea: use one stage to build the artifact, then start a fresh stage and copy over only the artifact. Everything else stays behind in the build and never ships.
What a stage actually is
Every FROM instruction in a Dockerfile begins a new stage. In a single-stage build you only have one, so whatever you install to compile your code — compilers, headers, package managers, test dependencies — ends up in the final image whether you want it or not. Deleting files later in the same Dockerfile doesn't shrink the image either; earlier layers still carry them.
With multiple stages, only what you explicitly COPY --from=<stage> crosses into the final image. The intermediate stages exist during the build and are discarded afterward. That's the whole trick, and it's a fully supported, long-established Docker feature — no plugins, no BuildKit gymnastics required (though BuildKit, the default builder in current Docker versions, builds stages in parallel and skips unused ones).
A worked example: Go service
Here's a realistic two-stage Dockerfile for a Go HTTP service:
# syntax=docker/dockerfile:1
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/server /server
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]A few decisions worth noting:
AS buildnames the stage so the final stage can reference it. Naming matters — a typo in the--fromreference is a common cause of confusing "stage not found" build errors.go mod downloadruns before copying the source. Module files change rarely, so this layer caches well and rebuilds stay fast.CGO_ENABLED=0produces a statically linked binary, which is what allows the final image to bedistroless/static— no libc, no shell, no package manager. If your binary needs CGO, use a glibc-based runtime base likedebian:bookworm-sliminstead.
Run the build from the repo root with the Docker CLI (no special permissions beyond Docker daemon access):
docker build -t myservice:local .Then verify what actually shipped:
docker images myservice:local
docker image history myservice:localThe history output should show only the distroless base layers plus the single COPY layer. If you see layers referencing apt, go, or your source tree, something leaked — usually a COPY . . in the final stage or a missing --from flag. Comparing against the single-stage equivalent, images for compiled languages typically come out 5–10x smaller; a Go service commonly drops from ~900 MB to under 30 MB.
Where ARG and ENV fit in
Build arguments are scoped per stage, which is useful but trips people up. An ARG declared before the first FROM is available to FROM lines (e.g., FROM golang:${GO_VERSION}) but is not automatically visible inside stages — you must redeclare it. Conversely, an ARG declared inside the build stage does not exist in the final stage unless you pass the value through explicitly:
FROM gcr.io/distroless/static-debian12
ARG APP_VERSION=dev
LABEL org.opencontainers.image.version="${APP_VERSION}"This scoping is a feature: secrets passed as build args to a build stage don't leak into the final image's environment or metadata. Still, for real secrets (private registry tokens, SSH keys), prefer BuildKit secrets (--secret mounts) over ARG, since ARG values can persist in intermediate image history.
Trade-offs and limitations
Multi-stage builds aren't free. Debugging gets harder: when a build fails, the failing stage's filesystem is gone unless you build that stage directly with docker build --target build . and shell into it. Teams new to Docker often stumble on stage-naming typos and on the ARG scoping rules above.
Caching also needs care. A --no-cache rebuild compiles everything from scratch, which for Rust or large Java projects can mean many minutes. And smaller isn't automatically better: a distroless image has no shell, so docker exec -it <container> sh won't work — production debugging requires ephemeral debug containers or a separate debug image variant. For interpreted languages like Python or Node, the wins are smaller too; you typically copy an installed site-packages or node_modules tree rather than a single binary, and sloppy copies can drag dev dependencies along.
A practical way to adopt it
Pick your noisiest service — the one with the biggest image or slowest pulls. Convert its Dockerfile to two stages, build both versions, and compare with docker images. Then run the container and hit its health endpoint; size reductions mean nothing if the binary was dynamically linked against something the slim base doesn't have (the classic symptom: the container exits immediately with "no such file or directory" on a binary that clearly exists). Once one service is converted and verified, the pattern copies cleanly across the rest of the fleet — and your registry storage, pull times, and CVE scanner reports all get quieter at once.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.