Why I Switched to Distroless Base Images

I switched our production containers to distroless base images for one blunt reason: the vulnerability scan came back with a fraction of the findings, and most of what remained was actually in my code rather than in a shell I never asked for.

A distroless image ships your application, its language runtime, a handful of CA certificates and time-zone data, and nothing else. No shell. No package manager. No apt, no coreutils, no busybox. If you have spent any time reading the output of a container scanner, you already know why that matters: the overwhelming majority of the CVEs a tool like Trivy flags on a typical debian or alpine base are in operating-system packages you never call. Distroless deletes the packages, and the findings go with them.

This is a field note on why the switch was worth it, what the Dockerfile actually looks like, and the one real thing you give up.

Free · 4 minutes

Would you survive contact with a determined attacker — or an auditor?

Fourteen questions on access, patching, detection, and recovery — the basics that prevent most real incidents, and the ones most often assumed rather than verified. Banded finding on screen, full sheet by email.

What “distroless” actually removes

Google’s distroless images live under gcr.io/distroless and come in a small family: static for statically linked binaries, base and cc for programs that need glibc or a C runtime, and language-specific images such as python3, java21 and nodejs22. Each is pinned to a Debian release (currently debian12 and debian13) and offered under four tags: latest, nonroot, debug and debug-nonroot.

The point is what is absent. There is no /bin/sh. That single fact is doing most of the security work. A container with no shell is dramatically harder to pivot inside once an attacker has a foothold: the standard playbook of dropping into a shell, pulling down tooling with curl or wget, and installing a package to move laterally simply has nothing to run. You have not made exploitation impossible, but you have removed the convenient interactive layer most post-exploitation tooling assumes is there.

The three effects compound. Fewer packages means fewer CVEs on the scan, which means less time triaging findings that were never yours to fix. Fewer packages also means a smaller image — a Go service on static lands in single-digit megabytes rather than the tens or hundreds a full base carries — which pulls and starts faster, and that shows up in deploy frequency and lead time when you are shipping often. And no shell means the attack surface of a compromised container collapses to your process and its runtime.

The Dockerfile: build fat, ship thin

Distroless only works with a multi-stage build. You compile in a full-featured builder image that has your compiler and toolchain, then copy just the artefact into the distroless runtime stage. Here is the pattern for a Go service, which is the cleanest case because a static binary needs nothing but the kernel.

# ---- build stage: full toolchain, throwaway ----
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Static binary, no cgo, symbols stripped to keep it small.
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app

# ---- runtime stage: distroless, nothing but the app ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
# The nonroot tag already runs as UID 65532; this is belt and braces.
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Two details earn their place. CGO_ENABLED=0 forces a static binary so the static image — which has no libc at all — is enough; if you need cgo, move to gcr.io/distroless/base-debian12 instead. And the nonroot tag matters: it runs as an unprivileged user (UID 65532) by default, so a mistake in your Kubernetes securityContext does not silently leave you running as root. For an interpreted stack the shape is the same — build wheels or install dependencies in the builder, then copy the virtualenv and source into gcr.io/distroless/python3-debian12:nonroot.

Proving the CVE claim with Trivy

Do not take the reduction on faith — measure it. Build the same service on a conventional base and on distroless, and scan both.

# Only the findings you can actually act on.
trivy image --severity HIGH,CRITICAL --ignore-unfixed myapp:distroless

# Fail a pipeline when a real, fixable HIGH or CRITICAL appears.
trivy image --severity HIGH,CRITICAL --ignore-unfixed \
  --exit-code 1 myapp:distroless

The --ignore-unfixed flag is the honest one to keep on: it hides vulnerabilities with no available patch, so the count reflects work you can do rather than noise you cannot. On distroless static that count is frequently zero, because there is no OS package layer for the scanner to find anything in. Wire the --exit-code 1 form into CI so a regression blocks the merge — the same discipline as a pre-commit baseline that actually runs, applied to images rather than source.

The trade-off: you cannot exec a shell

Here is what you give up, and it is real. kubectl exec -it pod -- sh fails, because there is no sh to run. The habit of shelling into a misbehaving container to poke at the filesystem, check an environment variable or run a quick curl is gone. The first time a pod misbehaves at 2am this feels like a serious loss. It is not, once you know the two ways back in.

The first is ephemeral debug containers. kubectl debug attaches a second container, carrying whatever tooling you like, into the running pod and shares the target’s process namespace — so you get a shell without ever putting one in your production image.

# Attach a busybox sidecar sharing the app container's process namespace.
kubectl debug -it mypod --image=busybox:1.36 \
  --target=app -- sh

# Your app's filesystem is reachable through its PID:
ls /proc/1/root/

Because --target shares the process namespace, the debug container sees the application process as PID 1, and its root filesystem is readable at /proc/1/root/. You inspect the running container from the outside with full tooling, and when you detach, nothing about the production image has changed. This has been generally available since Kubernetes 1.25, so on any current cluster it is simply there.

The second route is the :debug tag. Every distroless image has a debug and debug-nonroot variant that bundles a busybox shell. Keep your production Dockerfile on the plain tag, and swap to debug-nonroot in a throwaway build when you genuinely need a shell inside the image itself:

FROM gcr.io/distroless/static-debian12:debug-nonroot

The rule I follow: production runs the shell-less tag, always; the shell is a deliberate, temporary act, never the default.

Would I do it again

Yes, without hesitation, for anything that ships a compiled binary or a self-contained runtime. The scan is quieter, the images are smaller, and the security story I tell an auditor is materially stronger: a compromised container has no shell to pivot from and runs as an unprivileged user by design. The debugging loss is a one-week adjustment, not a permanent tax — you trade a reflex for a slightly more deliberate procedure, and the procedure is better hygiene anyway. Reducing what a container can do when it is breached is the same instinct as treating your API keys as a liability rather than a convenience: the smaller the blast radius you build in advance, the less there is to contain when something goes wrong.

If your images still carry a shell, a package manager and a hundred CVEs you will never patch, ask what any of it is actually there for. In production, the answer is usually nothing.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.