Building Alpine and Ubuntu Images from the Scratch Base
Most tutorials start with FROM alpine or FROM ubuntu and stop there. The scratch image is different: it is empty—no shell, no package manager, no /bin/sh. This guide explains what scratch is, when you ship a single static binary on top of it, and how to assemble minimal Alpine- or Ubuntu-style root filesystems in multi-stage builds without guessing what ends up in your final layer.
In short
scratch is the zero-byte OCI base. Use it when your app is a statically linked binary (Go, Rust) or when a builder stage copies a trimmed rootfs into the final stage. For day-to-day services, official alpine and ubuntu images are pre-built rootfs you extend with Dockerfiles—understanding scratch teaches you what those images actually contain and how to shrink attack surface.
What “scratch” means (not “sketch”)
In Docker Hub and BuildKit, the reserved name scratch is not a Linux distribution. It is a marker for “no base layer”—the first layer of your image is whatever you add in the Dockerfile. You cannot docker run -it scratch and get a shell; there is nothing to execute unless your Dockerfile copies files and sets CMD or ENTRYPOINT.
People sometimes say “sketch image” when they mean scratch. The mental model is a blank canvas: you paint the root filesystem yourself, or you copy one in from another stage.
For how images become running processes on the host kernel, see Docker and containerization — the hidden side. For Linux paths and permissions inside those roots, see Linux in depth.
Three ways teams build container images
| Approach | Base | Typical use | Trade-off |
|---|---|---|---|
| Distro base | alpine:3.20, ubuntu:24.04 |
Apps needing shell, apt/apk, interpreted runtimes |
Larger image; must patch OS packages |
| Scratch + static binary | scratch |
Go/Rust CLIs, minimal microservices, kube-proxy-style bins |
No libc unless static; no shell for debug unless you add tools in another stage |
| Scratch + copied rootfs | scratch (final stage) |
Custom minimal OS tree built in builder with debootstrap, apk, or distroless-style export |
More Dockerfile complexity; you own CVE response for copied libs |
Distroless (Google’s images) sits between distro and scratch: a curated rootfs with your runtime but no shell. Many teams use distroless for Java or Node production; scratch remains the smallest possible end state when one binary is enough.
Pattern 1: Static binary on scratch (smallest image)
Go is the classic example: build with CGO disabled so the binary does not depend on musl/glibc from the base image.
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app
FROM scratch
COPY --from=build /out/app /app
# CA certs only if your app talks HTTPS to the public internet
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 65532:65532
ENTRYPOINT ["/app"]
Verify locally:
docker build -t myapp:scratch .
docker run --rm myapp:scratch
docker image inspect myapp:scratch --format '{{.Size}}'
Hidden gotchas:
- CGO and DNS: With
CGO_ENABLED=0, Go uses its own resolver; in Kubernetes that is usually fine. With CGO on Alpine you need musl in the final image—not scratch alone. - Time zones and files: Scratch has no
/etc/localtimeor writable/tmpunless you copy or mount them. - Debugging:
docker execneeds a shell. Usekubectl debugephemeral containers or a “debug” image tag built from the same artifact withbusyboxin a non-prod stage.
Pattern 2: Alpine base image (small distro you extend)
Alpine Linux uses musl and apk. Official alpine images on Docker Hub are already a complete mini rootfs—not built from scratch in your pipeline, but maintained by the Alpine/docker-library team.
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --chown=app:app mybinary /app/mybinary
USER app
ENTRYPOINT ["/app/mybinary"]
Best practices:
- Pin tags:
alpine:3.20.3or digestalpine@sha256:…in CI. - Combine
RUNlines to reduce layers; remove caches in the same layer (--no-cacheon apk). - Prefer multi-stage: compile in
golang:alpine, copy binary into slim Alpine runtime.
Alpine is ~5–8 MB compressed for the base; adding busybox and certs stays small. Watch compatibility: some wheels and native libs expect glibc (Ubuntu/Debian), not musl.
Pattern 3: Ubuntu base image (glibc, apt, familiar ops)
Ubuntu images suit Python, .NET, Java, and teams that want GNU userland and apt. They are larger than Alpine but reduce “works on my laptop” friction.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -r -u 10001 app
WORKDIR /app
COPY --chown=10001:10001 app.jar /app/
USER 10001
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Use --no-install-recommends and delete /var/lib/apt/lists/* in the same RUN so apt metadata does not bloat layers. For even smaller Debian-based images, consider debian:bookworm-slim when you do not need Ubuntu-specific branding or packages.
Pattern 4: Ubuntu or Alpine rootfs copied onto scratch
Advanced teams build a root filesystem in a builder stage, then copy only required directories into FROM scratch. That is how you get “Ubuntu libraries without Ubuntu’s full userland” or a custom Alpine tree.
# Example: copy a minimal Alpine tree from the official image
FROM alpine:3.20 AS alpine-root
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=alpine-root /etc/ssl/certs /etc/ssl/certs
COPY --from=alpine-root /lib/ld-musl-x86_64.so.1 /lib/
COPY --from=alpine-root /lib/libc.musl-x86_64.so.1 /lib/
COPY mybinary /mybinary
ENTRYPOINT ["/mybinary"]
You must copy every dynamic linker and shared library your binary needs—use ldd mybinary on the builder. Missing .so files fail at runtime with confusing errors. For Ubuntu/glibc, the same applies with ld-linux-x86-64.so.2 and paths under /lib/x86_64-linux-gnu/.
Tools that automate rootfs assembly:
- debootstrap / multistrap — bootstrap Debian/Ubuntu chroots on build hosts.
- apk in Alpine builder — install packages into a staging directory with
--root. - buildah / podman — construct OCI images without a classic Dockerfile (useful in locked-down CI).
Multi-stage layout (recommended default)
One Dockerfile, two roles: builder (fat) and runtime (thin). The runtime stage chooses scratch, Alpine, or Ubuntu.
# Builder: compile or install dependencies
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server .
# Runtime: pick ONE
FROM alpine:3.20 AS runtime-alpine
RUN apk add --no-cache ca-certificates
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
# Or: FROM scratch + COPY binary + certs (as in Pattern 1)
BuildKit can target a stage: docker build --target runtime-alpine -t app:alpine .. CI matrices often build both Alpine and Ubuntu tags from shared compile stages to satisfy different compliance or libc requirements.
Alpine vs Ubuntu — decision guide
| Factor | Alpine | Ubuntu (or Debian slim) |
|---|---|---|
| Size | Smallest common glibc/musl distro base | Larger; slim variants help |
| C library | musl | glibc |
| Package manager | apk | apt |
| Corporate familiarity | Common in K8s examples | Common on VMs and LTS support contracts |
| When scratch wins | After static Go/Rust build | Rare for interpreted stacks; sometimes static CGo-off builds |
Neither replaces patching: schedule rebuilds when CVE scanners flag OS packages. Pin digests in production manifests—the same discipline as in Kubernetes day-one practices.
Security and supply chain
- Smaller image ≠ secure by default. Scratch reduces attack surface only if the binary and copied libs are trusted and updated.
- Do not put secrets in layers. Multi-stage builds still leak if you
COPY .envin an early stage. Use BuildKit secrets or runtime injection. - Scan every tag. Trivy, Grype, or registry scanners on
alpine,ubuntu, and scratch-based images alike. - Non-root USER in Dockerfile; Kubernetes
securityContext.runAsNonRootas a backstop. - Read-only root where the orchestrator allows it; scratch images often work well with read-only roots because they ship few writable paths.
CI/CD: building and publishing
Pipeline steps mirror any container build: build, test, scan, push by digest. Example GitHub Actions sketch:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/org/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
For deeper CI patterns, see GitHub CI/CD in depth and GitLab CI/CD in depth. Agents often use docker:24-dind or Kaniko with the same Dockerfiles.
Troubleshooting checklist
exec format error— binary arch mismatch (arm64 vs amd64) or wrong dynamic linker.no such file or directoryon a present binary — missing shared library; runlddin builder.- TLS failures on scratch — forgot
ca-certificates.crt. standard_init_linux.go/ permission denied — file not executable or wrongUSER.- Image huge despite scratch final stage — secrets or build artifacts copied in an earlier layer; use
.dockerignoreand inspectdocker history.
Hands-on lab (15 minutes)
- Build a Go hello-world with
FROM scratch; note image size withdocker images. - Rebuild with
FROM alpine:3.20and the same binary; compare size anddocker run -it … sh. - Rebuild with
FROM ubuntu:24.04, install onlyca-certificates, compare again. - Run
docker scout quickviewor Trivy on all three tags. - Push one tag by digest to your registry and reference
image@sha256:…in a test Deployment.
Further reading
- Docker documentation —
scratch, multi-stage builds, BuildKit - Alpine Linux wiki — musl and apk package management
- Ubuntu Docker Hub — tags, LTS cadence, minimal variants
- Google distroless — language-specific minimal runtimes
- OCI image spec — layers, config, and portability across runtimes
Blog index · Docker hidden side · Linux in depth · K8s day-one practices