Minimal bases: distroless, scratch, static and Alpine

Choose a runtime base and still be able to debug it.

Advanced15 min · lesson 6 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (4 files, 1 KB): distroless.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-int/distroless.tar.gz && tar -xzf distroless.tar.gz, which creates ~/lab/distroless/. SHA-256: 6bfa32c5d98b2428b40361b976453b745a54878a23daf1fa5c432b835075776b

docker exec lab-probe sh fails with exec: "sh": executable file not found in $PATH. On a distroless image that is the design working: there is no shell, no package manager and no downloader for an attacker who gets code execution to reach for, and no apt-get install to bring them back. The cost is that the image also lacks the small runtime files a program quietly expects, such as CA certificates, a timezone database or a user entry, and that debugging needs a different method. This lesson builds one Go program on three bases to see exactly which files matter, proves static versus dynamic linking, compares Alpine and Debian, and debugs the shell-less container from a sidecar.

Use the main lab VM. The lesson files go in ~/lab/distroless. The distroless images come from gcr.io, not Docker Hub, so they do not count against Docker Hub's pull limit.

What a distroless image contains

Google's distroless project publishes images built from Debian 13 packages: static-debian13 for programs that need no C library, base-debian13 which adds glibc and OpenSSL, cc-debian13 which adds the C++ runtime, and language images such as python3-debian13, nodejs24-debian13 and java21-debian13. Each comes in four tags: latest (runs as root), nonroot (UID 65532), and debug or debug-nonroot, which add a BusyBox shell. Pull the ones this lesson uses (the loop at the end pulls alpine:3.22 and debian:trixie-slim for the size comparison if an earlier lesson has not) and read the config of the static image:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker pull -q gcr.io/distroless/static-debian13:nonroot docker pull -q gcr.io/distroless/base-debian13:nonroot docker pull -q gcr.io/distroless/cc-debian13:nonroot docker pull -q gcr.io/distroless/static-debian13:debug-nonroot for i in alpine:3.22 debian:trixie-slim; do docker image inspect $i >/dev/null 2>&1 || docker pull -q $i; done
gcr.io/distroless/static-debian13:nonroot gcr.io/distroless/base-debian13:nonroot gcr.io/distroless/cc-debian13:nonroot gcr.io/distroless/static-debian13:debug-nonroot
$ docker image inspect gcr.io/distroless/static-debian13:nonroot --format '{{json .Config}}' | jq -c .
{"User":"65532","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"],"WorkingDir":"/home/nonroot"}
$ docker create gcr.io/distroless/static-debian13:nonroot
Error response from daemon: no command specified

The config sets a user, an environment and a working directory, and no Cmd or Entrypoint; your image supplies those. That is why docker create on the bare base fails with no command specified. To look inside such an image, give docker create any command at all; it is never run:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ id=$(docker create gcr.io/distroless/static-debian13:nonroot /nonexistent) docker export "$id" > static.tar docker rm "$id" >/dev/null echo "entries: $(tar -tf static.tar | wc -l)" echo "zoneinfo: $(tar -tf static.tar | grep -c '^usr/share/zoneinfo/')" mkdir rootfs && tar -xf static.tar -C rootfs echo "ELF files: $(find rootfs -type f -exec sh -c 'head -c 4 "$1" | grep -q ELF && echo "$1"' _ {} \; | wc -l)" tar -tf static.tar | grep -E '(^|/)(sh|bash|busybox|apt|dpkg|apk|curl|wget)$' || echo "no shell, package manager or downloader" tar -xOf static.tar etc/passwd tar -tf static.tar | grep -E '^etc/ssl/certs/'
entries: 1400 zoneinfo: 1247 ELF files: 0 no shell, package manager or downloader root:x:0:0:root:/root:/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin etc/ssl/certs/ etc/ssl/certs/ca-certificates.crt

The 1400 entries include a few that docker export adds for every container (.dockerenv, etc/hosts, etc/resolv.conf). Of the rest, 1247 are timezone files. There are no ELF executables at all, no shell and no package manager. What is there is exactly the runtime support a static program needs: a passwd file with root, nobody and nonroot, and a CA certificate bundle. The same check, with the grep for shells and package managers, is a useful CI gate on your own final images.

One program on three bases

probe is a small Go program that reports its user and tries the four things that typically break on a minimal base: a user lookup, a timezone conversion, a DNS lookup and an HTTPS request. Run with serve, it is an HTTP server on port 8080 for the debugging section.

