CoursesAdvanced container securityStatic binaries & tiny bases

Static binaries & tiny bases

Go, Rust, and C services at a few megabytes.

Advanced14 min · lesson 7 of 25

A scratch image ships nothing. No shell, no C library (the bundle of basic routines nearly every Linux program calls), no package manager, not one file you didn't put there yourself. That emptiness is the whole security argument. Break into a normal container and the first thing an intruder does is go shopping. Someone who lands RCE (remote code execution, the ability to run commands on your machine) reaches for whatever is lying around: sh to run a payload, curl or wget to pull down the next stage, cat and find to map the place. Defenders call it living off the land, and it works because a fat base image hands the attacker a fully stocked toolbox. On scratch there is no toolbox, because there is no operating system to raid. Your one binary runs, and that binary is the entire filesystem.

You only earn that emptiness if the binary genuinely stands on its own, and that comes down to how it was linked. A dynamically linked program is a recipe written on the assumption that somebody else stocked the kitchen. When it starts, a helper called the dynamic loader (ld-linux, the small program that assembles other programs at launch) reads the list of shared libraries the recipe calls for, hunts each one down on disk, and wires them in. libc, the C standard library, is almost always on that list. A statically linked program skips the shopping trip entirely. Every function it calls was baked into the executable at build time, so it needs no loader and no shared libraries. Drop a static binary onto an empty filesystem and it runs. Drop a dynamic one there and it dies before your first line of code, because the loader it asked for isn't there to answer.

Prove what you actually built

Don't eyeball this, and don't trust a language's reputation for it. Two commands settle the question. ldd lists the shared libraries a binary depends on. file tells you how the binary was put together. Run both against the artifact you are about to ship, before it ever reaches a registry.

terminal
# same Go service, built two ways: check the linkage before you ship
$ CGO_ENABLED=1 go build -o server-dyn ./cmd/server
$ ldd server-dyn
linux-vdso.so.1 (0x00007ffd8b3fe000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9c2a800000)
/lib64/ld-linux-x86-64.so.2 (0x00007f9c2ac0f000)
$ CGO_ENABLED=0 go build -ldflags="-s -w" -o server ./cmd/server
$ ldd server
not a dynamic executable
$ file server
server: ELF 64-bit LSB executable, x86-64, statically linked, stripped

Read the two outputs side by side. The dynamic build names libc.so.6 and ld-linux, the loader. The static build answers not a dynamic executable, and file backs that up: a statically linked ELF (Executable and Linkable Format, the standard shape of a Linux program file) with its debug symbols stripped out by -s -w. Go is where people trip. Go usually emits static binaries. Usually. CGO_ENABLED is the switch for cgo, the bridge that lets Go call C code. Leave it on (it defaults to on whenever a C compiler is sitting on the build machine) and import net or os/user, and Go quietly links against the system libc to look up hostnames and usernames. Now you have a dynamic binary that cannot run on scratch. Set CGO_ENABLED=0 and Go uses its own pure-Go resolver instead, and the output comes out fully static every time. That one variable is the difference between an image that boots and a loader error you will spend an afternoon chasing.

Ship it on nothing

Once the binary is provably static, the final stage gets almost comically small. scratch is the reserved empty base: no layers, no files, no metadata beyond what you add yourself. You copy the binary in and set an entrypoint. Two of the lines below look like boilerplate. Both are load-bearing security controls.

Dockerfile
# build stage: CGO off makes the binary fully static
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
FROM scratch
COPY --from=build /server /server
# outbound HTTPS needs a trust store; without this, TLS fails with x509
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 10001:10001 # numeric: scratch has no /etc/passwd
ENTRYPOINT ["/server"]

The USER line is a number on purpose. A name like app has to be looked up in /etc/passwd, and scratch has no such file, so you hand the runtime a raw user ID and group ID (UID and GID) instead. Leave the line out and the container runs as UID 0, root inside the namespace, which is the exact privilege you were trying to give up. There is no id command in the image to check afterwards, so you confirm it from outside with docker inspect. The certificate copy is the other classic omission. scratch carries no trust store, meaning no list of certificate authorities your program can use to decide whether a server is who it claims to be, so the first outbound call over TLS (Transport Layer Security, the S in HTTPS) fails with an x509 certificate error until you copy the bundle in. If the service does timezone math, you copy tzdata too. Nothing is assumed for you. That is the point.

terminal
$ docker build -t svc:static .
$ docker run --rm svc:static ls
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: exec: "ls": executable file not found in $PATH: unknown.
$ docker run --rm --entrypoint /bin/sh svc:static
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: exec: "/bin/sh": stat /bin/sh: no such file or directory: unknown.
$ docker inspect svc:static --format 'user={{.Config.User}} size={{.Size}}'
user=10001:10001 size=7813402

Those errors are the payoff, not a fault. There is no ls to look around with and no /bin/sh to drop into, so an RCE that would normally end at a shell prompt finds nothing to spawn. The image comes in under 8 MB and runs as a non-root user. One question is still open: what is actually in there? A scanner cannot read a package database that doesn't exist, so you build an SBOM (software bill of materials, an itemised inventory of every component in the image) with syft, then point trivy at those same components to check them against live vulnerability feeds. On a scratch Go image that inventory is the Go standard library and your own modules, and the CVE (Common Vulnerabilities and Exposures, the public catalogue of known security flaws) surface shrinks to match. No distro packages sit there ageing into fresh CVEs, because there is no distro.

