Learning paths / Containers / Writing Dockerfiles

Multi-stage builds

Reading · 6 min · Module 4, lesson 2 of 646 min left in this module

Module 4 · Writing DockerfilesLesson 2 of 6

Goal: Use a multi-stage Dockerfile to keep build tools out of the image you ship.

Key idea

Building an app needs compilers, package managers and caches. Running it needs almost none of that. A multi-stage build does the building in one stage and copies only the result into a fresh, small final stage. Only the last stage becomes your image.

The problem with one stage

A single-stage Dockerfile ships whatever it built with. For a Go app, FROM golang brings the whole Go toolchain, a full Linux userland, your source code and the build cache, all to run one program a few megabytes in size. Every one of those files is downloaded on each deploy and is something an attacker could use.

Two stages, one image

This is the Dockerfile behind learn-sample-api, the image you ran in module 3, simplified and on golang:1.27, the Go image the rest of this path uses:

# Stage 1, named "build": the Go toolchain compiles the app
FROM golang:1.27 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .

# Stage 2: the image you ship starts from a nearly empty base
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Three things make it work:

  • AS build names the first stage.
  • A second FROM starts over with an empty slate: nothing from stage 1 carries across on its own.
  • COPY --from=build reaches back into the named stage and copies one file.

CGO_ENABLED=0 makes Go produce a program that needs no system libraries, which is what a distroless static base expects. Without it, a build on an image that has a C compiler, such as this Debian-based golang:1.27, gives a container that fails with exec /app: no such file or directory, even though the file is there.

What it saves

Built on a Mac, the build stage on its own is 1.44 GB on disk. The final image is 20.2 MB: the distroless base plus one program. The toolchain did its job and stayed behind.

Small doesn't mean bare. The distroless static base still carries CA certificates and time-zone data, so the app can make HTTPS calls and format local times. It has no shell and no package manager, so nothing can be installed into a running container.

The same shape works for any language that produces something you can copy: a Go or Rust binary, a Java .jar (onto a JRE-only base), or a front end's static files (onto a small web server image). Interpreted apps, like Node.js and Python, gain less, but still leave compilers and dev dependencies in the build stage.

Build or debug one stage on its own

docker build --target build -t my-app:build . stops after the named stage and tags it, so you can run it and look inside. That image has a shell, unlike the distroless final one.

Stages you don't reference from the final stage, directly or through another stage, are skipped, so a Dockerfile can carry a test stage that runs only with --target test.

Check yourself

A two-stage Dockerfile compiles in stage 1 and copies the binary into stage 2. Is the Go compiler in the final image?
You build a Go app in a golang:1.27 stage and copy it onto gcr.io/distroless/static. It fails with exec /app: no such file or directory. The file is there. What's missing?