Multi-stage builds
Small images with no build tools left behind.
Walk into a furniture workshop and you see saws, clamps, a drum of sandpaper and a bin of offcuts. The customer who buys the chair sees none of it. They get the chair. A container image is supposed to work the same way, and the obvious way to build one gets it backwards: the compiler, the package manager, the header files and your whole source tree all ride along to production, and the running program touches none of them.
Multi-stage builds fix that. You write one Dockerfile (the recipe file that tells Docker how to assemble an image) with more than one FROM line, and each FROM starts a fresh stage with its own base and its own filesystem. Do the messy work early: pull dependencies, compile, run the tests. Then copy the one finished artifact into a clean final stage. Only the last stage becomes the image you push. Everything before it is scaffolding, and scaffolding comes down.
The version everybody writes first
Here is a single-stage build for a small payments service written in Go. It compiles the binary and then runs it, all inside the official Go image. Functionally there is nothing wrong with it. It works.
FROM golang:1.23WORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /server ./cmd/serverENTRYPOINT ["/server"]
Build it, then look at what you got.
$ docker build -t payments-api:naive -f Dockerfile.naive .$ docker images payments-api
REPOSITORY TAG IMAGE ID CREATED SIZEpayments-api naive 8b7d3f0a2c15 4 seconds ago 852MB
852MB to ship a binary that weighs about 18MB on its own. Riding along: the Go toolchain, the module cache, git, a shell, and every file of your source. Each one is something you pull over the network, store on every node, and patch when a CVE (Common Vulnerabilities and Exposures, the public catalogue of known security flaws) advisory lands against it. Your app uses none of them while it runs.
Split it in two
FROM golang:1.23 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /out/server ./cmd/serverFROM gcr.io/distroless/static:nonrootCOPY --from=build /out/server /serverUSER nonrootENTRYPOINT ["/server"]
Two FROM lines, two stages. The first is named build, and it is the same heavyweight Go image as before. That is fine now, because it never leaves your machine. The second starts from gcr.io/distroless/static, a base with no shell, no package manager and no libc (the C standard library that most programs link against for basics like opening a file). It carries barely enough to run a static binary as a non-root user. The line joining the two stages is COPY --from=build. It reaches into the builder's filesystem and lifts out /out/server. Nothing else crosses the gap.
One detail there is quietly doing real work. go.mod and go.sum are copied and downloaded before the rest of the source. Docker caches each layer and reuses it as long as that layer's inputs are unchanged, so while your dependency list holds still, the RUN go mod download layer comes straight from cache and you skip re-fetching modules on every code edit. Copy the thing that changes least, first.
$ docker build -t payments-api:slim .$ docker images payments-api
[+] Building 34.2s (15/15) FINISHED=> [internal] load build definition from Dockerfile 0.0s=> [internal] load metadata for gcr.io/distroless/static 0.6s=> [internal] load metadata for docker.io/library/golang:1.23 0.6s=> [build 1/6] FROM docker.io/library/golang:1.23 0.0s=> [stage-1 1/2] FROM gcr.io/distroless/static:nonroot 0.4s=> [build 2/6] WORKDIR /src 0.1s=> [build 3/6] COPY go.mod go.sum ./ 0.0s=> [build 4/6] RUN go mod download 6.1s=> [build 5/6] COPY . . 0.1s=> [build 6/6] RUN CGO_ENABLED=0 go build -o /out/server ... 25.8s=> [stage-1 2/2] COPY --from=build /out/server /server 0.1s=> exporting to image 0.2s=> => naming to docker.io/library/payments-api:slim 0.0sREPOSITORY TAG IMAGE ID CREATED SIZEpayments-api slim 4f2a9c1e77b3 3 seconds ago 21MBpayments-api naive 8b7d3f0a2c15 2 minutes ago 852MB
Same source, same binary, 852MB down to 21MB. That is a 40x cut, and nothing was compressed to get there. The builder's layers were never part of the final image in the first place. A 21MB image lands on every node in a fraction of the time, and it hands an intruder close to nothing: no shell to spawn, no curl to fetch a payload, no package manager to install one.
One Dockerfile, several outputs
Naming your stages buys a second trick. Add a stage that does nothing but run the tests, then tell the build to stop there. --target builds up to the stage you name and no further.
FROM golang:1.23 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /out/server ./cmd/serverFROM build AS testRUN go test ./...FROM gcr.io/distroless/static:nonroot AS finalCOPY --from=build /out/server /serverUSER nonrootENTRYPOINT ["/server"]
Now CI (Continuous Integration, the automated system that builds and tests every change you push) gets two jobs out of one file. Build the test target to run the suite against the exact tree the release is compiled from. Build the default target to produce the tiny image. The two cannot drift apart, because they share every stage that comes before them.
$ docker build --target test -t payments-api:test .
[+] Building 8.7s (11/11) FINISHED=> [build 1/6] FROM docker.io/library/golang:1.23 0.0s=> CACHED [build 2/6] WORKDIR /src 0.0s=> CACHED [build 3/6] COPY go.mod go.sum ./ 0.0s=> CACHED [build 4/6] RUN go mod download 0.0s=> CACHED [build 5/6] COPY . . 0.0s=> CACHED [build 6/6] RUN CGO_ENABLED=0 go build -o /out/... 0.0s=> [test 1/1] RUN go test ./... 2.3s=> => # ok github.com/acme/payments/cmd/server 0.412s=> exporting to image 0.1s=> => naming to docker.io/library/payments-api:test 0.0s
If go test fails, the RUN fails, the build fails, and the pipeline stops before anything gets tagged. COPY --from is not limited to your own stages, either. COPY --from=nginx:1.27 /etc/nginx/mime.types . pulls a single file straight out of somebody else's published image, with no package installed to get at it.
The modern builder handles one more thing for you. BuildKit, the build engine that has been the default since Docker 23 and is what you get in 27.x, works out which stages your target actually depends on and builds only those. Park a lint stage or a docs stage next to the build stage and a normal image build skips them entirely. Stages that do not depend on each other run at the same time. Anything you did not ask for costs you nothing.
What actually leaves the builder
A multi-stage Dockerfile is two or more images welded into a single build. The builder stage holds compilers, package managers and test tooling. The final stage starts clean, usually on a slim or distroless base, and COPY --from=builder brings across only the artifacts you name. The rest of the builder's filesystem is dropped the moment the build finishes. That is the whole point. Your runtime image never contains gcc, npm, or a .git directory you copied by accident back in stage one. Teams that skip multi-stage usually find out the hard way, when a scanner flags build-time CVEs inside a production tag.
The trade is build complexity against attack surface. A single-stage image is easier to poke at when something breaks, because you still have a shell and a package manager inside it. But every tool you used to compile is a tool an attacker inherits after a compromise. Use multi-stage for anything that ships. If developers want a fatter image on their laptops, give them a separate debug target or a -debug tag rather than weakening the production stage. Name stages with AS builder and AS runtime so COPY --from=runtime still reads clearly six months from now.
In CI, cache mounts keep multi-stage cheap. Mount the package cache as a BuildKit cache mount inside the builder so dependency downloads survive between builds without ever landing in a layer. Pin base image digests in both stages so yesterday's builder and today's runtime cannot quietly drift apart. When a final image is still fatter than you expected, run dive or docker history against the runtime stage on its own. The bloat is nearly always an accidental COPY of a whole folder, or a package install that should have stayed in the builder.
Before you swap a single-stage Dockerfile for a multi-stage one in a real pipeline, write down the old image digest and the new one, the size of each, and which host ran the build. If the slim image misbehaves in production, that note is what lets you retag the previous digest in a minute instead of rebuilding the old Dockerfile from memory. Record the exact --target names your pipeline calls, too, and the sizes docker images reported on a healthy build. A teammate should be able to reproduce your 21MB from the ticket alone.
Try this
Run this on a lab engine; Docker 24 or newer is fine. Read the sample output first, so you know what a healthy result looks like before you lean on any of it in production.
$ cat > Dockerfile <<'EOF'FROM golang:1.22-alpine AS builderWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /out/app .FROM gcr.io/distroless/static:nonrootCOPY --from=builder /out/app /appUSER nonroot:nonrootENTRYPOINT ["/app"]EOF$ docker build -t demo:ms .# naming stages keeps COPY --from readable; final image has no go toolchain$ docker image ls demo:msREPOSITORY TAG IMAGE ID SIZEdemo ms 3f8c1a9b2d4e 8.4MB
Takeaway
Compile with a heavy toolkit, ship a thin runtime. COPY --from only the artifact you named, give every stage an AS name, pin both bases by digest, and keep debug images on their own tag. When a scanner still flags gcc or a package manager inside your production tag, that finding is not noise. Something crossed the COPY --from line that should have stayed in the builder.