The Minimal Docker Image Guide: Alpine, Distroless, Scratch
Published August 8, 2026 · 9 min read
A minimal Docker image is not just a smaller download — it is fewer packages that can carry a CVE and less software available to an attacker after a compromise. This guide covers picking the right minimal tier for your language and verifying that the build actually stayed minimal.

The three minimal tiers
Below the full distribution image sit three progressively smaller tiers: Alpine (a shell and package manager, musl libc), distroless (a language runtime, no shell or package manager), and scratch (nothing at all). Each removes more software, and each requires more care in exchange. Our Alpine vs Debian vs Distroless comparison covers the middle two tiers in depth; this guide adds scratch and focuses on the build steps to reach each one.
Step 1: Build a static binary for scratch (Go)
FROM scratch provides nothing — no libc, no CA certificates, no shell. It only works with a statically linked binary:
FROM golang:1.23 AS build WORKDIR /src COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server FROM scratch COPY --from=build /out/server /server COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt ENTRYPOINT ["/server"]
CGO_ENABLED=0 forces a static build with no glibc dependency, and -ldflags="-s -w" strips debug symbols, shrinking the binary further. The CA certificate copy is required manually since scratch ships none — skip it and any outbound TLS connection will fail immediately.
Step 2: Build a static binary for scratch (Rust)
Rust needs the musl target instead of the default glibc target to produce a fully static binary:
FROM rust:1.82 AS build RUN rustup target add x86_64-unknown-linux-musl WORKDIR /src COPY . . RUN cargo build --release --target x86_64-unknown-linux-musl FROM scratch COPY --from=build /src/target/x86_64-unknown-linux-musl/release/server /server COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt ENTRYPOINT ["/server"]
Step 3: Use distroless when you need a runtime, not just a binary
Interpreted and JIT-compiled languages need their runtime present, which rules out scratch. Distroless gives you the runtime without a shell or package manager:
FROM node:22-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . FROM gcr.io/distroless/nodejs22-debian12 WORKDIR /app COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/index.js ./index.js CMD ["index.js"]
Our CVE reduction guide covers this pattern for Node, Python, and Go in more detail.
Step 4: Use Alpine when you genuinely need a shell
If your entrypoint needs a wrapper script, or your team relies on docker exec sh for debugging, Alpine is the minimal tier that still allows it:
FROM golang:1.23-alpine AS build WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server FROM alpine:3.20 RUN apk add --no-cache ca-certificates COPY --from=build /out/server /server COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"]
Step 5: Clean up build artifacts and caches
Even in a slim or Alpine final stage, package manager caches and downloaded archives add unnecessary bulk if left behind:
# Debian/Ubuntu
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Alpine
RUN apk add --no-cache ca-certificates
# npm
RUN npm ci --omit=dev && npm cache clean --forceCombine install and cleanup into the same RUN instruction — splitting them across separate instructions leaves the cache present in an earlier layer even after a later layer deletes it, since Docker layers are additive.
Verifying the image is actually minimal
Size alone is a weak signal — confirm with layer inspection and a scan of installed packages:
docker images myapp --format "{{.Tag}}\t{{.Size}}"
# Inspect what each layer added
dive myapp:minimal
# Confirm the package count in a scan
docker save myapp:minimal -o myapp.tar
curl -fsSL https://scanrook.io/install.sh | sh
scanrook scan --file myapp.tar --format json --out report.json
jq '.summary, (.findings | length)' report.jsonA genuinely minimal image should show a package count in the low single or double digits and a finding total near zero — if the scan still shows hundreds of findings despite a small reported size, an earlier build stage is likely leaking files forward via an overly broad COPY.
Where ScanRook fits
Image size and vulnerability count are correlated but not the same measurement. ScanRook reports the packages genuinely present in each layer, which is the only way to confirm a minimal build actually minimized attack surface rather than just compressing well. See our container security checklist or the docs to automate the check.
Frequently asked questions
What is the smallest possible Docker image?
FROM scratch contains nothing at all — it only works for statically linked binaries.
Should I use Alpine, distroless, or scratch?
Scratch for static binaries, distroless for a runtime without a shell, Alpine when you need a shell or package manager.
How do I build a static binary for scratch?
Set CGO_ENABLED=0 for Go, or target musl instead of gnu for Rust.
Does a smaller image mean fewer vulnerabilities?
Generally yes, since most findings come from installed OS packages — but a scan is the only way to confirm it.
Confirm minimal means minimal
Scan your image to see the actual package count and finding total, not just the compressed size on disk.