Multi-stage builds: a fat builder, a thin runtime, and how COPY --from collapses 1.2 GB into 28 MB
A multi-stage build compiles in a fat builder stage, then a thin runtime stage copies only the artifact. BuildKit builds just the final target and its deps, prunes unreferenced stages, and ships a 28 MB image instead of 1.2 GB — with toolchains and source absent from the layers.
The security team blocked the release: the scanner had flagged 312 CVEs in the Go service’s image, most of them in gcc, git, make, and a pile of -dev headers — none of which the running binary used. The image was 1.2 GB. The developer was baffled: the Go program was a single statically linked binary, 18 MB. Where did 1.2 GB come from? From the Dockerfile shipping the entire build toolchain into production: FROM golang:1.23, then COPY . ., RUN go build, and that was the final image — compiler, module cache, the full Debian userland, and the source code, all baked into the shipped layers. Every one of those CVEs was in software that existed only to produce the binary, not to run it. The rewrite was four lines: build in one stage, then FROM gcr.io/distroless/static, COPY --from=build /app /app. The image dropped to 28 MB, the CVE count to single digits, and the source code stopped shipping to customers. Nothing about the program changed. The build just stopped confusing “what I need to compile” with “what I need to run.”
Two jobs, two stages
Ask yourself: does the running container actually need the compiler that built it? Almost never — yet single-stage builds ship both environments in the same layers. A single-stage build conflates two different filesystems: the one you need to build an artifact (compilers, package managers, headers, source, module caches) and the one you need to run it (the binary plus its actual runtime dependencies). Multi-stage separates them. You write several FROM stages in one Dockerfile; the last one is the image you ship, and earlier stages exist only to produce inputs the final stage copies in.
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build # ~840 MB: compiler, git, module cache
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:nonroot AS runtime # ~2 MB base
COPY --from=build /app /app # copy ONLY the binary
USER nonroot
ENTRYPOINT ["/app"]COPY --from=build /app /app is the hinge. It reaches into the build stage’s final filesystem and copies just /app into the runtime stage. The compiler, the 400 MB module cache, the source tree, git — none of it crosses the boundary. The shipped image is the distroless base plus one 18 MB binary layer: 28 MB total, against the 1.2 GB single-stage image. Same binary, 40× smaller, and the entire build toolchain — every CVE-bearing dev package — is simply absent from the layers a customer pulls.
Only the target stage (and its deps) gets built
A second property people miss: docker build builds the final target stage and only the stages it transitively depends on. Unreferenced stages are pruned from the LLB graph and never execute. So you can keep a test stage and a lint stage in the same Dockerfile, and a plain docker build (targeting the runtime stage) skips them entirely unless something COPY --froms their output. You can also build a specific stage with --target build to get a debug image with the full toolchain, or --target test in CI to run tests inside the graph. One Dockerfile, several outputs, and the production build pays for none of the stages it does not reference.
A Dockerfile has a build stage (compiler + source, ~840 MB) and a runtime stage that does COPY --from=build /app /app onto a 2 MB distroless base. How large is the shipped image, and where did the compiler go?
▸Why this works
Why does a smaller image matter beyond the disk number? Three compounding reasons. Attack surface: every binary in the image is something a scanner flags and an attacker might exploit; a distroless image with no shell, no package manager, and no compiler removes whole classes of post-exploitation moves (you cannot curl | sh what has no curl and no sh). Pull latency: a 28 MB image cold-pulls onto a fresh node in well under a second where 1.2 GB takes tens of seconds — multiplied across an autoscaling event spinning up 50 pods, that is the difference between fast and visibly slow scale-up. And supply-chain hygiene: shipping source code and .git to production leaks intellectual property and credentials that should never leave the build. The size is a proxy; what you are really minimizing is everything that is not the program.
The COPY-from-stage cache trap
Multi-stage interacts with caching in a way that bites people. The runtime stage’s COPY --from=build /app vertex is keyed on the digest of /app as produced by the build stage. So if the build stage’s compile is a cache hit and produces the identical binary, the COPY --from hits cache too and the runtime layer is reused. But if your build is non-deterministic — embedding a timestamp, a random build ID, or a Go build that varies — /app’s digest changes every build, the COPY --from always misses, and you push a new runtime layer to the registry every time even when nothing meaningful changed. The same content addressing that makes the cache work makes non-reproducible builds expensive: a reproducible compile lets the final layer be a stable cache hit, which is exactly what the next lesson on reproducible builds is about. The discipline: order the build stage like any other (manifests before source), and keep the artifact deterministic so the cross-stage copy can cache.
A Dockerfile contains build, test, and lint stages plus a runtime stage. The CI runs plain docker build targeting runtime, which only COPY --from=build. What happens to the test and lint stages?
- 01Walk through how a multi-stage build turns a 1.2 GB single-stage Go image into ~28 MB, and what specifically stops shipping.
- 02Explain which stages BuildKit actually builds, and the cross-stage COPY --from cache trap.
A single-stage build ships one filesystem that conflates building and running — compiler, package manager, module cache, source, and the artifact all in the final image — which is why a single statically linked 18 MB Go binary arrives as a 1.2 GB image carrying 300-plus CVEs in software the running program never touches. A multi-stage build writes several FROM stages in one Dockerfile: a fat build stage compiles the artifact, and a separate thin runtime stage on a minimal base (distroless, ~2 MB) does COPY —from=build /app /app and nothing else. COPY —from copies only the named path out of the earlier stage’s filesystem; that stage’s layers are never appended to the final image, so the toolchain, the module cache, git, the dev headers, and the source code do not cross the boundary. The result is ~28 MB instead of 1.2 GB — same binary, ~40x smaller — with the CVE count in single digits and source no longer leaking to customers, while the smaller surface also removes the shell and package manager an attacker would reach for and slashes cold-pull latency during autoscaling. BuildKit reinforces this by building only the target stage and its transitive dependencies: a test or lint stage in the same file is pruned unless you target it explicitly with —target, so production pays for nothing it does not reference. The one trap is cache: the runtime stage’s COPY —from is keyed on the artifact’s digest, so a non-reproducible build whose binary digest churns every run misses that copy every time and pushes a fresh registry layer for no real change — which is the bridge to keeping builds reproducible. Now when you review a Dockerfile, count the FROM stages: one stage shipping to production is almost always a red flag — ask what toolchain ended up in the shipped layers.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.