terminal
$ syft svc:static -o syft-table
NAME VERSION TYPE
github.com/go-chi/chi/v5 v5.1.0 go-module
golang.org/x/crypto v0.31.0 go-module
stdlib go1.23.4 go-module
$ trivy image --severity HIGH,CRITICAL svc:static
2026-07-16T09:14:02Z WARN Unable to detect OS in image; scanning application dependencies only
svc:static (unknown)
Total: 0 (HIGH: 0, CRITICAL: 0)
/server (gobinary)
Total: 0 (HIGH: 0, CRITICAL: 0)
"Statically linked" glibc still reaches outside the box
Building C with gcc -static feels like it seals the box shut. glibc's name lookups leak out of it anyway. getaddrinfo (turning a hostname into an address over DNS, the Domain Name System) and getpwnam (turning a username into a user record) both run through NSS, the Name Service Switch, and NSS dlopens libnss_*.so at runtime no matter how you linked everything else. So a "static" glibc binary behaves beautifully until the first hostname it resolves on scratch, and then it falls over, because that shared library was never copied in. strace shows the tell straight away: an openat on libnss_files.so.2 coming back ENOENT (no such file or directory). Two fixes work. Build against musl instead (Rust's x86_64-unknown-linux-musl target, or Alpine's toolchain), or stay in pure Go with CGO_ENABLED=0. Both resolve names without dlopen, so they are static all the way down and safe on an empty base.

Static-on-scratch isn't the right answer for every service, though. What you pick depends on what your binary needs while it is running, and you can read that straight off the ldd output you already have instead of guessing.

Which minimal base for this binary?
Which minimal base?
read it off ldd, don't guess
ldd: "not a dynamic executable"
FROM scratch
genuinely static (Go CGO_ENABLED=0, Rust musl target); copy in CA certs and a numeric USER
ldd lists libc.so + ld-linux
distroless/base (glibc)
dynamic against glibc; the base already carries libc, the loader, CA certs and a non-root user
needs a language runtime
distroless/java or /python
a Java or Python runtime can't be one file; take the matching variant, never a full distro
you must open the box to debug
:debug tag / ephemeral debug
attach busybox at runtime (docker debug, kubectl debug); never bake tools into the production image
Every branch except the first keeps some userland, and userland means CVEs you have to keep patching. Prove static first; fall back only when the runtime genuinely needs more.

Static linking is what earns you scratch. A dynamically linked binary drags a loader and a pile of shared libraries into the image behind it, and those bring back package CVEs and somewhere for an intruder to pivot. Go's CGO_ENABLED=0, Rust's musl targets and a carefully built C toolchain are the three roads that get you there.

Put the check in CI, not in your head. Fail the build when ldd or file reports "dynamically linked", because a Dockerfile can look perfectly minimal and still produce an image that dies on scratch. A red pipeline costs you five minutes. Finding the same thing at 3am costs rather more.

The trade-off is real. A static artifact is bigger on its own, and patching a library CVE means a rebuild rather than a quick package bump. For most services the smaller attack surface is worth more than the convenience you gave up.

In production this becomes a change-window check. After anything touches the build, rerun ldd or file against the artifact you actually shipped, paste the command and its output into the ticket, and refuse to close the change if the reading moved. Give the workload the tightest surface it can still run on. That habit compounds across every host and every pipeline you own.

Try this

Build a static binary, prove it is static, then run it on nothing. Then try the same trick with a dynamic binary and watch it fail on the spot.

terminal
$ docker run --rm -v "$PWD":/out golang:1.22-alpine sh -c 'cd /tmp && printf "package main\nfunc main(){}\n" > m.go && CGO_ENABLED=0 go build -o /out/staticapp m.go'
$ file staticapp
staticapp: ELF 64-bit LSB executable, x86-64, ... statically linked ...
$ docker build -t scratch:static - <<'EOF'
FROM scratch
COPY staticapp /staticapp
ENTRYPOINT ["/staticapp"]
EOF
$ docker run --rm scratch:static; echo exit:$?
exit:0

Takeaway

scratch only pays off when the binary truly stands alone. Prove the linking in CI, COPY that single artifact onto scratch or distroless/static, and treat rebuilds as your patching story for library flaws.

Quick check
01You put a Go service on FROM scratch, and the container dies the instant it starts with "no such file or directory", before a single line of your code logs. What's the likely cause?
Correct — "No such file" at exec time is the missing dynamic loader talking. Run ldd on the binary and you'll see libc.so and ld-linux listed. Rebuild with CGO_ENABLED=0 and it comes out fully static.
Incorrect — An exec-form ENTRYPOINT like ["/server"] never invokes a shell at all, so a missing shell can't be the cause here. The error is about the binary's own loader.
Incorrect — Missing CA certs break outbound TLS later on, at the first HTTPS call, with an x509 error. They wouldn't stop the process from starting in the first place.
Incorrect — A numeric UID needs no passwd entry to run, which is exactly why you use a number on scratch. It wouldn't cause an instant exec failure.
02A C program built with gcc -static against glibc can still fall over the first time it resolves a hostname on a scratch base. Why?
Correct — NSS modules load dynamically at lookup time, so a 'static' glibc binary still wants a file that scratch doesn't carry.
Incorrect — No: static linking bakes the program's own code into the executable. That part is never fetched at runtime.
Incorrect — No: a genuinely static binary reports 'not a dynamic executable' and needs no loader. The failure here is the NSS module specifically.
Incorrect — No: name resolution happens as a library call inside the process, not as an external shell command.
03Your static Go binary runs happily on FROM scratch and serves local traffic, but every outbound HTTPS call comes back with an x509 certificate error. What did the image leave out?
Incorrect — No: a static binary needs no loader, and a missing loader would stop the process from starting rather than break HTTPS on its own.
Incorrect — No: certificate validity is evaluated in UTC. tzdata only matters for local timezone math.
Correct — with no trust store on scratch, the first outbound TLS call can't verify who it's talking to and errors with x509.
Incorrect — No: passwd governs user resolution, not certificate validation, so it has no bearing on the x509 error.

Related