Distroless and Multi-Stage Builds: Smaller, Safer Images

Ship the binary and its runtime, leave the operating system behind

Contents

The first Docker image I ever built for a small Go service was 900 megabytes. The compiled binary inside it was eleven. Everything else — the Go toolchain, a full Debian userland, a package manager, a shell, compilers, a hundred libraries the binary never touched — came along for the ride because I had done the obvious thing and built the image FROM golang. It worked. It was also a comically oversized attack surface wrapped around a program that needed almost none of it, and every one of those hundred unused libraries was something a scanner could find a CVE in.

Multi-stage builds and distroless base images together fix this properly, and they are two halves of one idea. A multi-stage build lets you use a big, tool-rich image to compile your software and then copy only the finished artefact into a tiny, clean image to ship. Distroless is what that tiny shipping image should be: a base that contains a language runtime and its essential dependencies and deliberately nothing else — no shell, no package manager, no ls, no cat. The result for that same Go service is an image around 12 megabytes with a handful of packages in it, and a vulnerability scan that comes back nearly empty because there is almost nothing left to be vulnerable in.

Why a smaller image is a safer image

Advertisement

The security argument is direct: you cannot have a vulnerability in a package that is not present. A conventional base image ships hundreds of packages to make interactive use pleasant — a shell, coreutils, a package manager, network tools — and every one is code that could carry a flaw, most of which your application never calls. Distroless removes them. The wall of base-image CVEs I complained about in CVE triage for a homelab largely evaporates, because those CVEs lived in exactly the packages distroless leaves out.

There is a second, quieter benefit that matters more than it first appears: no shell means no interactive foothold. A great many container attacks depend, after the initial entry, on spawning /bin/sh to look around, pull down a second-stage payload, or pivot. An image with no shell and no package manager gives an attacker who lands inside it very little to work with — they cannot curl | sh, cannot install tools, cannot even list a directory with a familiar command. It is not a magic wall, and a determined attacker with a memory-corruption bug in your actual application can still do damage, but you have removed an enormous amount of the convenient tooling that turns a toehold into a foothold.

The size win is real too, and it compounds. Smaller images pull faster, which means faster deploys and faster CI, and they use less registry storage and less bandwidth every time a node fetches one. On a homelab where the same warm-runner logic from Gitea Actions already makes builds quick, a 12 MB image versus a 900 MB one is the difference between a deploy that is instant and one you wait on.

How a multi-stage build is structured

The mechanism is a Dockerfile with more than one FROM. Each FROM begins a new stage with its own base image and its own filesystem, and a later stage can reach back into an earlier one with COPY --from= to lift out specific files. Only the final stage becomes the image you ship; everything in the earlier stages is discarded. So you compile in a heavy stage that has every build tool, then copy just the binary into a featherweight final stage.

Here is a complete, realistic example for a Go service targeting Google’s distroless static base:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# ---- Stage 1: build ----
# Full toolchain lives here and never ships.
FROM golang:1.23 AS build
WORKDIR /src

# Cache dependencies separately from source for fast rebuilds.
COPY go.mod go.sum ./
RUN go mod download

COPY . .
# Static, stripped binary. CGO off so it needs no libc at runtime.
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o /app ./cmd/server

# ---- Stage 2: ship ----
# distroless static: CA certs + tzdata + a nonroot user, nothing else.
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app /app

# Run as the built-in unprivileged 'nonroot' user.
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]

Everything that made the build possible — the compiler, the module cache, the source — stays in stage one and is thrown away. What ships is the static:nonroot base plus one binary. That base is a few megabytes and carries CA certificates, timezone data, /etc/passwd with a nonroot user, and little else. The scanner has almost nothing to report on it.

For an interpreted or runtime-dependent language, the pattern is the same but the final base differs. A Node service ships FROM gcr.io/distroless/nodejs22-debian12, copying in node_modules and your application code from a build stage that ran npm ci. A Python service uses the Python distroless variant. The principle holds across all of them: compile or install in a fat stage, run in a lean one.

Choosing the right distroless flavour

Advertisement

The distroless family has a few members and picking the wrong one wastes hours.

  • static — for fully static binaries with no libc dependency at all. This is the smallest and cleanest, ideal for a Go binary built with CGO_ENABLED=0, or Rust built against musl. If your program genuinely links nothing dynamically, this is the target.
  • base — includes glibc and a couple of core libraries, for dynamically linked binaries that need libc but nothing more. A Go binary built with cgo, or a C/C++ program, wants this rather than static.
  • The language imagesnodejs, python3, java — bundle the corresponding runtime, for interpreted languages that need their interpreter present.
  • The :debug tag — every distroless image has a :debug variant that adds a minimal busybox shell, purely so you can exec in and poke around during development. Use it while building, ship the non-debug tag to anything real.
  • The :nonroot tag — configures the image to run as an unprivileged user by default. Prefer it. A container that does not need root should never run as root.

