Docker path · Building images · Topic 5 of 12

Multi-stage builds and small images

Not started

Building software needs compilers, package managers, headers and caches. Running it usually needs only the result. A multi-stage Dockerfile uses one stage with the full toolchain to build, then starts a fresh, minimal stage and copies in just the artefact. The image you ship never contains the build tools.

Why it matters: Every compiler, package manager and shell you ship is more to download, more to scan and more for an attacker to use. Multi-stage builds leave them behind.

  1. 1Several FROMs, one image

    Each FROM starts a new stage. Only the last stage (or the one you --target) becomes the image; earlier stages are scaffolding.

  2. 2Copy just the result

    COPY --from=build takes the compiled binary or bundle out of the build stage and nothing else.

  3. 3Pick the runtime base on purpose

    Debian slim, Alpine, distroless and scratch trade size against libc compatibility, debuggability and tooling.

  4. 4Smaller means safer

    Fewer packages mean fewer CVEs to triage and no shell or package manager for an intruder to use.

1

How it works

The explanation, with examples you can run.

A Go service, two stages

# syntax=docker/dockerfile:1FROM golang:1.27-alpine AS buildWORKDIR /srcCOPY go.mod ./RUN go mod downloadCOPY . .RUN --mount=type=cache,target=/root/.cache/go-build \    CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/hello . FROM gcr.io/distroless/static-debian13:nonrootCOPY --from=build /out/hello /helloEXPOSE 8080ENTRYPOINT ["/hello"]
docker build -t hello-go:1 .docker build --target build -t hello-go:build .   # stop after the first stage, for comparisondocker image ls --tree# IMAGE             DISK USAGE   CONTENT SIZE# hello-go:1            14.4MB         3.15MB# hello-go:build         381MB         74.4MB

The shipped image is about 3 MB to download instead of 74 MB, and it contains one binary plus the CA certificates, timezone data and /etc/passwd entries that distroless provides. With BuildKit, stages that the target does not depend on are skipped, and independent stages build in parallel.

The same pattern works for any language: build a frontend bundle with Node and copy dist/ into an nginx or static-server stage; run mvn package in a JDK stage and copy the jar into a JRE-only stage; install Python wheels in a builder and copy the virtual environment into a slim runtime.

Choosing the runtime base

BaseSizeHas a shell?Notes
debian:*-slim, python:*-slimtens of MByesglibc, apt; the most compatible choice
alpinea few MByes (BusyBox)musl libc: some prebuilt binaries and DNS edge cases differ
distrolessa few MB and upno (:debug tags add BusyBox)runtime files only; :nonroot tags run as UID 65532
scratchemptynoonly for fully static binaries; you add certificates yourself

There is no single right answer. Alpine is small but its musl libc is a different C library from the glibc most software is tested against. Distroless removes the shell and package manager, which shrinks the attack surface and the CVE list, but you debug from outside the container. Slim Debian images are larger and boring, which is often what you want for interpreted languages with native extensions.

What to check

  • docker run --rm --entrypoint sh hello-go:1 fails with exec: "sh": executable file not found in $PATH: no shell, as intended.
  • docker image inspect hello-go:1 --format '{{.Config.User}}' prints 65532: the :nonroot base already sets a non-root user.
  • Dev dependencies stay out: for Node, run npm ci --omit=dev (or prune) in the stage you copy node_modules from.
  • Keep the build stage's base pinned too; a new compiler version is a change like any other.
2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.What ends up in the image built from this Dockerfile?
    FROM golang:1.27-alpine AS buildWORKDIR /srcCOPY . .RUN CGO_ENABLED=0 go build -o /out/hello . FROM gcr.io/distroless/static-debian13:nonrootCOPY --from=build /out/hello /helloENTRYPOINT ["/hello"]
  2. 2.docker exec -it app sh fails with 'executable file not found' on a distroless image. Why?
  3. 3.A Python wheel with C extensions works on python:3.13-slim but crashes on Alpine. What is the likely cause?

0 of 3 right so far. Get all three to finish this topic.