Skip to content
Blog

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

BaseWhat’s in itlibcTypical sizeShell / package manager
ubuntu:24.04, debian:12Full distributionglibc~75–120 MBYes / apt
debian:12-slimTrimmed install, no docs/recommendsglibc~75 MBYes / apt (reduced)
node:22-bookworm-slim, python:3.13-slimLanguage runtime, minimal OSglibc~50–200 MBYes / apt
alpine:3.21BusyBox userlandmuslunder 6 MBsh / apk
gcr.io/distroless/*Runtime only: libc, ca-certs, tzdata, nonroot userglibc or muslSmallNo
scratchEmptynone0 MBNo

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.conf setups differently). If a container resolves names oddly, this is on the short list.
  • BusyBox tools are minimal. wget lacks flags, grep/sed differ in edge cases, ps output 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 sh does not work. Debugging happens from outside: kubectl debug with an ephemeral container, or a separate debug image built from the same artifacts.
  • No apt-get means no hotfixing a CVE in the running base — you rebuild.
  • Language-specific variants exist (distroless/python3-debian12, distroless/nodejs22-debian12, Java via jlink), and Java teams in particular pair jlink with 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

  1. Is it a compiled static binary? → scratch (add certs, tzdata, nonroot user).
  2. Does it need a runtime but you don’t need to shell in? → distroless variant.
  3. Is it an interpreted app (Node, Python, Ruby, Java)? → language:distro-slim as the default; Alpine only after tests pass on musl.
  4. 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.21 can resolve to a different patch tomorrow. Pinning alpine: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’s package-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 --pull forces a fresh check for a newer base; --no-cache re-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 -slim for 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 ENTRYPOINT on distroless and scratch.
  • Pin versions or digests, rebuild on a schedule, --pull in CI.
  • Scan images for CVEs as a merge gate.

Sources: Docker — building best practices, Docker — multi-stage builds, Dockerfile reference.