Keep the build stage tidy too

The final image gets all the attention, but the build stage is where your build speed and a surprising amount of correctness live. Two habits pay off immediately.

A .dockerignore file keeps junk out of the build context. Without one, COPY . . hauls your .git directory, local node_modules, editor cruft and any secrets sitting in the working tree into the build, which slows every build and risks baking something private into a layer. A short ignore list — .git, build output, local env files — makes the context small and the build faster, and it means a stray .env on your disk never ends up in a stage.

Ordering your Dockerfile so dependencies are fetched before source is copied is the other big win, and the example above already does it: COPY go.mod go.sum and go mod download come before COPY . ., so a change to your source code does not invalidate the cached dependency layer. Get this ordering wrong and every one-line code change re-downloads the whole dependency tree; get it right and rebuilds are seconds. The same layering discipline is what makes builds fast on a warm CI runner, which ties straight back to the point about self-hosted CI — the two techniques reinforce each other.

If you want to go a step further, modern Docker can emit a software bill of materials and build provenance as you build (docker build --sbom=true --provenance=true), giving you a signed record of exactly what went into the image. For a homelab that is optional polish, but it is the same instinct as everything else here: know precisely what you are shipping, and keep it to the minimum that works.

Troubleshooting distroless, which is its own skill

Distroless is stricter than a normal base, and the ways it bites are consistent enough to list. Nearly all of them come from something you took for granted being genuinely absent.

x509: certificate signed by unknown authority when the app makes HTTPS calls. The image has no CA certificate bundle. The distroless/static and base images do ship ca-certificates, so if you hit this you are probably on scratch (which ships nothing) or copied the binary into a hand-rolled base. Either move to distroless, or explicitly COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ from a stage that has them.

Timestamps are all UTC and timezone conversions are wrong. No timezone database. Distroless static and base include tzdata, but scratch does not — copy /usr/share/zoneinfo in from the build stage if you need local times, or handle timezones in application code.

docker exec ... /bin/sh fails: no such file. Correct, there is no shell, which is the whole point. To debug, run the :debug tag temporarily, or on a modern Docker use docker debug / an ephemeral debug container that attaches a toolbox alongside the running container without changing the image. Do not add a shell back into your production image to make debugging easier — you would be reintroducing the exact thing you removed.

The container’s HEALTHCHECK never passes. A HEALTHCHECK CMD curl ... needs curl, which is not there. Either bake a tiny static healthcheck binary into the image and call that, or move the health probe out to the orchestrator — Kubernetes and Compose can both probe an HTTP endpoint without needing a tool inside the container. The orchestrator-side probe is the cleaner answer and keeps the image pristine.

The binary segfaults or reports “no such file or directory” on start despite existing. This is the classic glibc-versus-musl mismatch. A binary dynamically linked against glibc will not run on a musl base, and vice versa, and the error is often the misleading “not found” rather than anything about libraries. The fix is to match the base to how you linked: a fully static build runs on static, a glibc-linked build needs base. For Go, CGO_ENABLED=0 sidesteps the whole question by producing a static binary; if you need cgo, build against glibc and ship on distroless/base.

Permission denied writing to a path. Running as nonroot means the process cannot write to root-owned directories or bind to ports below 1024. Write to a path your user owns (or a mounted volume), listen on a high port and remap it outside, and COPY --chown=nonroot:nonroot any files the app must write to. This is friction the first time and good discipline forever after, since a service that needs root to run is a service worth being suspicious of.

Is it worth it?

For anything you build yourself and run more than once, yes, and the effort is smaller than it looks. Converting a Dockerfile to multi-stage plus distroless is usually twenty minutes and a couple of the troubleshooting items above, done once, after which every rebuild is smaller and quieter forever. The payoff lands in three places you feel regularly: a vulnerability scan that is actually readable because the base-image noise is gone, deploys and CI that move faster on tiny images, and an attacker’s post-exploitation toolkit reduced to almost nothing because there is no shell to find.

Where it is genuinely not worth it: throwaway experiments, images you will delete tomorrow, and anything where you need heavy interactive debugging inside the container as part of normal operation. For those, a normal slim base is less fuss and the security surface does not matter because the thing is ephemeral anyway. But for the services that actually run your homelab — the ones exposed, the ones holding data, the ones you would be upset to see compromised — shipping the binary and its runtime while leaving the operating system behind is one of the highest-leverage habits I have picked up. It makes the images smaller and safer at the same time, and once the Dockerfile is written you get both for free on every build.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.