Choosing a base image: Alpine, Slim, distroless, scratch
Last reviewed on 2026-05-02
The FROM line is the most consequential choice in your Dockerfile. This guide walks through the four families in common use, the trade-offs that distinguish them, and a decision flow for picking one without churning through five iterations.
Why this choice matters
Your base image determines image size, available system libraries, attack surface, debuggability, and how predictable your security patching cadence will be. Switching base images late in a project's life is doable but disruptive: the libc may differ, package names may differ, glibc versus musl can change runtime behaviour, and your CI cache becomes stale overnight. It is much cheaper to make a deliberate choice up front.
The four families
Alpine
Alpine Linux uses musl libc and BusyBox, giving extremely small images (a few MB). It is a community favourite for microservices in Go, Rust, or any language whose runtime ships statically linked.
The trade-off is musl. Most software is tested on glibc; switching to musl can produce subtle DNS resolution differences, slower performance for some glibc-tuned workloads, and missing locale data. Languages with a JIT or with native dependencies (Python's NumPy, Node.js native addons, Java) hit musl issues more often than statically compiled ones.
Debian / Ubuntu "slim"
The -slim variants of Debian and Ubuntu strip documentation, locale data, and unnecessary tools while keeping glibc and a familiar package manager. They typically land in the 30–80 MB range and behave like a normal Linux box. Most software runs unchanged.
This is the boring, safe choice for almost any application. You get a familiar shell, a real package manager for hotfixes, and predictable runtime behaviour — at the cost of a larger image than Alpine or distroless.
Distroless
Distroless images contain only the language runtime (or nothing) plus your application's libraries and CA certificates. There is no shell, no package manager, no ls. Variants exist for Java, Node.js, Python, and a "static" image for statically linked binaries.
You get a minimal attack surface and small images while keeping glibc compatibility (most distroless images are based on Debian). The cost is debuggability: you cannot docker exec into a running container and poke around. You compensate with structured logs, sidecar debug containers in production, or a separate "debug" tag during incidents.
Scratch
The empty image. Useful only for languages whose binaries are fully static and have no runtime dependencies — most commonly Go, Rust, and some C/C++. Image size approaches the binary size.
You'll typically need to copy ca-certificates from a builder stage, plus /etc/passwd if you want to run as a non-root user. Beyond that, scratch is glorious — but only if your binary is truly self-contained.
Side-by-side comparison
| Criterion | Alpine | Debian slim | Distroless | Scratch |
|---|---|---|---|---|
| Typical size | 5–15 MB + app | 30–80 MB + app | 2–80 MB + app | ~0 MB + app |
| libc | musl | glibc | glibc | n/a |
| Has a shell? | Yes (BusyBox) | Yes (bash/dash) | No (debug variants do) | No |
| Package manager? | apk | apt | None | None |
| Best fit | Static or simple binaries | Most workloads | Hardened production | Fully static binaries |
Decision flow
- Is your binary fully static and self-contained? If yes — pick
scratchordistroless/static. Addca-certificatesif it makes outbound TLS calls. - Is your runtime dynamically linked but you control all dependencies? Use a distroless image for your language (e.g.
distroless/java,distroless/nodejs). You get glibc, a minimal surface, and an out-of-the-boxnonrootuser. - Does your stack have a long tail of native dependencies, glibc-specific behaviour, or "we'll need to install something during incidents"? Pick
debian:NN-slimorubuntu:NN.NN-slim. Bigger but predictable. - Are you running a small, statically compiled service and image size dominates other concerns? Alpine is fine — but test for musl-specific issues in CI before adopting it widely.
cgo in Go, or for Python wheels with C extensions) and then copy the binary into Alpine, you'll either get cryptic loader errors or silently broken behaviour. Either build statically, or build and run on the same libc.
Practical worked example: a Go service
FROM golang:1.22-alpine AS builder
ENV CGO_ENABLED=0 GOOS=linux
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
Compile statically (CGO_ENABLED=0), then ship onto distroless static. Final image is essentially the size of the Go binary, runs as a non-root user out of the box, and has zero attack surface beyond the binary itself.
Practical worked example: a Node.js service
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --chown=nonroot:nonroot . .
EXPOSE 8080
CMD ["server.js"]
Install dependencies on full Debian slim (so any native modules build correctly against glibc), then ship onto distroless Node. The end-result image is a fraction of node:22's size and contains no shell, no npm, and no apt.
Common mistakes
- Using
node:latestorpython:3. Floating tags break reproducibility. Pin to a specific minor version with a slim variant:node:22-bookworm-slim,python:3.13-slim. - Copying
node_modulesbuilt on Alpine into Debian (or vice versa). Native modules will mismatch. Build dependencies on the same libc family you'll run on. - Picking distroless and then needing to debug. Plan for it: keep a parallel debug tag, or use
kubectl debugwith an ephemeral container. - Picking scratch and forgetting
ca-certificates. Outbound TLS will fail with cryptic certificate errors. - Pinning by digest only and never refreshing. A pinned image is a fossil if you never re-pin. Schedule a recurring update so you pick up CVE fixes upstream.
Where to read next
- FROM reference — pinning, tags, digests.
- Multi-stage builds tutorial — the pattern these examples rely on.
- Securing Docker builds tutorial — non-root containers, image scanning, secrets.
- USER reference — running as a numeric, non-root UID.
- Security best practices for Docker images