Minimize the base image

Distroless, multi-stage, and footprint.

Advanced10 min · lesson 18 of 24

Every package in your base image is a liability twice over. Once as something you'll have to patch the next time someone files a CVE against it (CVE = Common Vulnerabilities and Exposures, the public catalog of known software flaws), and again as a ready-made tool for whoever breaks into your pod. A big class of attacks ends the same way: some remote-code bug runs /bin/sh, opens a reverse shell back to the attacker, and now they're wandering around inside your container with a package manager and curl, pulling down their next stage. Take the shell and the tools away and that payload has nothing to run. A distroless or scratch image ships no shell, no package manager, no coreutils. So most drop-a-shell exploits just fail at the last step, and the surface you have to keep patched shrinks from a whole operating system down to the handful of libraries your app actually links against.

The trick that makes this practical is the multi-stage build. Think of a restaurant kitchen. You cook in the messy back room with every knife, pan, and sack of flour out on the counter, then send a clean plate to the table carrying only the finished dish. Same idea here. You compile in a fat builder stage that holds the whole toolchain, then copy just the one built binary into a tiny final stage that has nothing else in it. The compiler, your source code, and the package caches all stay behind in the builder and never ride along to a node.

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"]

Prove the shell is gone

A minimized base is just a claim until you check it. Two things should hold for the final image: it's a fraction of the builder's size, and there's nothing inside for an attacker to pivot with. The fastest way to test the second one is to try to open a shell in it yourself, and there are two honest ways to ask. Add --entrypoint sh to docker run, which swaps out the image's ENTRYPOINT; or, against a container that is already running, use docker exec, which starts a brand-new process and never consults ENTRYPOINT at all. What does not work is docker run payments-api:1.4.2 sh, because anything after the image name replaces CMD, not ENTRYPOINT: that command starts /server and hands it the word sh as an argument. When there's no shell on disk, either honest test fails to start one, and that's the same wall a reverse-shell payload hits at runtime.

terminal
$ docker images | grep payments-api
payments-api build-stage 812MB
payments-api 1.4.2 21MB # distroless final
$ docker run --rm --entrypoint sh payments-api:1.4.2
docker: Error response from daemon: failed to create task for container: ...
exec: "sh": executable file not found in $PATH: unknown # no shell to pivot with

Count the CVEs you deleted

Smaller is the win you can see. The quiet one is how many known-vulnerable packages you just stopped shipping. Point Trivy (the open-source image scanner from Aqua) at the fat builder base, then at the distroless final, and read the two totals next to each other. The builder drags along a full Debian userland and every flaw ever filed against it. The distroless image carries a couple of root certificates and almost nothing else, so its OS-package count usually comes back at zero. Your own application dependencies still get scanned on their own, and that's the whole point: minimizing the base clears out the distro's packages, not the libraries you chose to bundle.

terminal
$ trivy image --severity HIGH,CRITICAL golang:1.23
golang:1.23 (debian 12.8)
Total: 46 (HIGH: 40, CRITICAL: 6)
$ trivy image --severity HIGH,CRITICAL gcr.io/distroless/static:nonroot
gcr.io/distroless/static:nonroot (distroless)
Total: 0 (HIGH: 0, CRITICAL: 0) # nothing left in the base to flag

Choosing a base

Match the base to what the binary actually needs, and stop at the emptiest one that still runs. scratch is completely empty. It works only for a binary that depends on nothing outside itself: no C library, no certificates, no user lookups. The distroless family sits one notch up. The static variant the Dockerfile above uses adds just the bare files a self-contained binary tends to want anyway, chiefly the CA (Certificate Authority) root certificates so an outbound HTTPS call can verify who it's talking to, plus an /etc/passwd entry so a non-root user resolves to a real name. It does not bundle libc (the C standard library), and that's exactly why it fits only statically linked binaries. If your program is dynamically linked and needs glibc at runtime, you step up one more to distroless/base, which adds it. Alpine and Wolfi go further again: a real, minimal userland with a working package manager for when you genuinely need to install something, at the price of shipping a shell all over again. Watch which C library those two carry, though. Alpine is built on musl, so a binary linked against glibc will not start there at all; Wolfi is built on glibc, so it will. The rule doesn't change whichever you land on. Carry the least that still runs.

Match the base to the binary
What does your binary actually need?
stop at the emptiest base that still runs it
nothing but itself
scratch
empty image, no certs, no libc, no shell; nothing to exec, nothing to patch
certs + a user, still static
distroless/static
adds CA roots and an /etc/passwd entry, but no libc and no shell; nonroot variant runs as a fixed user ID (UID) 65532
glibc, or a package manager
distroless/base · Alpine / Wolfi
base adds glibc for dynamically linked binaries; Alpine (musl) and Wolfi (glibc) add a real userland and apk, but the shell comes back, so scan it and justify it
Every step down this list adds capability and attack surface. The shell you add for your own convenience is the shell an attacker inherits.

One more habit locks the win in place. A tag like :nonroot is really just a label, and labels can be moved. Whoever publishes the image can repoint :nonroot at completely different bytes overnight, which might pull in fresh CVEs or, on a bad day, something deliberately tampered with. Think of the tag as the name on a hotel-room door and the digest as a fingerprint of whoever's actually inside. Pin the base by its digest and you're naming the exact bytes you scanned and approved. A digest is a content hash of the image, so nobody can move it out from under you without it turning into a different digest.

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

