Docker & ContainersV · Dockerfiles & BuildKitMinimal images
Minimal images — distroless, scratch, and when to use which
What you'll learn
- Compare base image options on size, libc, shell and package manager
- Choose between Ubuntu, Debian slim, Alpine, distroless, and scratch
- Identify workloads that require a full base
- Debug a container that has no shell, from the host and from a sidecar
- Anticipate the four things that are missing from scratch and break applications
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
The base image is the single biggest determinant of image size, attack surface, and startup time. Choose the smallest base that runs your workload.
The part that gets left out of that advice is the bill. A minimal base removes the shell, the package manager and the coreutils — and those are the tools you reach for when a container is failing and you have fifteen minutes. Going minimal is usually correct. Going minimal without first working out how you will debug it is how a five-minute incident becomes a two-hour one.
The options
| Base | Approx. size | Shell | Package manager | libc | Use for |
|---|---|---|---|---|---|
ubuntu:24.04 | ~78 MB | yes | apt | glibc | General workloads that need a full distribution |
debian:bookworm-slim | ~75 MB | yes | apt | glibc | Same as above, fewer packages |
alpine:3.21 | ~8 MB | yes (busybox) | apk | musl | Small, but musl compatibility differences |
gcr.io/distroless/static-debian12 | ~2 MB | no | no | none | Static binaries (Go, Rust) |
gcr.io/distroless/base-debian12 | ~20 MB | no | no | glibc | Binaries that need libc and OpenSSL |
gcr.io/distroless/cc-debian12 | ~25 MB | no | no | glibc + libstdc++ | C/C++ and other libstdc++ consumers |
scratch | 0 | no | no | none | Static binaries you trust completely |
Distroless in depth
Distroless images contain:
- A libc (in the non-
staticvariants). - CA certificates.
/etc/passwdwith anonrootuser (UID 65532).- A timezone database (in some variants).
- Nothing else.
Distroless images do not contain:
- A shell (
sh,bash,dash). - A package manager.
- Coreutils (
ls,cat,cp). - Anything that could be invoked to escape the container.
The project’s own summary is that these images hold “only your application and its runtime dependencies” without “package managers, shells or any other programs”.
This means debugging is harder. You cannot docker exec a shell
into a distroless container. You must docker exec the actual
binary — and it is the only binary there.
When distroless is right
- A Go, Rust, or other compiled-static binary.
- A Java application using a JRE-only base.
- A Python application using a minimal CPython runtime.
When distroless is wrong
- An application that needs shell access to debug.
- An application whose entrypoint is a shell script — there is no
shell to run it, and the failure is
exec: "/entrypoint.sh": stat ... no such file or directoryeven though the file is right there, because the interpreter on the shebang line is missing. - An application that uses glibc and cannot be rebuilt — use
baseorccrather thanstatic. - A workload that depends on
ps,top, or other Unix tools at runtime.
Scratch
FROM scratch
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
scratch is an empty image. No filesystem, no shell, no libc. The
binary must be statically linked (no dynamic linker dependencies).
Use scratch when:
- The binary is fully static (Go with
CGO_ENABLED=0, Rust with musl, C compiled with-static). - You want the smallest possible image.
- You accept that debugging requires the kernel tools (
nsenter,strace, etc.) since there is no shell.
The four things that are missing and break applications
A real example
# Build
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app
# Runtime: scratch
FROM scratch
COPY --from=alpine:3.21 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]
The final image contains the binary and a CA bundle, and nothing else. Its size is essentially the size of the binary — typically 10–30 MB for a Go service. The attack surface is the binary itself.
Note USER 65532:65532 rather than USER nonroot: scratch has
no /etc/passwd, so a name cannot be resolved.
A real example with distroless
# Build
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app
# Runtime: distroless (adds CA certs, /etc/passwd, tzdata, /tmp)
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build --chown=nonroot:nonroot /out/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]
Same static binary, plus a nonroot user (UID 65532) that the
:nonroot tag already selects, CA certificates for TLS, and a
/tmp. Roughly 2 MB larger than the scratch version, and it
removes the four failure modes above. For almost every service
this is the better trade.
Debugging a container with no shell
This is the section to read before you need it. docker exec -it web sh will fail, and the container will be down while you work
out what to do instead.
1. From the host, through /proc
Every container’s root filesystem is visible from the host at
/proc/<pid>/root/, using the host’s tools. No shell in the
image is required, because you are not running anything in the
image.
CONTAINER=web
PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
ls -l /proc/"$PID"/root/
cat /proc/"$PID"/root/etc/ssl/certs/ca-certificates.crt | head -2
tr '\0' '\n' < /proc/"$PID"/environ# ls -l /proc/2841/root/total 14116
-rwxr-xr-x 1 root root 14454784 Jan 1 1970 app
drwxr-xr-x 3 root root 60 Aug 12 09:22 etcIllustrative output
The same trick answers most of the questions you would have used a
shell for: is the config file where the app expects it, did the
volume mount land, what is in the environment, what is the process
actually doing (cat /proc/"$PID"/status, ls -l /proc/"$PID"/fd).
2. From a sidecar sharing the namespaces
When you need to run tools against the container — a network trace, a port check — start a second container in the target’s namespaces. This is the production-safe technique, because the tools live in the debug image and never touch the one you ship.
CONTAINER=web
docker run --rm -it \
--network "container:$CONTAINER" \
--pid "container:$CONTAINER" \
nicolaka/netshootInside that sidecar you have a full toolkit, you see the target’s
listening sockets as if they were your own because you share its
network namespace, and you can read its filesystem at
/proc/1/root/ because you share its PID namespace.
Two honest caveats. Sharing the PID namespace means the sidecar can signal the target’s processes — treat it as a privileged operation and do not leave it running. And a container that has already exited has no namespaces to join; for a crash loop you need the logs and the exit code, which is a different lesson.
3. Swap the tag in staging
gcr.io/distroless/base-debian12:debug is the same image with a
busybox shell added. Reproduce the fault in staging on the :debug
tag, then fix it and go back. Never promote the :debug tag.
Knowledge check
Knowledge check · 6 questions
Q1. `distroless` images contain:
Q2. A Go binary must be statically linked — for example built with CGO_ENABLED=0 — before it will run on a `scratch` image.
Q3. A distroless container is serving traffic correctly but reports unhealthy. `.State.Health.Log` shows `exec: "/bin/sh": stat /bin/sh: no such file or directory`. What is wrong?
Q4. You need to inspect the filesystem of a running `scratch`-based container. Which approach works without modifying the image?
Q5. Which of these commonly break when moving an application from debian:bookworm-slim to scratch? Select all that apply.
Q6. `gcr.io/distroless/cc-debian12` includes a shell for debugging.
Passing score: 75%. Answers are checked in this browser.