Skip to main content
RunBook Academy

Docker & ContainersV · Dockerfiles & BuildKitMinimal images

Minimal images — distroless, scratch, and when to use which

Intermediate⏱ ~28 mindocker

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

Not yet marked complete on this device.

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

BaseApprox. sizeShellPackage managerlibcUse for
ubuntu:24.04~78 MByesaptglibcGeneral workloads that need a full distribution
debian:bookworm-slim~75 MByesaptglibcSame as above, fewer packages
alpine:3.21~8 MByes (busybox)apkmuslSmall, but musl compatibility differences
gcr.io/distroless/static-debian12~2 MBnonononeStatic binaries (Go, Rust)
gcr.io/distroless/base-debian12~20 MBnonoglibcBinaries that need libc and OpenSSL
gcr.io/distroless/cc-debian12~25 MBnonoglibc + libstdc++C/C++ and other libstdc++ consumers
scratch0nonononeStatic binaries you trust completely

Distroless in depth

Distroless images contain:

  • A libc (in the non-static variants).
  • CA certificates.
  • /etc/passwd with a nonroot user (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 directory even 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 base or cc rather than static.
  • 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.

Read-only / Safeinspect from host
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
Read-only / Safeproc root
# 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 etc

Illustrative 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.

Service impact possibledebug sidecar
CONTAINER=web
docker run --rm -it \
--network "container:$CONTAINER" \
--pid "container:$CONTAINER" \
nicolaka/netshoot

Inside 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

  1. Q1. `distroless` images contain:

  2. Q2. A Go binary must be statically linked — for example built with CGO_ENABLED=0 — before it will run on a `scratch` image.

  3. 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?

  4. Q4. You need to inspect the filesystem of a running `scratch`-based container. Which approach works without modifying the image?

  5. Q5. Which of these commonly break when moving an application from debian:bookworm-slim to scratch? Select all that apply.

  6. Q6. `gcr.io/distroless/cc-debian12` includes a shell for debugging.

Passing score: 75%. Answers are checked in this browser.