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

  1. Is your binary fully static and self-contained? If yes — pick scratch or distroless/static. Add ca-certificates if it makes outbound TLS calls.
  2. 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-box nonroot user.
  3. 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-slim or ubuntu:NN.NN-slim. Bigger but predictable.
  4. 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.
Don't mix glibc and musl across a multi-stage build. If you compile against glibc (typical for 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

Where to read next