AI Agents · Distroless

Distroless Containers — A Field Guide

Distroless Containers — A Field Guide is the work that turns a deploy into a system. The deployment is one moment; the system is the next 18 months of uptime, incidents, and.

John Kihiu12 min read

A distroless image contains your application and its runtime dependencies — and almost nothing else. No shell, no package manager, no coreutils, no libc-level utilities you didn't explicitly ask for. Google's gcr.io/distroless images are the reference implementation: a distroless base for Java, Node, Python, or a static Go/Rust binary, each stripped down to libc, certificates, timezone data, and the language runtime itself.

Why strip the shell

A normal Debian or Alpine-based image ships bash, coreutils, apt or apk, and dozens of small utilities nobody in production ever intentionally uses. Every one of those is attack surface: if an attacker gets remote code execution in your app, a shell and package manager give them a foothold to explore, download tools, and pivot. Without a shell, `docker exec -it container sh` doesn't work, and neither does a reverse shell payload that assumes `/bin/sh` exists. This doesn't make the app unhackable, but it removes an entire category of post-exploitation tooling for free.

Smaller images, fewer CVEs

Distroless images are also just smaller — often tens of megabytes versus hundreds for a full Debian base — and fewer installed packages means fewer packages a vulnerability scanner (Trivy, Grype, Snyk) can flag. A lot of "CVE fatigue" in container scanning comes from OS packages the application never touches.

Building for distroless with multi-stage builds

You almost never build directly on a distroless base — you need a compiler, package manager, or build toolchain to produce the artifact first. The standard pattern is a multi-stage Dockerfile: build in a full image, then copy only the compiled output into a distroless final stage.

DOCKERFILE
FROM golang:1.22 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

Go and Rust are the easiest fits since they compile to static binaries with no runtime dependency. For Node or Python, use the language-specific distroless variant (`gcr.io/distroless/nodejs20-debian12`, `gcr.io/distroless/python3-debian12`) which bundles just enough of the runtime to execute your bundled code, and still nothing else.

Debugging without a shell

The obvious cost: you can't `docker exec` in and poke around. Google ships `:debug` tag variants of most distroless images (e.g. `gcr.io/distroless/static-debian12:debug`) that include a minimal busybox shell for troubleshooting — use the debug tag locally or in staging, never in the production image. For production incident response, lean on structured logging shipped out of the container and `docker cp` for pulling files, rather than expecting to shell in live.

Debug tags are not production images

Swapping to a `:debug` tag to unblock an investigation and then forgetting to swap back reintroduces the exact shell-based attack surface distroless was meant to remove. Treat the debug tag as a local-only tool, and keep it out of the image your CI pipeline actually pushes.

When distroless doesn't fit

If your application legitimately needs to shell out — calling ImageMagick, ffmpeg, or another CLI tool as a subprocess — distroless is the wrong base unless you vendor that binary in yourself with its exact dependencies, which usually isn't worth the effort. It's also a poor fit for early-stage projects where local debugging speed matters more than hardened production posture. Distroless earns its keep once an application is stable and running at scale, where the shell was never actually being used — just sitting there as risk.

Base imageIncludes a shellTypical size
debian:bookwormYes~120 MB
alpine:3.20Yes (ash)~7 MB
gcr.io/distroless/static-debian12No~2 MB
gcr.io/distroless/nodejs20-debian12No~70 MB (runtime included)

Wrapping up

Distroless is a small, mechanical change — swap the final stage of your Dockerfile — that removes a real class of container attack surface for close to zero runtime cost. The trade-off is debuggability, and the fix for that is discipline: debug tags stay out of production, and observability tooling replaces the reflex to shell in.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.