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.

Apr 2, 2026·Updated ·5 min readAdvanced·By SecOpsLog · command-tested
bash — observed: the property you are buying (static-debian13:nonroot, Docker Engine 28.5.2, linux/arm64)observed
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 $PATH
exit=127
docker run -d --name api app && sleep 1 && docker logs api && docker run --rm app whoami
listening on :8080
uid=65532 gid=65532
docker 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-legacy
no 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 yours

Most 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)

ImageContainsFor
static-debian13ca-certificates, tzdata, a root /etc/passwd entry; no libcstatically linked Go, Rust, C binaries (about 2 MiB)
base-nossl-debian13static plus glibcdynamically linked binaries that do not use OpenSSL
base-debian13static plus glibc and libsslcgo builds, most compiled languages
cc-debian13base plus libgccRust and C++ binaries with runtime dependencies on libgcc
java17|21|25-debian13a JRE, no JDKJVM services; copy the jar, set the entrypoint
nodejs22|24|26-debian13Node runtime, no npmbuilt dist/ plus production node_modules
python3-debian13CPython, no pipapplications 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.

Dockerfile
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/server
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/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.

bash — observed: the two health checks, and the shell-form CMD, on the same baseobserved
docker run -d --name api app # HEALTHCHECK CMD ["/app", "healthcheck"] in the Dockerfile
docker inspect -f "{{.State.Health.Status}}" api
healthy # after 1 s; the last probe entry: ExitCode 0 | healthy
docker 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 directory
exit=127
docker run -d --name api2 --entrypoint /app shellform # started in exec form; its HEALTHCHECK is CMD curl -f …
docker inspect -f "{{.State.Health.Status}}" api2
unhealthy # after 5 s, two probes
docker 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 directory
docker 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/sh
382
the debug tag runs as the same uid and adds one directory; :nonroot itself has no sh anywhere (exit 127 again)
bash — representative: debugging without a shell in the image (kubectl debug; no cluster in the fixture)
kubectl debug -it pod/api-7d9c -n shop --image=busybox:1.37 --target=api -- sh
Targeting 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 nonroot
nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin
the debug container shares the process namespace; the application image is unchanged

The /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

SituationDoDo not
the container exits at once with exec: "/bin/sh": … no such filethe 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 firstswitch 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 deploycopy libraries out of the build stage by hand
a :debug-nonroot tag reached production after a troubleshooting sessionrebuild 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 againleave it because the app runs: a shell is what the tag was chosen to remove
What was run for this article
Docker Engine 28.5.2 on linux/arm64 with gcr.io/distroless/static-debian13:nonroot and :debug-nonroot pulled by digest on 2026-09-13, and the fixture Go service built on the article’s Dockerfile plus a shell-form variant. The terminal blocks marked observed are copied from that run; fifteen exit codes are asserted: the missing shell, the uid, the passwd entry, the absence of sh/curl/apt paths in the exported filesystem, the six dpkg entries, both health checks, the shell-form CMD and the debug tag. The Java, Node and Python images, the vulnerability comparison in the split below and the kubectl debug session are representative and were not executed; no scanner was run, so no vulnerability counts are quoted. The recovery table follows the documented model.
Distroless removes tools, not mistakes
A distroless image that runs as root, has a secret file copied into it, or mounts the Docker socket is still a compromised host waiting to happen. The combination that holds is a minimal base, a non-root user, a read-only root filesystem and dropped capabilities; the base image supplies one of the four.
What a scanner sees
debian:bookworm-slim
bash, sh, dpkg, apt
coreutils, findutils, grep
perl-base, libc6, gpgv and their dependencies
dozens of packages to track
static-debian13:nonroot
no shell, no package manager
no network client
ca-certificates, tzdata, netbase, base-files
six Debian packages, then one binary of yours

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.

Related posts

Quick reference