Git, CI/CD & GitOpsLIII · Container CIContainer CI
Multi-stage builds and distroless images
What you'll learn
- Explain how a multi-stage Dockerfile separates the build environment from the runtime environment
- Quantify the size and attack-surface reduction a distroless final image provides
- Identify what distroless keeps and what it removes
- Choose a runtime user and entrypoint for a production container
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A production image should contain exactly what the application needs and nothing else. Multi-stage builds and distroless base images are the two mechanisms that turn that principle into a Dockerfile. Both serve the same argument from two directions: multi-stage builds remove the build toolchain from the final image, and distroless images remove the OS from the final image.
Multi-stage in one Dockerfile
A multi-stage Dockerfile is one file with multiple FROM lines.
The first stage compiles; later stages copy artefacts forward and
discard everything else.
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 /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
The two stages share a build context but produce different final
images. The first stage ends in a binary on the local filesystem;
the second stage starts from a different base. The --from=build
flag on COPY is what carries the binary across stages while
discarding the toolchain, the cache, the source, and every other
file the build wrote. The final image is the second stage’s
filesystem union, period; the first stage never enters it.
flowchart LR
A["Stage 1: build toolchain"] -->|"COPY --from=build /out/app"| B["Stage 2: runtime base"]
B --> C["Final image"]
A -.discarded.-> X["Not in final image"]
The size difference is the headline. A golang:1.22 image is ~800
MB; a gcr.io/distroless/static-debian12 image is ~2 MB. A binary
compiled in stage 1, copied to stage 2, produces a 5-15 MB final
image. Pull time, registry storage, scan time, and CVE surface all
fall with size.
What distroless actually is
A distroless image is not a stripped-down Linux. It is not Linux
at all in the conventional sense. The distroless/static images
ship only:
- A
/etc/passwdwith anonrootuser. - A minimal
/etc/nsswitch.conf. - A
/procand/sysmountpoint. - The application’s binary and runtime libraries.
The distroless/base and distroless/debian12 images add back
the standard C library and the few pieces of the GNU coreutils
that some interpreted runtimes (Python, Java) need. They do not
add a shell, a package manager, an OpenSSH daemon, or any tool that
has historically been a foothold for exploitation.
Three consequences worth knowing:
docker execis near-useless. A shell would be a vulnerability surface; distroless does not ship one. Debugging is via logs, attached processes, or a debug variant of the same image.apk add/apt-get installare unavailable. Any runtime dependency that needs a binary not in the image must be added in the build stage, not at startup.USER nonroot:nonrootis the default. The distroless tagstatic-debian12:nonrootruns as UID 65532 by default, which means a kernel-level escape lands as nobody on the host.
The security argument
Two benefits accumulate as the image shrinks:
- CVE surface shrinks. A 2 MB static image inherits zero
CVEs from packages - there are no packages. A 50 MB
distroless/baseimage inherits the CVEs of libc and a handful of libraries, which is a closed set the team can monitor. A 400 MBpython:3.12-slimimage inherits the CVEs of every Debian package the slim image ships, plus the Python standard library, plus whatever pip pulled in for the application. - Kernel-level exploit cost goes up. A kernel exploit that
escapes a container runs as whatever UID the container started
with. In an image running as
nonroot, the exploit lands as a user with no privileged files; in an image running as root, the exploit lands as root. The blast radius is different by orders of magnitude.
The security argument for multi-stage is the same: every file in the final image is a file the runtime can touch. A compiler in the final image is a file the runtime does not need and an attacker can use.
When distroless is the wrong choice
Distroless is not free. Two situations argue against it:
- Debugging interactive workloads. A
kubectl execthat fails because the image has no shell is a debugging cost. Teams that ship to a platform where on-call engineers rarely need shell access should choose distroless; teams that rely on shell debugging for every incident should not. - Applications that need OS utilities. A workload that invokes
bash,awk, ornccannot run onstatic-distroless. If the application is not really static, the right base isdistroless/baseordistroless/debian12, notstatic.
Production discipline
- Pick one base image per language and stick with it. A
proliferation of
python:3.12,python:3.12-slim, anddistroless/python3-debian12across services creates a CVE monitoring matrix that no security team can keep up with. - Pin the distroless tag by digest, not by tag, in source. Same
argument as production images:
gcr.io/distroless/static-debian12:nonrootis a mutable label;gcr.io/distroless/static-debian12@sha256:...is the contract. - Always
USER nonrootfor non-infrastructure workloads. Root in a container is a footgun; the migration path off distroless if the team needs an OS tool is to layer only that tool, not to fall back to root. - Smoke-test the entrypoint in CI. A binary that requires a
library the base does not have will start as
exec failed. This is a misconfiguration caught on first boot; it is also cheap to catch in the image test phase (LIII-04).
Cross-course references
- Containerisation for Production Sysadmins - Part VIII (multi-stage patterns) demonstrates the same pattern in non-Dockerfiles: Buildah and Podman produce equivalent final images with the same distroless argument.
- Container Security for Production Sysadmins - Parts III-IV (image hardening) cover dropCapabilities, no-new-privileges, and read-only filesystems, which are the runtime complement to a distroless image.
Quiz
Knowledge check · 4 questions
Q1. In a multi-stage Dockerfile, which line is responsible for discarding the build toolchain from the final image?
Q2. A container built FROM gcr.io/distroless/static-debian12:nonroot starts running as UID 65532 by default and inherits no shell, package manager, or coreutils.
Q3. Name two distinct benefits the final image gets from shrinking via multi-stage and distroless, beyond just download time.
Q4. Diagnose why a distroless deployment kept failing exec probes even though the image passed the CVE scan.
A team moves a Python service from python:3.12-slim to gcr.io/distroless/python3-debian12:nonroot. The CVE scan improves dramatically (Debian Slim had 40 packages with 12 CVEs; distroless has 6 packages with 2 CVEs). Two days later, Kubernetes liveness probes start failing with 'exec failed: executable file not found'. The Dockerfile is unchanged except for the FROM line; the application invocation is CMD ["python", "-m", "app"].
Passing score: 75%. Answers are checked in this browser.