The FROM line has more downstream impact than almost any other decision in a Dockerfile:
| Base type | Example | Size | Trade-off |
|---|---|---|---|
| Full distribution | node:20, ubuntu:22.04 | Largest (100s of MB+) | Includes a shell, package manager, debugging tools — easiest to work in |
| Slim | node:20-slim | Smaller | Debian-based but stripped of most non-essential packages |
| Alpine | node:20-alpine | Much smaller (~5–10x less than full) | Uses musl libc instead of glibc — occasionally breaks native modules that assume glibc |
| Distroless | gcr.io/distroless/nodejs20 | Smallest practical option | No shell, no package manager at all — can't docker exec a shell into it, meaningfully reduces attack surface |
Alpine's smaller size comes from a genuinely different C standard library (musl instead of glibc), not just fewer packages — this occasionally surfaces as a real bug: a native Node addon or Python package compiled against glibc can fail to run (or behave subtly differently) on Alpine, which is why "just switch to alpine" sometimes needs more than swapping the FROM line. Distroless images take the size and security reduction further by removing the shell entirely, which is great for a production runtime but means you cannot docker exec -it container sh into one to debug — you'd need a debug variant of the image, or an ephemeral debug container attached to the same namespaces, for interactive troubleshooting.
A smaller image isn't purely about disk space — it's a smaller attack surface (fewer installed packages means fewer potential CVEs to patch), faster pulls (matters a lot for autoscaling, where new instances need the image before they can serve traffic), and faster CI.