main.go
// probe: checks the runtime files a minimal image may or may not have.
package main
import (
"fmt"
"net"
"net/http"
"os"
"os/user"
"time"
)
func check(name string, err error, ok string) {
if err != nil {
fmt.Printf("%-5s FAIL %v\n", name, err)
return
}
fmt.Printf("%-5s ok %s\n", name, ok)
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "serve" {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "probe ok") })
fmt.Println("listening on :8080")
fmt.Println(http.ListenAndServe(":8080", nil))
return
}
fmt.Printf("uid=%d gid=%d\n", os.Getuid(), os.Getgid())
u, err := user.Current()
check("user", err, func() string { if u != nil { return u.Username + " " + u.HomeDir }; return "" }())
loc, err := time.LoadLocation("Europe/Berlin")
check("tz", err, func() string { if loc != nil { return time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC).In(loc).Format("15:04 MST") }; return "" }())
addrs, err := net.LookupHost("mirror.gcr.io")
check("dns", err, fmt.Sprintf("%d addresses", len(addrs)))
c := &http.Client{Timeout: 10 * time.Second}
resp, err := c.Get("https://mirror.gcr.io/v2/")
if err == nil {
resp.Body.Close()
}
check("tls", err, func() string { if resp != nil { return resp.Status }; return "" }())
}

The Dockerfile builds it once with CGO_ENABLED=0 and copies the binary onto three bases: plain scratch, scratch plus three runtime files, and distroless static. Note the numeric USER on the scratch stages, and that the builder and the distroless base are pinned by digest, so a rebuild next month starts from the same files; a bot moves the pins, as "Pinning, SBOMs, provenance and scanning" describes. Dockerfile comments must start a line; a comment after FROM, COPY or USER breaks the instruction.

Dockerfile
FROM golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/probe .
RUN echo 'app:x:10001:10001:app:/nonexistent:/sbin/nologin' > /out/passwd
FROM scratch AS scratch-bare
COPY --from=build /out/probe /probe
USER 10001:10001
ENTRYPOINT ["/probe"]
FROM scratch AS scratch-full
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /usr/share/zoneinfo.zip
ENV ZONEINFO=/usr/share/zoneinfo.zip
COPY --from=build /out/passwd /etc/passwd
COPY --from=build /out/probe /probe
USER 10001:10001
ENTRYPOINT ["/probe"]
FROM gcr.io/distroless/static-debian13:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3 AS distroless
COPY --from=build /out/probe /probe
ENTRYPOINT ["/probe"]
ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ for t in scratch-bare scratch-full distroless; do docker build -q --target $t -t lab-min:$t . done
sha256:a3ea7f58174429ee37254840c94676cce862f1263ac1f01bd18d3ee4d8bb367e sha256:4471fe38c99a2d628a527b5894dd6f65f84f6b6b01a33e4eab6cab7e8eade2ba sha256:d13f9361941984d0e97e6523965c743fde568f679d461f3a3df6315f2d7e94d4
$ docker run --rm lab-min:scratch-bare
uid=10001 gid=10001 user FAIL user: Current requires cgo or $USER set in environment tz FAIL unknown time zone Europe/Berlin dns ok 2 addresses tls FAIL Get "https://mirror.gcr.io/v2/": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
$ docker run --rm lab-min:scratch-full
uid=10001 gid=10001 user ok app /nonexistent tz ok 14:00 CEST dns ok 2 addresses tls FAIL Get "https://mirror.gcr.io/v2/": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
$ docker run --rm lab-min:distroless
uid=65532 gid=65532 user ok nonroot /home/nonroot tz ok 14:00 CEST dns ok 2 addresses tls FAIL Get "https://mirror.gcr.io/v2/": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

On bare scratch three of the four checks fail, each for a missing file:

scratch-full copies those three things from the build stage and passes everything; distroless static passes because it ships them. The next check shows the other reason for a numeric USER: Docker resolves a user name against the image's /etc/passwd when the container starts, and on scratch there is no file to resolve against.

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker run --rm --user app lab-min:scratch-bare
docker: Error response from daemon: unable to find user app: no matching entries in passwd file Run 'docker run --help' for more information

A numeric UID needs no entry. That is also why distroless/static:nonroot and the scratch stages above work with volume ownership set by number; "Run as non-root" covers that part.

Static or dynamic: prove it

Go binaries are static only when cgo is off. Go enables cgo by default for native builds when it finds a C compiler; golang:*-alpine has none, so cgo was off above, while the Debian-based golang images include gcc and produce binaries linked against glibc for code that uses net or os/user. Dockerfile.cgo turns cgo on deliberately (after installing gcc and the musl headers) and puts the result on distroless base, which does have a C library, but glibc:

