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: f37490e1619cafd27106de832d983258c46dc835fcaa37bf30d0c41428751865

docker exec lab-probe sh fails with exec: "sh": executable file not found in $PATH. On a distroless image that's the design working. An attacker who gets code execution finds no shell, no package manager and no downloader to reach for, and there's 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. Debugging needs a different method too. You'll build one Go program on three bases to see exactly which files matter, and prove static versus dynamic linking. Then you'll compare Alpine and Debian and debug 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 don't 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 you need here and read the config of the static image. (The loop at the end pulls alpine:3.24 and debian:trixie-slim for the size comparison if an earlier lesson hasn't.)

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.9.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.24 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's 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.9.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, makes 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. Those are a user lookup, a timezone conversion, a DNS lookup and an HTTPS request. Run with serve, it's 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. 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:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c 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.9.2
$ for t in scratch-bare scratch-full distroless; do docker build -q --target $t -t lab-min:$t . done
sha256:23418812409f6363a2685545b0188b6d58ae840630585b7868f6104794250e81 sha256:5dd9b16ab960a9342175b66c41fd4ca0f6b4c93c7fb1bf53c99e447b41b5661b sha256:b56c6e6dae558aaafd759451c56c93090f78f476dc75beba658ed44102e84945
$ 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/": tls: failed to verify certificate: x509: certificate signed by unknown authority
$ 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 ok 401 Unauthorized
$ 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 ok 401 Unauthorized

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 all four checks. Its HTTPS request gets 401 Unauthorized from mirror.gcr.io, an HTTP answer that only arrives after the certificate verified. Distroless static passes all four the same way, because it ships those files. 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's no file to resolve against.

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.9.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's 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. The Debian-based golang images include gcc, and they 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:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c 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.9.2
$ docker build -q -f Dockerfile.cgo -t lab-min:cgo .
sha256:e5f8c27a5ae439695397220c01d73e7cc4f953afe0092d1fff4a0975973642e5
$ 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.9.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 (0x0000fc6d759d0000) linux-vdso.so.1 (0x0000fc6d75a0c000) 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 can't 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 won't 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 doesn't give the same guarantee. glibc's user and host lookups (getpwnam, getaddrinfo) go through the Name Service Switch. It 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.9.2
$ docker image ls --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | awk 'NR == 1 || /^(lab-min|alpine:3.24|debian:trixie-slim|gcr.io\/distroless)/'
REPOSITORY:TAG SIZE lab-min:cgo 62.9MB lab-min:distroless 15MB lab-min:scratch-bare 8.41MB lab-min:scratch-full 9.24MB alpine:3.24 13.6MB debian:trixie-slim 142MB gcr.io/distroless/base-debian13:nonroot 49.2MB gcr.io/distroless/static-debian13:nonroot 6.65MB gcr.io/distroless/cc-debian13:nonroot 53.8MB gcr.io/distroless/static-debian13:debug-nonroot 8.74MB

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.24 is a tenth of debian:trixie-slim, and distroless static is smaller still. Size isn't 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. The same goes 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.9.2
$ docker run -d --name lab-probe lab-min:distroless serve
b7b6b071a6f63061796f68a10881cc3e822c613c76d122849127005a02940a23
$ 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. Its tools then see the target's processes and its localhost, while the target's image stays untouched:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.9.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 16 root 0:00 sh -c ps; netstat -tln; wget -qO- http://localhost:8080/ 22 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 doesn't include CAP_SYS_PTRACE. Grant it to the debug container only, for the session:

ubuntu@secopslog-docker:~/lab/distroless · Docker 29.9.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's 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.9.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's 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. The export check from the first section catches that, because 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.9.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:23418812409f6363a2685545b0188b6d58ae840630585b7868f6104794250e81 Untagged: lab-min:scratch-full Deleted: sha256:5dd9b16ab960a9342175b66c41fd4ca0f6b4c93c7fb1bf53c99e447b41b5661b Untagged: lab-min:distroless Deleted: sha256:b56c6e6dae558aaafd759451c56c93090f78f476dc75beba658ed44102e84945 Untagged: lab-min:cgo Deleted: sha256:e5f8c27a5ae439695397220c01d73e7cc4f953afe0092d1fff4a0975973642e5

Try this

On the main lab VM, repeat the --user app step against lab-min:scratch-full and lab-min:distroless instead of scratch-bare. Predict which of them can resolve the name app, keeping in mind that distroless ships a passwd with root, nobody and nonroot only. Then run lab-min:distroless with --user nonroot and compare its user line with the earlier distroless run. Each container uses --rm, so there's nothing to clean up.

Takeaway

Ship a shell-less final image, and gate it in CI with file on the binary plus the export grep for shells and package managers. In the lab the cgo build died with no such file or directory while /probe was plainly there, an error that sends people hunting for the wrong file when the loader is what's missing. When you need tools, bring a short-lived --pid container: sidecar rather than a :debug tag that can slip into production.

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.

Related