The base image a Go service is built in weighs 241 MB. The base image it should run in is 2.21 MB. Everything a production Dockerfile is about sits between those two numbers: nothing that was needed only at build time should ship to production. All sizes below are a docker images reading on linux/amd64.
Multi-stage: build here, run there
Multi-stage is usually sold as a way to shrink the image. Shrinking is a side effect. What matters is that the final layer starts from a clean base and holds exactly what was copied by hand: the compiler, the package manager, the headers and the shell never make it in, because nobody copied them.
# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /bin/app ./cmd/app
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /bin/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
The first line is not decoration: it pulls the current Dockerfile frontend instead of the one baked into the daemon, and the frontend version decides whether --mount and --link from the next sections are available at all.
| Base | Size | When to pick it |
|---|---|---|
distroless/static-debian13 | 2.21 MB | static binaries: Go with CGO_ENABLED=0, Rust |
alpine:3.24 | 8.42 MB | you need a shell, busybox or a package manager |
distroless/base-debian13 | 24.3 MB | dynamic linking against glibc |
debian:trixie-slim | 78.6 MB | system dependencies that do not exist under musl |
A tag without the distro suffix currently resolves to -debian13, but upstream warns it will move to the next Debian in time. Better to spell the suffix out.
Cache: layer order matters more than flags
One rule: what changes rarely gets copied first. The dependency manifest in its own COPY, then the install, and only then the sources. The other way round, editing a single line of code invalidates the package install.
On top of that BuildKit offers cache mounts that survive layer invalidation: the directory lives on the host and is mounted into the build.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install --no-install-recommends -y gcc
sharing=locked is mandatory here — apt does not survive two parallel builds in one directory. The second underrated flag: COPY --link puts the copied files on their own layer, independent of the ones before it, and that layer is reused even after the base is rebuilt.
A secret that reached a layer stays in that layer
An image is not an archive, it is a stack of immutable layers. A token written in one RUN and deleted in the next has gone nowhere: rm only removed it from the top view of the filesystem.
docker history --no-trunc my-app:latest
docker save my-app:latest | tar -xO | strings | grep -i 'token\|secret'
Anyone with pull access can extract it, without a single exploit. There is exactly one approach that works: --mount=type=secret mounts the file for the duration of the instruction, and it lands neither in a layer nor in the manifest.
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
ARG will not do, not even in the builder stage: the value stays in that stage's layers and in the build cache on the host, and multi-stage only protects the final image. Runtime secrets are a separate job — that is where External Secrets Operator and workload identity work, not the build.
Non-root and signals
USER with a numeric UID is not cosmetics. Kubernetes with runAsNonRoot: true will not start a container whose image config carries a user name instead of a number: the kubelet cannot verify that the name will not resolve to root. The nonroot distroless tags use 65532.
The common belief that USER has to come before CMD or the container starts as root is wrong. Two images with those instructions in opposite order both give uid=1001: the value comes from the last USER instruction in the file, and its position relative to CMD plays no part. The check takes a minute, and it is worth doing before carrying that rule from somebody else's checklist into your own.
Something else does break — the form the entry point is written in. The shell form ENTRYPOINT app starts /bin/sh -c, and PID 1 goes to the shell: the signal never reaches the process, docker stop sits out the timeout and then kills the container. Docker's own documentation measures the difference — 10.19 seconds against 0.20. The exec form ENTRYPOINT ["/app"] does not have the problem.
Attestations arrive on their own, SBOMs do not
BuildKit adds a mode=min provenance attestation by default: the record of who built what from which inputs is there even when nobody asked for it. An SBOM is not generated by default.
docker buildx build --provenance=mode=max --sbom=true --push -t app:1.0 .
For an internal service that is hygiene; for a product on the EU market it is a Cyber Resilience Act requirement with a September 2026 deadline. BuildKit provenance is groundwork for SLSA levels, not a replacement for them: signing and verification stay with the pipeline.
What distroless costs you
There is no shell in the image — so neither a HEALTHCHECK calling curl nor the familiar docker exec sh works. The health check moves to the kubelet, debugging to an ephemeral container (kubectl debug --target) or to the :debug tag, which adds busybox. If the application needs a shell at runtime, distroless simply does not fit, and that is a normal outcome: alpine at 8.42 MB does not ship a compiler to production either.
Checklist
- Multi-stage; only artifacts are copied into the final layer.
- The base is pinned by digest
@sha256:…, not by a tag alone. - Dependencies are copied and installed before the sources; heavy caches go through
--mount=type=cache. - Not one secret through
ARGorENV— only--mount=type=secret. USERwith a numeric UID,ENTRYPOINTin exec form..dockerignorecovers.git,.env,node_modules, Terraform state.--sbom=trueis on deliberately, not "when someone asks".
Not one item needs a new tool: all of it is BuildKit, on by default since Docker Engine 23.0. What is left is writing ten-odd lines in the right order.