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 buildnames the first stage.- A second
FROMstarts over with an empty slate: nothing from stage 1 carries across on its own. COPY --from=buildreaches 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