CoursesDocker in depthMulti-stage builds

Multi-stage builds

Small images with no build tools left behind.

Intermediate12 min · lesson 2 of 30

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.

Dockerfile.naive
FROM golang:1.23
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server
ENTRYPOINT ["/server"]

Build it, then look at what you got.

terminal
$ docker build -t payments-api:naive -f Dockerfile.naive .
$ docker images payments-api
output
REPOSITORY TAG IMAGE ID CREATED SIZE
payments-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

Dockerfile
FROM golang:1.23 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:nonroot
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/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.

terminal
$ docker build -t payments-api:slim .
$ docker images payments-api
output
[+] 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.0s
REPOSITORY TAG IMAGE ID CREATED SIZE
payments-api slim 4f2a9c1e77b3 3 seconds ago 21MB
payments-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.

Toolchain in, artifact out
1Builder stage
FROM golang:1.23 with compiler, modules and source (~850MB)
2Compile
RUN go build -o /out/server
3COPY --from=build
reach in, take only the finished binary
4Final image
distroless base plus binary (~21MB); builder thrown away
Only the last stage ships. Whatever stays behind in the builder is weight and attack surface production never sees.

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.

Dockerfile
FROM golang:1.23 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 build AS test
RUN go test ./...
FROM gcr.io/distroless/static:nonroot AS final
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/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.

terminal
$ docker build --target test -t payments-api:test .
output
[+] 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.

distroless/static only runs a genuinely static binary
Drop CGO_ENABLED=0, let one stray cgo dependency creep in, and go build quietly hands you a dynamically linked binary instead. cgo is the feature that lets Go call C code, and a handful of standard-library packages reach for it on their own, the net package's DNS (Domain Name System, the service that turns a name like api.example.com into an IP address) resolver being the usual culprit. That binary runs happily inside the golang builder. Then the container exits on gcr.io/distroless/static with a baffling 'exec /server: no such file or directory'. The file is sitting right there. What is missing is its dynamic loader and libc, because the static base does not carry them. Keep CGO_ENABLED=0 for a fully static build, or point the final stage at gcr.io/distroless/base, which ships glibc (the GNU C library). Either way, run the final image before you trust it, not only the builder stage.

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.

terminal
$ cat > Dockerfile <<'EOF'
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
EOF
$ docker build -t demo:ms .
# naming stages keeps COPY --from readable; final image has no go toolchain
$ docker image ls demo:ms
REPOSITORY TAG IMAGE ID SIZE
demo 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.

Quick check
01Your teammate's single-stage image is 852MB. Yours, built from the same source and producing the same binary, is 21MB. Where did the other 831MB go?
Correct — The final stage starts from distroless and receives one copied binary. The build stage's layers exist while the build runs, but nothing in the image you tagged points at them, so they never ship.
Incorrect — No. Nothing gets squeezed down. The builder's layers are absent from the final image altogether.
Incorrect — No. The final image never references the builder stage, that is all. docker build deletes nothing, and intermediate stages often stay around as cache.
02Pull speed aside, why does shipping the 21MB distroless image instead of the 852MB builder image narrow what an intruder can do once inside a compromised container?
Incorrect — distroless encrypts nothing. It leaves tools out; it does not lock files down.
Correct — A minimal base removes the very programs an attacker reaches for after breaking in.
Incorrect — Running as root widens access rather than limiting it. distroless/static:nonroot runs as a non-root user, which is a separate protection from its lack of tools.
Incorrect — Size has nothing to do with runtime writability. Every container still gets a writable layer on top.
03Your multi-stage build compiles cleanly in the golang builder, but the final container on gcr.io/distroless/static dies straight away with 'exec /server: no such file or directory', and /server is plainly there. What is the likely cause?
Incorrect — The binary is present, as the question says. A genuinely missing file fails in a different way.
Incorrect — The Dockerfile sets ENTRYPOINT ["/server"]. The error is about running the file, not about finding the instruction.
Incorrect — An architecture mismatch can look similar in theory, but the cause here is dynamic linking against a libc the static base does not carry.
Correct — Without CGO_ENABLED=0, a cgo dependency yields a dynamic binary that the static base cannot start.

Related