Distroless container images: shipping without a shell
Ship images with no package manager, no shell, and almost no attack surface — and still debug when you need to.
docker run --rm gcr.io/distroless/static-debian13:nonroot sh; echo exit=$?docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: exec: "sh": executable file not found in $PATHexit=127docker run -d --name api app && sleep 1 && docker logs api && docker run --rm app whoamilistening on :8080uid=65532 gid=65532docker export $(docker create gcr.io/distroless/static-debian13:nonroot /app) | tar -t | grep -E "^var/lib/dpkg/status.d/[^/]+$" | grep -v md5sums | sed "s|.*/||" | tr "\n" " "base-files ca-certificates media-types netbase tzdata tzdata-legacyno shell, no package manager, no curl: the post-exploitation toolkit is missing, the application is not. Six Debian packages, 2.2 MB, and one binary of yoursMost container intrusions follow a script after the initial code execution: spawn a shell, fetch a second stage with curl or wget, look around with coreutils, install something with the package manager. A distroless image contains the application, its runtime dependencies, CA certificates and timezone data, and none of those tools. The attacker's script stops at step one. The cost is that your scripts stop there too, and the rest of this guide is about that.
Which image, by what the binary needs
Distroless images (Debian 13 generation)
| Image | Contains | For |
|---|---|---|
static-debian13 | ca-certificates, tzdata, a root /etc/passwd entry; no libc | statically linked Go, Rust, C binaries (about 2 MiB) |
base-nossl-debian13 | static plus glibc | dynamically linked binaries that do not use OpenSSL |
base-debian13 | static plus glibc and libssl | cgo builds, most compiled languages |
cc-debian13 | base plus libgcc | Rust and C++ binaries with runtime dependencies on libgcc |
java17|21|25-debian13 | a JRE, no JDK | JVM services; copy the jar, set the entrypoint |
nodejs22|24|26-debian13 | Node runtime, no npm | built dist/ plus production node_modules |
python3-debian13 | CPython, no pip | applications with vendored site-packages |
Every image has four tags: latest and nonroot, and their debug and debug-nonroot counterparts. nonroot runs as user 65532, which is the tag to use unless the binary must bind a port below 1024 without CAP_NET_BIND_SERVICE or write to a root-owned path, both of which are better fixed in the application than by running as root. The unsuffixed names such as gcr.io/distroless/static still resolve, but the -debian13 form says which Debian package set you are inheriting security updates from.
FROM golang:1.27 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/serverFROM gcr.io/distroless/static-debian13:nonrootCOPY --from=build /app /appUSER nonroot:nonrootEXPOSE 8080ENTRYPOINT ["/app"] # vector form: there is no shell to expand a string
Four things that stop working, and what replaces them
Shell-form instructions. CMD /app --port 8080 is executed by /bin/sh -c, which does not exist; use the JSON array form for ENTRYPOINT and CMD, and for HEALTHCHECK. Health checks that shell out. HEALTHCHECK CMD curl -f http://localhost/health fails for two reasons, no shell and no curl, and the shell goes first: the probe log records exec: "/bin/sh": stat /bin/sh: no such file or directory before curl is ever looked up. Either build a --healthcheck mode into the binary (CMD ["/app", "healthcheck"]) or let the orchestrator probe over HTTP, as Kubernetes liveness and readiness probes do without any tool in the image. docker exec … sh. For a live container use kubectl debug with an ephemeral container, or run the debug-nonroot tag of the same image in a non-production environment, which adds a busybox under /busybox (on PATH, 382 applets in the image checked here) and nothing else. Package installs at runtime. There is no apt; anything the process needs is copied in at build time, including CA bundles for private authorities and locale or timezone data beyond what the base ships.
docker run -d --name api app # HEALTHCHECK CMD ["/app", "healthcheck"] in the Dockerfiledocker inspect -f "{{.State.Health.Status}}" apihealthy # after 1 s; the last probe entry: ExitCode 0 | healthydocker run --rm shellform; echo exit=$? # the same binary, but CMD /app (shell form)docker: Error response from daemon: … exec: "/bin/sh": stat /bin/sh: no such file or directoryexit=127docker run -d --name api2 --entrypoint /app shellform # started in exec form; its HEALTHCHECK is CMD curl -f …docker inspect -f "{{.State.Health.Status}}" api2unhealthy # after 5 s, two probesdocker inspect -f "{{json .State.Health.Log}}" api2 | jq -r ".[-1] | [.ExitCode, .Output] | @tsv"-1 OCI runtime exec failed: exec failed: unable to start container process: exec: "/bin/sh": stat /bin/sh: no such file or directorydocker run --rm --entrypoint sh gcr.io/distroless/static-debian13:debug-nonroot -c "id; command -v sh; ls /busybox | wc -l"uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot)/busybox/sh382the debug tag runs as the same uid and adds one directory; :nonroot itself has no sh anywhere (exit 127 again)kubectl debug -it pod/api-7d9c -n shop --image=busybox:1.37 --target=api -- shTargeting container "api". If you don't see processes from this container it may be because the container runtime doesn't support this feature./ # ls /proc/1/root/app && cat /proc/1/root/etc/passwd | grep nonrootnonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologinthe debug container shares the process namespace; the application image is unchangedThe /etc/passwd line above is the one the fixture copied out of the :nonroot image with docker cp (there is no cat inside to print it): nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin. Two operational habits follow from the tag scheme. The debug and debug-nonroot tags contain a busybox shell, so an admission rule or a CI check that rejects them for production namespaces keeps the property you chose the image for; the difference between :nonroot and :debug-nonroot is one word in a Dockerfile and is easy to leave behind after a troubleshooting session. And because the project rebuilds its images as Debian publishes package updates, pin by digest rather than by tag and rebuild your image on a schedule, so that a fix in ca-certificates or tzdata reaches production through a reviewed change rather than through whatever :nonroot happened to point at when the build ran.
When the image stops working: recovery in order of preference
| Situation | Do | Do not |
|---|---|---|
the container exits at once with exec: "/bin/sh": … no such file | the CMD, ENTRYPOINT or HEALTHCHECK is in shell form; rewrite it as a JSON array and rebuild; nothing to roll back if it never started, otherwise redeploy the previous digest first | switch the base to one with a shell to make the string form work |
the binary fails on the static base (missing libc or libssl) | redeploy the previous digest; then either build with CGO_ENABLED=0 or move to base-debian13; verify with docker run --rm <image> before the next deploy | copy libraries out of the build stage by hand |
a :debug-nonroot tag reached production after a troubleshooting session | rebuild from :nonroot (one word in the Dockerfile), redeploy, and add the admission rule or CI check that rejects debug tags so it cannot happen quietly again | leave it because the app runs: a shell is what the tag was chosen to remove |
Distroless is the runtime half of a small-image story; the build half, where the compiler lives in a stage that never ships, is in multi-stage builds. What a minimal image actually blocks, and what it does not, becomes clearer after walking through a container escape step by step and noting which steps needed a shell.