Skip to main content
RunBook Academy

Git, CI/CD & GitOpsLIII · Container CIContainer CI

Multi-stage builds and distroless images

Intermediate⏱ ~25 mingitdocker

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

Not yet marked complete on this device.

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/passwd with a nonroot user.
  • A minimal /etc/nsswitch.conf.
  • A /proc and /sys mountpoint.
  • 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:

  1. docker exec is 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.
  2. apk add / apt-get install are unavailable. Any runtime dependency that needs a binary not in the image must be added in the build stage, not at startup.
  3. USER nonroot:nonroot is the default. The distroless tag static-debian12:nonroot runs 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/base image inherits the CVEs of libc and a handful of libraries, which is a closed set the team can monitor. A 400 MB python:3.12-slim image 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 exec that 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, or nc cannot run on static-distroless. If the application is not really static, the right base is distroless/base or distroless/debian12, not static.

Production discipline

  • Pick one base image per language and stick with it. A proliferation of python:3.12, python:3.12-slim, and distroless/python3-debian12 across 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:nonroot is a mutable label; gcr.io/distroless/static-debian12@sha256:... is the contract.
  • Always USER nonroot for 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

  1. Q1. In a multi-stage Dockerfile, which line is responsible for discarding the build toolchain from the final image?

  2. 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.

  3. Q3. Name two distinct benefits the final image gets from shrinking via multi-stage and distroless, beyond just download time.

  4. 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.