Dockerfile.cgo
FROM golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS build
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=1 go build -trimpath -o /out/probe .
FROM gcr.io/distroless/base-debian13:nonroot@sha256:a0d70d6a97cd697d9362bc2aae4a6560dd65817e365d0043b07325a97975dc91
COPY --from=build /out/probe /probe
ENTRYPOINT ["/probe"]
ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker build -q -f Dockerfile.cgo -t lab-min:cgo .
sha256:f80194e9c490be519bf6f4ab2c89fc6fd918035de01b6b3b30471a085b985d36
$ docker run --rm lab-min:cgo
exec /probe: no such file or directory

no such file or directory names /probe, which is plainly there. The missing file is the program interpreter, the dynamic loader the kernel must start first. file and ldd on the two binaries show it:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ for t in distroless cgo; do id=$(docker create lab-min:$t) docker cp -q "$id":/probe probe-$t docker rm "$id" >/dev/null done file probe-distroless probe-cgo | sed 's/, Go BuildID=[^,]*//; s/, BuildID\[sha1\]=[^,]*//'
probe-distroless: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, stripped probe-cgo: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-aarch64.so.1, with debug_info, not stripped
$ ldd probe-distroless ldd probe-cgo
not a dynamic executable /lib/ld-musl-aarch64.so.1 => /lib/ld-linux-aarch64.so.1 (0x0000f808bbde0000) linux-vdso.so.1 (0x0000f808bbe29000) libc.musl-aarch64.so.1 => not found

The static build is statically linked and ldd says not a dynamic executable. The cgo build names its interpreter, /lib/ld-musl-aarch64.so.1, and needs libc.musl-aarch64.so.1. The VM's glibc ldd substitutes its own loader for that interpreter line and cannot find the musl C library at all. A distroless or Debian base has glibc's loader, not musl's, so a binary linked on Alpine does not run there, and the reverse is equally true. On an amd64 machine the names read x86-64, /lib/ld-musl-x86_64.so.1 and libc.musl-x86_64.so.1. Put file on your build artifact into CI and fail on dynamically linked when the target base is scratch or static.

Building C with gcc -static against glibc does not give the same guarantee. glibc's user and host lookups (getpwnam, getaddrinfo) go through the Name Service Switch, which loads libnss_* modules at run time even in a statically linked program, so the binary starts on scratch and then fails on its first lookup. The linker warns about this when it links those functions statically. For static C or Rust, link against musl (for Rust the x86_64-unknown-linux-musl or aarch64-unknown-linux-musl target); for Go, CGO_ENABLED=0.

Alpine, musl and Debian slim

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker image ls --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | awk 'NR == 1 || /^(lab-min|alpine:3.22|debian:trixie-slim|gcr.io\/distroless)/'
REPOSITORY:TAG SIZE lab-min:scratch-full 9.24MB lab-min:cgo 62.8MB lab-min:distroless 15MB lab-min:scratch-bare 8.4MB debian:trixie-slim 142MB alpine:3.22 13.4MB gcr.io/distroless/cc-debian13:nonroot 53.8MB gcr.io/distroless/static-debian13:nonroot 6.64MB gcr.io/distroless/base-debian13:nonroot 49.2MB gcr.io/distroless/static-debian13:debug-nonroot 8.73MB

These are disk-usage figures on this VM (unpacked layers plus stored content, as explained in "Layers and the build cache" in Docker for beginners). alpine:3.22 is a tenth of debian:trixie-slim, and distroless static is smaller still. Size is not the whole decision. Alpine uses musl instead of glibc, and the differences show up in production:

The musl behaviour is documented on the musl wiki; the binary incompatibility you saw in the lab. Choose Alpine for static or musl-native programs and for small utility images. Choose Debian slim (or distroless base or cc) when the service pulls in native extensions, vendor binaries or deep recursion in threads, or when a DNS difference would be expensive to discover in production. For a static Go or Rust binary, distroless static or scratch with the three files above beats both.

Debugging a container without a shell

Start the probe as a server. docker exec has nothing to run:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker run -d --name lab-probe lab-min:distroless serve
121f8fc10eca4cf07bcbfcdeaed5f8580cb771405cb41376900aab0777b1cb92
$ docker exec lab-probe sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

Bring the tools to the container instead. A debug container started with --pid container:NAME joins the target's PID namespace and with --network container:NAME its network namespace, so its tools see the target's processes and its localhost, while the target's image stays untouched:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker run --rm --pid container:lab-probe --network container:lab-probe busybox:1.37 \ sh -c 'ps; netstat -tln; wget -qO- http://localhost:8080/'
PID USER TIME COMMAND 1 65532 0:00 /probe serve 18 root 0:00 sh -c ps; netstat -tln; wget -qO- http://localhost:8080/ 24 root 0:00 ps Active Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State tcp 0 0 :::8080 :::* LISTEN probe ok

