Originally published on docker.learnio.dev — part of the Docker fundamentals course. View this lesson on the original site.
The FROM line sets the floor for every layer above it
Every image is a stack of layers. The base image is the bottom one. Everything you add in RUN, COPY, and package installs sits on top of whatever FROM pulled in.
Pick a base too heavy and every environment pays on every pull. Pick a base too minimal and on-call loses shells, package managers, or libc compatibility your app assumed. The goal is not the smallest tag on Docker Hub; it is a base your stack has been validated on.
What you are actually choosing
| Concern | Question to answer |
|---|---|
| Pull size | How many megabytes does every deploy and every laptop download? |
| libc | Do native binaries or PHP extensions assume glibc or musl? |
| Tooling | Do you need apt or apk at runtime, or only during build? |
| Incidents | Can someone debug without docker exec into a shell? |
Official guidance: use a trusted publisher and keep the image as small as practical. Practical still means the container survives a real outage.
Three families you will see in the wild
| Base style | Typical traits | Good fit |
|---|---|---|
| Debian or bookworm-slim | glibc, apt, familiar tooling, larger pull | Default when teams want straightforward debugging |
| Alpine | musl, apk, smaller pull | Services with extensions and binaries tested on musl |
| Distroless or minimal runtime | tiny filesystem, often no shell | Self-contained compiled apps; advanced PHP setups |
These are patterns, not a leaderboard. Debian-family bases are a sane default until measurement and tests say otherwise.
flowchart TD
Start["Pick a base for a new service"]
Q1{"Need apt/apk or shell at runtime?"}
Q2{"Native deps validated on musl?"}
Q3{"Self-contained binary + debug story?"}
Debian["debian/bookworm-slim or official php:*-bookworm"]
Alpine["alpine when tests pass on musl"]
Distro["distroless or minimal runtime"]
Multi["multi-stage: fat build, slim final"]
Start --> Q1
Q1 -->|yes, often| Debian
Q1 -->|no, build only| Multi
Multi --> Q3
Q3 -->|yes| Distro
Q3 -->|no / PHP-FPM typical| Debian
Debian --> Q2
Q2 -->|yes, measured smaller| Alpine
Q2 -->|no or unsure| Debian
Compare pull size with the same app on two bases
Use one trivial Dockerfile shape and swap only the FROM line. That keeps the comparison honest.
GitHub example (clone and run)
Use the ready-made project with Dockerfile.debian, Dockerfile.alpine, and a short README:
Academy repo: 2-images-layers/fat-slim-distroless-bases
git clone https://github.com/stuarttodd-dev/halfshellstudios-academy.git
cd halfshellstudios-academy/docker-fundamentals/2-images-layers/fat-slim-distroless-bases
docker build -f Dockerfile.debian -t base-compare:debian .
docker build -f Dockerfile.alpine -t base-compare:alpine .
docker image ls base-compare
docker run --rm base-compare:debian
docker run --rm base-compare:alpine
docker build -f Dockerfile.distroless -t base-compare:distroless .
docker run --rm base-compare:distroless
The folder includes Dockerfile.debian, Dockerfile.alpine, Dockerfile.distroless, main.go, and a README.md with the full workflow.
Create both Dockerfiles (if you are not using the repo)
mkdir -p /tmp/base-compare && cd /tmp/base-compare
cat > Dockerfile.debian <<'EOF'
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
CMD ["curl", "--version"]
EOF
cat > Dockerfile.alpine <<'EOF'
FROM alpine:3.20
RUN apk add --no-cache curl
CMD ["curl", "--version"]
EOF
Build and list sizes
{
"ariaLabel": "Two docker build commands with different -f Dockerfile flags",
"segments": [
{ "text": "docker", "bottomLabel": { "text": "CLI" } },
{ "text": "build", "topLabel": { "text": "Command", "variant": "command" }, "highlight": "command" },
{ "text": "-f", "bottomLabel": { "text": "Dockerfile path" } },
{ "text": "Dockerfile.debian", "bottomLabel": { "text": "Debian variant" } },
{ "text": "-t", "bottomLabel": { "text": "Tag" } },
{ "text": "base-compare:debian", "topLabel": { "text": "Tag", "variant": "tag" }, "highlight": "tag" }
]
}
docker build -f Dockerfile.debian -t base-compare:debian .
docker build -f Dockerfile.alpine -t base-compare:alpine .
docker image ls base-compare
docker run --rm base-compare:debian
docker run --rm base-compare:alpine
Both runs should print a curl version line. The SIZE column in docker image ls is what you are comparing.
Read the numbers without overfitting
Alpine is often smaller on disk. That does not automatically make it your production default.
- glibc vs musl matters for some native PHP extensions and prebuilt binaries
- slim Debian images still include apt when you need a quick package during an incident
- a smaller base does not fix a bloated
COPY . .or a fat application layer above it
If Alpine wins on size but fails your extension or integration tests, the measurement still helped: you learned the trade-off with evidence.
Distroless: when the filesystem is mostly your binary
Distroless images ship runtime files without apt, apk, or an interactive shell. They suit processes that are self-contained after build (many Go or Java services).
A typical PHP-FPM deployment still wants extension tooling during the image build and often wants a shell during incidents unless you have another debug path (sidecar, ephemeral debug image, or remote logging you trust).
Treat distroless as a deliberate ops choice, not a default FROM because the name sounds secure.
Multi-stage: fat build, slim run
You do not have to pick one base for the whole Dockerfile. A common pattern keeps compilers and package managers in a build stage and copies only artifacts into a smaller final stage:
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY . .
RUN go build -o /out/hello .
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/hello /hello
ENTRYPOINT ["/hello"]
Build tools stay in the build stage. Only /hello lands in the final image. PHP teams reuse the same idea later: Composer and extensions in a build stage, a smaller FPM runtime stage for production.
Decision checklist before you change production
- Did you measure pull time and
docker image lssize, or only read the tag name? - Do native libraries or extensions assume glibc?
- Do you need a package manager or shell at runtime?
- If the final image has no shell, what is the on-call debug story?
- Can build-time tooling move to a separate stage instead of shrinking the runtime below what incidents need?
Default to a well-known Debian-family base until tests and metrics say otherwise. Move to Alpine when your real stack passes there. Move toward distroless when you accept the operational constraints and have rehearsed debugging without exec into bash.
Try it yourself
- Clone the GitHub example or create
/tmp/base-comparewith the two Dockerfiles above. - Build
base-compare:debianandbase-compare:alpine. - Record both
SIZEvalues fromdocker image ls base-compare. - Run both containers and confirm
curl --versionprints on stdout. - Pick which base you would choose for a new internal API service and write one sentence citing size, libc, or debugging — not slogans.
Success looks like: you can explain why two tags differ in megabytes, name one reason Alpine might still be wrong for your stack, and describe when multi-stage separates build fat from runtime slim.
From Docker fundamentals — Images, layers, tags, and everyday commands