Best practices

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.

Building minimal Docker images

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 --force

Combine 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.json

A 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.

Related Posts

More on this topic.