PID 1 is /probe serve running as 65532, the listener on 8080 is visible, and wget reaches the app over the shared loopback. Reading the target's filesystem through /proc/1/root needs more: the kernel requires ptrace access to another process's root, and Docker's default capability set does not include CAP_SYS_PTRACE. Grant it to the debug container only, for the session:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker run --rm --pid container:lab-probe busybox:1.37 ls /proc/1/root/
ls: /proc/1/root/: Permission denied
$ docker run --rm --pid container:lab-probe --cap-add SYS_PTRACE busybox:1.37 \ sh -c 'ls /proc/1/root/; cat /proc/1/root/etc/passwd'
bin boot dev etc home lib probe proc root run sbin sys tmp usr var root:x:0:0:root:/root:/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin

A debug container sharing the target's PID namespace can also signal and trace the target, so treat it like an interactive root session: start it with --rm, keep it short, and use an image you trust (busybox:1.37 here). On Kubernetes, kubectl debug creates the same kind of ephemeral container. docker debug exists too, but it is a Docker Desktop feature and not part of Docker Engine. The basic no-shell tools (inspect, logs, cp) are in "Inspect, logs and exec" in Docker for beginners.

The :debug images are for reproducing a problem on your own machine:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker run --rm --entrypoint sh gcr.io/distroless/static-debian13:debug-nonroot \ -c 'id; ls -l /busybox/sh; ls /busybox | wc -l'
uid=65532(nonroot) gid=65532(nonroot) groups=65532(nonroot) lrwxrwxrwx 1 root root 16 Jan 1 1970 /busybox/sh -> /busybox/busybox 382

It is the same base with a BusyBox of 382 applets under /busybox, outside the usual PATH directories. A :debug tag that ships to production undoes the point of the base, which is what the export check from the first section catches: it matches busybox and sh by name, wherever they are.

Clean up

The distroless base images stay in the image store for the next lessons; remove them with docker image rm if you need the space.

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.8.2
$ docker rm -f lab-probe docker image rm lab-min:scratch-bare lab-min:scratch-full lab-min:distroless lab-min:cgo rm -rf static.tar rootfs probe-distroless probe-cgo
lab-probe Untagged: lab-min:scratch-bare Deleted: sha256:a3ea7f58174429ee37254840c94676cce862f1263ac1f01bd18d3ee4d8bb367e Untagged: lab-min:scratch-full Deleted: sha256:4471fe38c99a2d628a527b5894dd6f65f84f6b6b01a33e4eab6cab7e8eade2ba Untagged: lab-min:distroless Deleted: sha256:d13f9361941984d0e97e6523965c743fde568f679d461f3a3df6315f2d7e94d4 Untagged: lab-min:cgo Deleted: sha256:f80194e9c490be519bf6f4ab2c89fc6fd918035de01b6b3b30471a085b985d36
Quick check
01A static Go service on FROM scratch starts and serves requests, but outbound HTTPS calls fail with x509: certificate signed by unknown authority and time.LoadLocation("Europe/Berlin") returns an error. What is the fix?
Incorrect — There are no system libraries on scratch; a cgo binary would not even start there.
Incorrect — The files do not exist at all, so no user can read them.
Correct — Those are the files the lab added to scratch-full, and distroless static ships them.
Incorrect — Go's TLS stack is part of the binary; it needs a trust store, not a shell.
02A container fails with exec /app: no such file or directory, yet docker cp shows that /app exists and is executable. file app prints dynamically linked, interpreter /lib/ld-musl-aarch64.so.1. The base is gcr.io/distroless/base-debian13. Why?
Correct — The missing file is the interpreter; the lab's cgo build on Alpine failed the same way on distroless base.
Incorrect — The file is world-executable; a permission problem would say permission denied.
Incorrect — Exec form runs the binary directly; that is why it works on scratch for static binaries.
Incorrect — file reports the architecture, and the interpreter name in it is the arm64 musl loader.
03A production container built on distroless/static:nonroot misbehaves and has no shell. Which way of investigating leaves the running image unchanged?
Incorrect — That replaces the running container with a different image and puts a shell into production.
Incorrect — That writes a shell into the live container, changing exactly what you wanted to keep.
Incorrect — There is no shell for any user, and a restart loses the state you wanted to see.
Correct — The tools run in their own image while sharing the target's namespaces.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from minimal bases: distroless, scratch, static and alpine, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related