Then check two things about the image you actually built. First, that the base pin still resolves to where you think it does. Second, that the container really runs as a non-root user. crane reads the digest that any reference points to, and docker inspect prints the user baked into the image config. Two quick commands, and you're not just taking the Dockerfile's word for it.

terminal
$ crane digest gcr.io/distroless/static@sha256:d71f4f2...
sha256:d71f4f2... # a digest resolves to itself, always
$ crane digest gcr.io/distroless/static:nonroot
sha256:9a1e77b... # the floating tag already moved on; your pin didn't
$ docker inspect --format '{{.Config.User}}' payments-api:1.4.2
nonroot # not root; UID 65532 in the distroless passwd

Distroless still has libraries. Keep scanning and rebuilding on a cadence; "small" is not "immune."

Debug distroless with ephemeral debug containers or a twin debug image tagged separately — never ship the debug image to prod.

Pin digests for base images in CI so yesterday's FROM tag cannot silently change under you.

SBOMs travel with the image. Generate them in CI, store them beside the digest, and make sure your scanner can read the format you emit. A distroless image without an SBOM is harder to triage when the next CVE drops. Write the verification command next to the control in the same pull request, and keep the sample output so the next on-call person can tell pass from fail without guessing.

Try this

Build a multi-stage distroless (or scratch) image, run it, and prove there is no shell to exec into. The sample program sleeps rather than exiting, because a container that has already stopped gives you a 'container is not running' error instead of the answer you're testing for.

terminal
$ mkdir distroless-demo && cd distroless-demo
$ cat > main.go <<'EOF'
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("payments-api up")
time.Sleep(time.Hour) // stay alive so there is something to exec into
}
EOF
$ cat > go.mod <<'EOF'
module demo
go 1.22
EOF
$ cat > Dockerfile <<'EOF'
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /out/app .
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
EOF
$ docker build -t payments-api:distroless .
...
=> => naming to docker.io/library/payments-api:distroless
$ docker run -d --name app payments-api:distroless
$ docker exec app /bin/sh || echo NO_SHELL
OCI runtime exec failed: exec failed: unable to start container process:
exec: "/bin/sh": stat /bin/sh: no such file or directory: unknown
NO_SHELL
$ trivy image --severity HIGH,CRITICAL payments-api:distroless | tail -5
Total: 0 (HIGH: 0, CRITICAL: 0)
$ docker rm -f app

Takeaway

Small bases delete shells, package managers, and whole CVE classes. Multi-stage builds make distroless practical.

Quick check
01Trivy reports zero OS-package CVEs after you move payments-api onto distroless/static:nonroot. What does that number actually tell you?
Incorrect — Distroless strips the distro's packages, but your binary still links libraries (an OpenSSL, your language runtime) that Trivy reports separately and that can carry their own CVEs.
Correct — Minimizing the base removes the distro's packages; a lockfile or a statically linked library can still ship a vulnerable version, so you scan those on their own.
Incorrect — The :nonroot tag still floats upstream and can be repointed to new bytes. The digest pin is what keeps tomorrow's rebuild reproducible.
Incorrect — Zero CVEs is about packages, not identity. Root is controlled by USER and runAsNonRoot, which you verify separately with docker inspect or a policy check.
02The lesson pins the base as FROM gcr.io/distroless/static:nonroot@sha256:d71f4f2... instead of just :nonroot. What does pinning by digest protect against?
Incorrect — a digest is a content hash for identity, not a compression scheme.
Incorrect — the user the image runs as is unrelated to whether you pin by digest or tag.
Correct — a tag is a movable label; a digest names the exact bytes you scanned and approved, so nobody can swap them underneath you.
Incorrect — pinning changes nothing about what the scanner inspects.
03Your service is a dynamically linked binary that needs glibc at runtime. You build it onto gcr.io/distroless/static:nonroot and it crashes on startup. What's the fix that keeps the base minimal?
Incorrect — scratch is emptier still and also lacks libc, so it would fail the same way.
Correct — distroless/static omits libc on purpose; distroless/base adds glibc while staying shell-free and minimal.
Incorrect — and not for the reason you might expect. Alpine's userland is built on musl, not glibc, so your binary would not start there either; it dies hunting for the missing /lib64/ld-linux-x86-64.so.2 loader, which shows up as a baffling 'no such file or directory'. On top of that you'd be handing back the shell and package manager you just removed.
Incorrect — the crash is a missing C library, not a user or permission problem.
The debug image sneaks the shell back in
Distroless has no shell, and that stings the first time a pod is misbehaving and your kubectl exec lands you nowhere to type. The tempting fix is to rebuild on the :debug variant, which bundles busybox so you can poke around inside. Great for a local repro, quietly disastrous in production, because it hands every attacker the exact shell you worked to remove. Debug a running production pod with an ephemeral container instead (kubectl debug attaches one at incident time without baking it into the image), and keep prod on the shell-less base. Then confirm what you actually shipped: docker run --rm --entrypoint sh payments-api:1.4.2 should still fail to find a shell.

Related