Minimize the base image
Distroless, multi-stage, and footprint.
Every package in your base image is two liabilities at once: a component you have to patch when its next CVE lands, and a tool an intruder can use after they break in. Minimizing the base image cuts both. A distroless or scratch base carries no shell, no package manager, and no coreutils, so most drop-a-shell payloads simply fail — there is no /bin/sh to exec — and the CVE surface you track shrinks from a full OS to a handful of libraries.
The mechanism is the multi-stage build: do the compiling in a fat builder stage with the whole toolchain, then copy only the finished artifact into a tiny final stage that has nothing else. The build tools, source, and package caches stay in the builder and never ship.
# builder: full toolchain, thrown awayFROM golang:1.23 AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server# final: one static binary, no shell, non-rootFROM gcr.io/distroless/static:nonrootCOPY --from=build /out/server /serverUSER nonrootENTRYPOINT ["/server"]
Measure what you removed
The numbers make the argument: the golang builder is ~800 MB with a compiler, shell, and package manager; the distroless result is ~20 MB with a single binary. Every megabyte removed is a dependency you no longer patch and a capability an attacker no longer finds — and a smaller image pulls faster onto every node, which matters at scale.
$ docker images | grep payments-apipayments-api build-stage 812MBpayments-api 1.4.2 21MB # distroless final$ docker run --rm payments-api:1.4.2 shdocker: Error ... exec: "sh": executable file not found in $PATH # no shell to pivot with
Choosing a base
Match the base to the binary. scratch (empty) works for a fully static binary and nothing else. Distroless adds just libc and CA certs, with nonroot and debug variants. Alpine or Wolfi give you a real but minimal userland and a package manager when you genuinely need one — at the cost of a shell being present. Whatever you pick, pin it by digest so tomorrow’s rebuild uses the exact bytes you scanned today.
# pin the base by digest, not a floating tagFROM gcr.io/distroless/static:nonroot@sha256:d71f4f2...# a tag can be repointed upstream; a digest is the content itself