Static binaries & tiny bases
Go, Rust, and C services at a few megabytes.
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.
# same Go service, built two ways: check the linkage before you ship$ CGO_ENABLED=1 go build -o server-dyn ./cmd/server$ ldd server-dynlinux-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 servernot a dynamic executable$ file serverserver: 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.
# build stage: CGO off makes the binary fully staticFROM golang:1.23 AS buildWORKDIR /srcCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/serverFROM scratchCOPY --from=build /server /server# outbound HTTPS needs a trust store; without this, TLS fails with x509COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/USER 10001:10001 # numeric: scratch has no /etc/passwdENTRYPOINT ["/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.
$ docker build -t svc:static .$ docker run --rm svc:static lsdocker: 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:staticdocker: 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.
$ syft svc:static -o syft-tableNAME VERSION TYPEgithub.com/go-chi/chi/v5 v5.1.0 go-modulegolang.org/x/crypto v0.31.0 go-modulestdlib go1.23.4 go-module$ trivy image --severity HIGH,CRITICAL svc:static2026-07-16T09:14:02Z WARN Unable to detect OS in image; scanning application dependencies onlysvc:static (unknown)Total: 0 (HIGH: 0, CRITICAL: 0)/server (gobinary)Total: 0 (HIGH: 0, CRITICAL: 0)
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.
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.
$ 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 staticappstaticapp: ELF 64-bit LSB executable, x86-64, ... statically linked ...$ docker build -t scratch:static - <<'EOF'FROM scratchCOPY staticapp /staticappENTRYPOINT ["/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.
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?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.gcc -static against glibc can still fall over the first time it resolves a hostname on a scratch base. Why?FROM scratch and serves local traffic, but every outbound HTTPS call comes back with an x509 certificate error. What did the image leave out?