A distroless image is a container image that holds your app and the runtime it needs, and nothing else: no shell, no package manager, no debugging tools. Less in the box means less to patch, less to scan and far less for an attacker to work with.
What a typical image carries
Most Dockerfiles start from a general-purpose base such as debian, ubuntu, node or python. Those images are built to be comfortable for people. They ship a full operating system userland: a shell (sh or bash), a package manager (apt or apk), core utilities like ls, cat and grep, and often networking tools such as curl.
That is handy while you develop. You can open a shell in the container, poke around and install something to test a theory. In production, though, your app almost never calls any of it. A web service written in Go needs its binary, CA certificates for talking to HTTPS services and time zone data. A Python app needs the interpreter and its libraries. The shell and the package manager just come along for the ride.
What distroless removes
The name is a little misleading. A distroless image still contains some files from a Linux distribution, because your runtime needs them: a C library to link against, /etc/passwd so it knows which user it runs as, and certificates to verify TLS connections. What it drops is everything a person would use to manage the system:
- no shell, so nothing can run
sh -c - no package manager, so nothing can be installed at runtime
- no general-purpose tools like
curl,wgetorls
What remains is the smallest set of files that lets one kind of program run. That is why distroless images come in flavours per runtime: a static image for self-contained binaries, and separate images for Python, Java, Node.js and so on.
Try to open a shell in one and it simply fails, because there is no sh to start:
docker run --rm -it --entrypoint sh \
cgr.dev/chainguard/nginxWhy fewer parts means fewer problems
A smaller attack surface
Suppose an attacker finds a bug in your app that lets them run a command. On a full base image, that is the start of a very bad day: they can open a shell, use curl to fetch more tools and use the package manager to install whatever else they need. In a distroless container, the same bug leads to a dead end. There is no shell to spawn and nothing to install with. It doesn't make the bug harmless, but it takes away the tools most attacks lean on next.
Fewer vulnerabilities to chase
Every package in an image is something that can have a known vulnerability, or CVE. Security scanners such as Trivy and Grype report every one they find, whether or not your app ever uses the affected package. A full OS image can produce a long list of CVEs in tools your service never touches, and someone still has to triage each one. Remove the packages and those reports go with them, leaving the ones that matter: your runtime and your own dependencies.
Smaller and quicker to ship
Fewer files means a smaller image, which pulls faster onto new servers and starts sooner when you scale out. That is a pleasant side effect rather than the main reason to switch. The security win is the point.
Where distroless images come from
Building your own base
You can strip an image down yourself, working out exactly which libraries, certificates and config files your runtime needs. It works, but it is a job for life. Those libraries receive security fixes, and each fix means rebuilding, testing and shipping again, for every service, indefinitely. Most teams shouldn't take this on.
Google's distroless images
The project that popularised the term. Google publishes images under gcr.io/distroless, built from Debian packages, with variants such as static, base, python3, java and nodejs. Each has a nonroot tag that runs as an unprivileged user, and a debug tag that adds a BusyBox shell for troubleshooting.
Chainguard images
Chainguard publishes a large catalogue of minimal, hardened images under cgr.dev/chainguard. They are built on Wolfi, Chainguard's own Linux distribution designed for containers, and rebuilt nightly, so fixes to the packages inside arrive without you doing the work. Many images also have a -dev variant that adds a shell and the apk package manager, meant for build stages and debugging rather than production.
For a popular image, switching is often a single line. Here is an nginx web server moving from the official Debian-based image to Chainguard's:
# Before: nginx on a full Debian base
FROM nginx
# After: a minimal build of nginx
FROM cgr.dev/chainguard/nginx'Often' matters there: the replacement can use a different default user, port or file layout, so read the image's documentation and test before you ship.
Building your app on a distroless base
You can't run the build itself in a distroless image: compiling needs compilers, package managers and a shell. The standard answer is a multi-stage build. Build in a full image, then copy only the result into a distroless one.
Here is a Go service built that way:
# Stage 1: the full toolchain
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app .
# Stage 2: only what runs
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
ENTRYPOINT ["/app"]The first stage has Go, Git, a shell and everything else a build needs. None of it reaches the final image. CGO_ENABLED=0 tells Go to produce a fully static binary that doesn't need a C library, so it can run on the tiny static image. The nonroot tag runs the app as an unprivileged user instead of root, another cheap layer of defence. With Chainguard, the same pattern uses cgr.dev/chainguard/go for the build stage and cgr.dev/chainguard/static for the final one.
Interpreted languages follow the same shape. For Python, install your dependencies in a full Python image, then copy the installed packages and your code into a distroless Python image that has the interpreter and little else.
Debugging without a shell
The first time something breaks in production, you will reach for a shell that isn't there. Plan for that before it happens:
- Lean on logs and metrics. With no way to look around inside the container, good logging stops being optional.
- Use a debug variant locally. Google's
debugtags and Chainguard's-devimages include a shell. Use them to investigate, but don't ship them. - Attach a debug container. In Kubernetes,
kubectl debugadds a temporary container with its own tools next to your app, so the production image stays minimal.
kubectl debug -it my-pod \
--image=busybox --target=appCommon mistakes
- Shell-form commands.
CMD python app.pyquietly runs through/bin/sh -c, which doesn't exist here. Use the exec form, such asENTRYPOINT ["/app"]. RUNsteps in the final stage. ARUNinstruction needs a shell too. Do all installing and compiling in the build stage.- Mismatched language versions. Packages installed for one Python version won't load in an image with another. Match the version exactly between stages.
- Never rebuilding. A nightly-rebuilt base only helps if you rebuild and redeploy your own image to pick up the new layers.
- Treating it as the whole security story. Your code and your dependencies still need scanning and patching. Distroless shrinks everything around them.
When to use it
Distroless images suit production services, especially anything facing the internet. Compiled languages that produce a single static binary, such as Go and Rust, are the easiest place to start.
They suit local development and CI images less well, since those genuinely need tools. They are also awkward for apps that call other programs at runtime, such as a service that shells out to git or ffmpeg: those programs have to be in the image, which pulls you back towards a fuller base.
Key takeaways
- A distroless image holds your app and its runtime, with no shell, package manager or debugging tools.
- Fewer packages means a smaller attack surface and fewer irrelevant CVEs to triage.
- Build in a full image and copy only the result into a distroless one with a multi-stage build.
- Google and Chainguard publish maintained distroless images, so you don't have to patch a base yourself.
- Rebuild often, and debug with a debug variant or
kubectl debuginstead of shipping a shell.