DevgainsDevgainsDevgains
All articles

Multi-Stage Docker Builds: Smaller, Faster, Safer Images

·5 min read
Multi-Stage Docker Builds: Smaller, Faster, Safer Images

Photo: Unsplash

A production image should contain exactly what your application needs to run and nothing else. No compilers, no dev headers, no test fixtures, no package manager caches. Yet the typical single-stage build ships all of it, because everything you install to compile the app stays in the final image alongside the app itself.

Multi-stage builds fix this by letting one Dockerfile define several intermediate images and copy only the finished artifacts into a clean final stage. The build tooling lives in a stage that gets thrown away. What ships is small, fast to pull, and carries far fewer vulnerabilities. This is one of the highest-leverage changes you can make to a containerized service.

The problem with a single stage

Consider a Go service. To compile it you need the Go toolchain, which is several hundred megabytes. To run the result you need a single static binary. A naive build keeps the whole toolchain in the shipped image:

FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /bin/server ./cmd/server
CMD ["/bin/server"]

This produces an image well over 800 MB to run a binary that might be 15 MB. Every node that pulls it downloads the entire Go SDK. Every vulnerability scanner flags every CVE in that SDK, none of which your running service actually exposes. You are paying storage, bandwidth, and audit cost for tools you only needed for thirty seconds.

One file, two stages

A multi-stage build splits compilation from runtime. The first stage builds; the second copies the artifact into a minimal base:

# Stage 1: build
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/server ./cmd/server
 
# Stage 2: runtime
FROM gcr.io/distroless/static:nonroot
COPY --from=build /bin/server /bin/server
USER nonroot
ENTRYPOINT ["/bin/server"]

The AS build names the first stage, and COPY --from=build reaches into it to grab just the compiled binary. The final image is based on distroless static, which contains no shell, no package manager, and no userland utilities, only the CA certificates and the binary. The result is in the low tens of megabytes instead of hundreds, and there is nothing for an attacker to pivot through because there is no shell to spawn.

A smaller image is a smaller attack surface. When there is no shell, no curl, and no package manager in the final stage, a compromised process has far fewer tools to escalate or exfiltrate with. Multi-stage builds make minimal base images practical, because the build dependencies live somewhere else.

It works for interpreted languages too

Multi-stage is not just for compiled languages. Front-end builds and Python apps benefit just as much. A Node app that needs a full toolchain to bundle assets can hand the output to a tiny static server:

FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html

The node_modules directory, the source, and the build tooling never reach production. Only the compiled dist folder is copied into the nginx image. For Python, a common pattern installs wheels into a virtual environment in the build stage and copies the populated environment into a slim runtime, leaving the compiler and dev headers behind.

Stop the build at the right stage

A useful trick is that you can target any named stage directly. This lets one Dockerfile serve both CI and production. Suppose you add a test stage between build and runtime:

FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/server ./cmd/server
 
FROM build AS test
RUN go test ./...
 
FROM gcr.io/distroless/static:nonroot
COPY --from=build /bin/server /bin/server
ENTRYPOINT ["/bin/server"]

In CI you build the test target to run the suite, and the production build skips it entirely:

# Run tests in CI
docker build --target test .
 
# Build the shippable image
docker build --target '' -t myservice:1.4.0 .

Because the final stage only copies --from=build, the test stage is never part of the production image even though it lives in the same file. BuildKit is also smart enough to build independent stages in parallel and skip any stage the target does not depend on.

Order stages for cache reuse

Multi-stage builds inherit all the caching behavior of regular layers, so the same ordering discipline applies. Inside the build stage, copy dependency manifests and download dependencies before copying source, exactly as you would in a single-stage file. The Docker build documentation explains how the builder tracks cache across stages and how --cache-from lets you seed the cache from a registry in CI, where the build host is ephemeral and the local cache starts empty.

For multi-architecture images, multi-stage composes cleanly with docker buildx to produce linux/amd64 and linux/arm64 variants from the same definition. The build stage compiles per-platform and the runtime stage stays identical, which is exactly the separation you want.

Watch the things that quietly bloat images

Even with multi-stage, a few habits still inflate the final image. Copying an entire directory when you only need one artifact drags in extras, so be specific with COPY --from. Forgetting a .dockerignore means the build context still uploads junk into the build stage. And choosing a heavyweight runtime base, like a full distro when distroless or alpine would do, undoes much of the benefit. The discipline is the same throughout: at each boundary, copy forward only what the next stage genuinely needs.

It is also worth distinguishing a smaller image from a more secure one. Multi-stage helps with both, but pinning your base by digest, running as non-root, and scanning the final image still matter. The OCI image specification defines the format your scanners read, and a minimal final stage simply gives them less to complain about.

Takeaways

  • Split build and runtime into separate stages so compilers and dev dependencies never ship to production.
  • Use COPY --from to pull only finished artifacts into a minimal final base like distroless or alpine.
  • Multi-stage works for interpreted languages too: build assets in a heavy stage, serve them from a light one.
  • Target named stages with --target to run tests from the same file without bloating the production image.
  • Keep dependency-before-source ordering inside each stage so the build cache survives code changes.
  • A minimal final image is a smaller attack surface; pair it with non-root users and digest-pinned bases.
5 min read

Read next