Minimize the base image

Distroless, multi-stage, and footprint.

Advanced10 min · lesson 18 of 24

Every package in your base image is two liabilities at once: a component you have to patch when its next CVE lands, and a tool an intruder can use after they break in. Minimizing the base image cuts both. A distroless or scratch base carries no shell, no package manager, and no coreutils, so most drop-a-shell payloads simply fail — there is no /bin/sh to exec — and the CVE surface you track shrinks from a full OS to a handful of libraries.

The mechanism is the multi-stage build: do the compiling in a fat builder stage with the whole toolchain, then copy only the finished artifact into a tiny final stage that has nothing else. The build tools, source, and package caches stay in the builder and never ship.

Dockerfile
# builder: full toolchain, thrown away
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
# final: one static binary, no shell, non-root
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]

Measure what you removed

The numbers make the argument: the golang builder is ~800 MB with a compiler, shell, and package manager; the distroless result is ~20 MB with a single binary. Every megabyte removed is a dependency you no longer patch and a capability an attacker no longer finds — and a smaller image pulls faster onto every node, which matters at scale.

terminal
$ docker images | grep payments-api
payments-api build-stage 812MB
payments-api 1.4.2 21MB # distroless final
$ docker run --rm payments-api:1.4.2 sh
docker: Error ... exec: "sh": executable file not found in $PATH # no shell to pivot with

Choosing a base

Match the base to the binary. scratch (empty) works for a fully static binary and nothing else. Distroless adds just libc and CA certs, with nonroot and debug variants. Alpine or Wolfi give you a real but minimal userland and a package manager when you genuinely need one — at the cost of a shell being present. Whatever you pick, pin it by digest so tomorrow’s rebuild uses the exact bytes you scanned today.

Dockerfile
# pin the base by digest, not a floating tag
FROM gcr.io/distroless/static:nonroot@sha256:d71f4f2...
# a tag can be repointed upstream; a digest is the content itself
latest is a moving target
A floating base tag re-imports whatever landed upstream since you last looked — new CVEs, and occasionally tampering. Pin bases by digest and rebuild deliberately. And the smallest base you can run is the best base: no shell means most post-exploitation tooling has nothing to grab onto.