Minimize the base image
Distroless, multi-stage, and footprint.
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.
# builder: full toolchain, thrown awayFROM golang:1.23 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server# final: one static binary, no shell, non-rootFROM gcr.io/distroless/static:nonrootCOPY --from=build /out/server /serverUSER nonrootENTRYPOINT ["/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.
$ docker images | grep payments-apipayments-api build-stage 812MBpayments-api 1.4.2 21MB # distroless final$ docker run --rm --entrypoint sh payments-api:1.4.2docker: 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.
$ trivy image --severity HIGH,CRITICAL golang:1.23golang:1.23 (debian 12.8)Total: 46 (HIGH: 40, CRITICAL: 6)$ trivy image --severity HIGH,CRITICAL gcr.io/distroless/static:nonrootgcr.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.
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.
# pin the base by digest, not a floating tagFROM 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.
$ crane digest gcr.io/distroless/static@sha256:d71f4f2...sha256:d71f4f2... # a digest resolves to itself, always$ crane digest gcr.io/distroless/static:nonrootsha256:9a1e77b... # the floating tag already moved on; your pin didn't$ docker inspect --format '{{.Config.User}}' payments-api:1.4.2nonroot # 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.
$ mkdir distroless-demo && cd distroless-demo$ cat > main.go <<'EOF'package mainimport ("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 demogo 1.22EOF$ cat > Dockerfile <<'EOF'FROM golang:1.22 AS buildWORKDIR /srcCOPY go.mod main.go ./RUN CGO_ENABLED=0 go build -o /out/app .FROM gcr.io/distroless/static:nonrootCOPY --from=build /out/app /appUSER nonroot:nonrootENTRYPOINT ["/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_SHELLOCI runtime exec failed: exec failed: unable to start container process:exec: "/bin/sh": stat /bin/sh: no such file or directory: unknownNO_SHELL$ trivy image --severity HIGH,CRITICAL payments-api:distroless | tail -5Total: 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.