Base Images Compared: Alpine, Slim, Distroless, and Scratch
The FROM line decides your image size, your CVE count, your glibc-vs-musl headaches, and whether you have a shell to debug with. A practical guide to choosing a base image.
Published on • October 14, 2026
AI Assistant

Docker’s own build guidance puts it plainly: “The first step towards achieving a secure image is to choose the right base image.” Every package in your base is a package that can carry a vulnerability, an update obligation, and a bigger pull. The FROM line is the single highest-leverage decision in your Dockerfile — and the choices form a spectrum from “full distribution” to “literally nothing.”
The spectrum
| Base | What’s in it | libc | Typical size | Shell / package manager |
|---|---|---|---|---|
ubuntu:24.04, debian:12 | Full distribution | glibc | ~75–120 MB | Yes / apt |
debian:12-slim | Trimmed install, no docs/recommends | glibc | ~75 MB | Yes / apt (reduced) |
node:22-bookworm-slim, python:3.13-slim | Language runtime, minimal OS | glibc | ~50–200 MB | Yes / apt |
alpine:3.21 | BusyBox userland | musl | under 6 MB | sh / apk |
gcr.io/distroless/* | Runtime only: libc, ca-certs, tzdata, nonroot user | glibc or musl | Small | No |
scratch | Empty | none | 0 MB | No |
The tradeoff running down that table is one axis: debuggability and compatibility vs. size and attack surface. A full distro is easy to exec into and terrible to ship; scratch is ideal to ship and impossible to debug in place.
Full distributions: ubuntu, debian
Rarely the right final base, frequently the right build stage. Compilers, apt, gdb, build toolchains — all the things your app needs at build time and none of it should ship. Docker’s docs recommend exactly this split: two base images, one for building and unit testing, another (slimmer) for production.
Where a full base still makes sense: legacy apps with system-library dependencies you cannot untangle, images that must run arbitrary admin tooling in production, and Windows containers (where the options are different anyway).
Slim variants: the default answer for glibc
*-slim images strip documentation, locales, and recommended packages while keeping the dynamic loader, apt, and a shell. They are the pragmatic middle:
FROM node:22-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]
You keep glibc — no musl surprises with native npm modules or DNS resolution in some configurations — at a fraction of the full image’s weight. For most teams’ first optimization step, full → slim captures most of the win with none of the risk.
Alpine: tiny, with an asterisk
Docker’s best-practices page specifically recommends Alpine as a base — “tightly controlled and small in size (under 6 MB), while still being a full Linux distribution.” It uses musl libc and BusyBox, not glibc and GNU coreutils, and that has consequences:
- Native binaries must be built against musl. Prebuilt artifacts compiled for glibc (some Go gVisor-style tools, proprietary agents, older wheels) simply won’t run.
- DNS resolution behaves differently in edge cases (musl’s resolver historically handled very long search domains and some
/etc/nsswitch.confsetups differently). If a container resolves names oddly, this is on the short list. - BusyBox tools are minimal.
wgetlacks flags,grep/seddiffer in edge cases,psoutput changes. Scripts written for Ubuntu occasionally misbehave. - Math-heavy numeric code can be slower on musl in specific workloads.
Alpine is excellent for static Go and Rust binaries, scripts, and anything you control end-to-end. Run your test suite against it before committing — a CI job that builds glibc and ships Alpine is a production incident waiting for a Friday.
Distroless: no shell, on purpose
Distroless images contain only the application runtime and its minimal dependencies — no shell, no package manager, no debugging utilities. Google’s rationale: every removed binary is an attack surface a compromised container can no longer use. An attacker who pops a shell in a distroless container has nowhere to go.
You get sensible defaults: CA certificates, timezone data, and a nonroot USER (the :nonroot variants) — three things people routinely forget to add themselves.
The cost is operational:
docker exec -it container shdoes not work. Debugging happens from outside:kubectl debugwith an ephemeral container, or a separate debug image built from the same artifacts.- No
apt-getmeans no hotfixing a CVE in the running base — you rebuild. - Language-specific variants exist (
distroless/python3-debian12,distroless/nodejs22-debian12, Java viajlink), and Java teams in particular pairjlinkwith distroless for a small, tailored JRE.
FROM gcr.io/distroless/java21-debian12
COPY build/libs/app.jar /app.jar
ENTRYPOINT ["/app.jar"] # exec form — distroless has no shell to run one
Note the exec-form ENTRYPOINT: with no /bin/sh available, shell form will fail outright.
scratch: for truly static binaries
scratch is an empty image — no libc, no CA bundle, no timezone data, no user. It exists for one purpose: shipping a static binary.
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/server /server
USER 65532:65532
ENTRYPOINT ["/server"]
The three COPY lines are the parts people forget: TLS roots (or your app can’t do HTTPS), zone info (or timestamps come out UTC forever), and an explicit UID (or you run as root by default — and root in scratch is still root). Rust with musl, Go with CGO_ENABLED=0, and Zig all produce binaries that land here happily.
Choosing: a decision path
- Is it a compiled static binary? →
scratch(add certs, tzdata, nonroot user). - Does it need a runtime but you don’t need to shell in? → distroless variant.
- Is it an interpreted app (Node, Python, Ruby, Java)? →
language:distro-slimas the default; Alpine only after tests pass on musl. - Do you need to debug in-container or run admin tools? → keep a slim (or full) image for dev/staging; don’t optimize the dev loop at the cost of the production image.
And in every case, follow the two-base pattern: build in one image, run in another, copy artifacts across stages.
Keeping the base honest
Choosing well is not a one-time act:
- Pin versions, ideally digests. Tags are mutable —
FROM alpine:3.21can resolve to a different patch tomorrow. Pinningalpine:3.21@sha256:...guarantees reproducibility; the docs’ tradeoff is that you then opt out of automatic security fixes, so pair digest pinning with a scheduled update process (Dependabot’spackage-ecosystem: "docker"does exactly this). - Rebuild regularly. Images are snapshots; a base patched for CVE last month is still vulnerable in your year-old image.
docker build --pullforces a fresh check for a newer base;--no-cachere-runs the steps (they solve different problems and combine). - Source from trusted publishers. Docker Official Images, Verified Publisher, and Docker-Sponsored Open Source badges indicate curated, regularly updated repositories — the docs call this out as part of choosing the base.
- Scan what you ship. A smaller base usually means fewer CVEs, but “usually” is why scanning belongs in CI regardless.
Checklist
- Build and run in different bases (multi-stage).
- Default to
-slimfor glibc runtimes; justify every move to Alpine. - Test musl compatibility before shipping Alpine.
- Add certs, timezone data, and a nonroot user if the base doesn’t provide them.
- Use exec-form
ENTRYPOINTon distroless andscratch. - Pin versions or digests, rebuild on a schedule,
--pullin CI. - Scan images for CVEs as a merge gate.
Sources: Docker — building best practices, Docker — multi-stage builds, Dockerfile reference.