How it works
The explanation, with examples you can run.
A Go service, two stages
# syntax=docker/dockerfile:1FROM golang:1.27-alpine AS buildWORKDIR /srcCOPY go.mod ./RUN go mod downloadCOPY . .RUN --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/hello . FROM gcr.io/distroless/static-debian13:nonrootCOPY --from=build /out/hello /helloEXPOSE 8080ENTRYPOINT ["/hello"]docker build -t hello-go:1 .docker build --target build -t hello-go:build . # stop after the first stage, for comparisondocker image ls --tree# IMAGE DISK USAGE CONTENT SIZE# hello-go:1 14.4MB 3.15MB# hello-go:build 381MB 74.4MBThe shipped image is about 3 MB to download instead of 74 MB, and it contains one binary plus the CA certificates, timezone data and /etc/passwd entries that distroless provides. With BuildKit, stages that the target does not depend on are skipped, and independent stages build in parallel.
The same pattern works for any language: build a frontend bundle with Node and copy dist/ into an nginx or static-server stage; run mvn package in a JDK stage and copy the jar into a JRE-only stage; install Python wheels in a builder and copy the virtual environment into a slim runtime.
Choosing the runtime base
| Base | Size | Has a shell? | Notes |
|---|---|---|---|
debian:*-slim, python:*-slim | tens of MB | yes | glibc, apt; the most compatible choice |
alpine | a few MB | yes (BusyBox) | musl libc: some prebuilt binaries and DNS edge cases differ |
| distroless | a few MB and up | no (:debug tags add BusyBox) | runtime files only; :nonroot tags run as UID 65532 |
scratch | empty | no | only for fully static binaries; you add certificates yourself |
There is no single right answer. Alpine is small but its musl libc is a different C library from the glibc most software is tested against. Distroless removes the shell and package manager, which shrinks the attack surface and the CVE list, but you debug from outside the container. Slim Debian images are larger and boring, which is often what you want for interpreted languages with native extensions.
What to check
docker run --rm --entrypoint sh hello-go:1fails withexec: "sh": executable file not found in $PATH: no shell, as intended.docker image inspect hello-go:1 --format '{{.Config.User}}'prints65532: the:nonrootbase already sets a non-root user.- Dev dependencies stay out: for Node, run
npm ci --omit=dev(or prune) in the stage you copynode_modulesfrom. - Keep the build stage's base pinned too; a new compiler version is a change like any other.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.