Small images boot faster, save bandwidth, and have smaller attack surface. Here are the techniques that actually work.

Multi-stage builds

The single biggest win. Build in one stage, copy only the artifacts to a minimal runtime stage. A Go binary of 15 MB ends up in a 17 MB image. Compare to a naive golang:1.22 image at 900+ MB.

Base image choice

From smallest to largest for Go/Rust static binaries:

  • scratch — nothing. Binary only.
  • gcr.io/distroless/static-debian12 — just CA certs, tzdata, user DB. About 2 MB.
  • alpine:3.19 — minimal userland with apk. About 5 MB.
  • debian:12-slim — full Debian trimmed. About 75 MB.
  • ubuntu:24.04 — full Ubuntu. About 80 MB.

For Python, Java, Node, you need something with a working userland. Alpine works but has musl libc, which sometimes causes subtle bugs with pre-built wheels.

Things that balloon images unexpectedly

  • apt-get install without apt-get clean and rm -rf /var/lib/apt/lists/*
  • pip install caching wheels in /root/.cache
  • npm install retaining dev dependencies (use npm ci –omit=dev)
  • Build tools kept in the final image
  • Log files and /tmp contents included

Measuring

docker images shows total size, but docker history myimage shows the delta each layer added. For deeper analysis, dive myimage:tag gives a layer-by-layer interactive explorer.