Dockerfile FROM and Choosing a Base Image
FROM decides your image size and patch surface. Alpine vs slim vs distroless vs scratch, why Alpine hurts Python, and pinning by digest.
Every Dockerfile begins with FROM. It decides the operating system, the C library, the package manager, the size of the image, and how much of your security patching is someone else's job. It is the most consequential line in the file.
FROM <image>
FROM <image>:<tag>
FROM <image>@<digest>
FROM <image>:<tag> AS <stage-name>
Choose the variant, not just the image
Most official images publish several variants, and the difference is dramatic:
| Tag | Approx. size | What you get |
|---|---|---|
node:22 | ~1.1 GB | Full Debian, compilers, git, everything |
node:22-slim | ~240 MB | Debian with the extras stripped out |
node:22-alpine | ~135 MB | Alpine Linux, musl libc |
gcr.io/distroless/nodejs22 | ~150 MB | Runtime only (no shell, no package manager) |
scratch + static binary | ~8 MB | Nothing but your binary |
Those numbers are the uncompressed local size; what you push is smaller. The ranking is what matters, not the digits.
Alpine is smaller but not free
Alpine uses musl libc rather than glibc, and that one substitution causes most Alpine surprises.
Node.js packages with native bindings, bcrypt, sharp, canvas, ship prebuilt binaries for glibc. On Alpine they compile from source, which needs build tooling in the image and adds minutes to the build.
Python is the sharp one. The manylinux wheels on PyPI are glibc-only, so pip install on Alpine builds nearly everything from source. A pandas install that takes 20 seconds on python:3.12-slim can take many minutes on python:3.12-alpine, and the resulting image is frequently larger because the compiler had to come along. For Python, default to -slim, not -alpine.
There have also been long-standing reports of musl's DNS resolver behaving differently from glibc's under certain cluster configurations. Rare, but memorable when it happens.
Alpine remains an excellent choice for Go, Rust, and anything without native extensions.
Distroless: small without the drawbacks
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=build /app /app
WORKDIR /app
CMD ["server.js"]
Distroless images are glibc-based, so no musl surprises, but contain no shell, no package manager and no coreutils. That removes most of the toolkit an attacker would reach for after breaking in.
The trade is debugging: docker exec -it <container> sh does not work, because there is no sh. Use the :debug tag variant when you need a shell, or attach an ephemeral debug container in Kubernetes with kubectl debug.
FROM scratch
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server .
FROM scratch
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
scratch is an empty filesystem. Only a statically linked binary can run in it, which is why CGO_ENABLED=0 is essential, without it the Go binary links against libc and dies immediately:
standard_init_linux.go:228: exec user process caused: no such file or directory
That message names no file because the missing file is the dynamic linker. Two other things scratch lacks that catch people out: TLS root certificates and timezone data. Copy them in when you need them:
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
Pin the tag. Then pin the digest.
FROM node:latest # changes without warning
FROM node:22 # still a moving target: 22.x advances
FROM node:22.11.0-alpine3.20 # a specific release
FROM node:22-alpine@sha256:bf77dc26e48ea95fca9d1aceb5acfa69d2e546b765ec2abfb502975f1a2d4def
A tag is a mutable pointer. node:22 today and node:22 next month are different images, which is how a build that worked yesterday fails this morning with no commit in between.
A digest is content-addressed and cannot change. That is what you want for production and for anything a compliance process looks at.
The obvious objection, "then I never get security patches", is answered by automation, not by leaving the tag loose. Renovate and Dependabot both raise pull requests that bump a pinned digest, which gives you the update and a reviewable record of it.
For everything else, rebuild against the tag periodically:
docker build --pull -t myapp:latest .
docker inspect node:22-alpine --format='{{index .RepoDigests 0}}'
node@sha256:bf77dc26e48ea95fca9d1aceb5acfa69d2e546b765ec2abfb502975f1a2d4def
Without --pull, a locally cached base image can be months old and your "fresh" build inherits every one of its CVEs.
Multi-stage: each FROM starts a stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Only the final stage ships. Compilers, dev dependencies and source stay behind.
Name stages with AS. You can reference them by index, but COPY --from=build survives someone inserting a stage above it and COPY --from=0 does not.
Build a single stage when you want to debug it:
docker build --target build -t myapp:debug .
--from also reads from images you never based anything on, which is often the cleanest way to obtain one binary:
COPY --from=hashicorp/terraform:1.9 /bin/terraform /usr/local/bin/terraform
Platform targeting
FROM --platform=linux/amd64 node:22-alpine
Use this sparingly. It pins every stage to one architecture. The better pattern lets BuildKit tell each stage what it is building for:
FROM --platform=$BUILDPLATFORM golang:1.23 AS build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/server .
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:1.0 --push .
Building on an Apple Silicon Mac and deploying to amd64 servers without this produces an exec format error at container start, which is an architecture mismatch rather than anything wrong with the image.
Compare Base Image Sizes
You want to pull both node:20 and node:20-alpine, then compare their sizes side by side. Which sequence of commands is correct?
Choosing, in one table
| Situation | Base image |
|---|---|
| Go or Rust, static binary | scratch, or distroless/static for certs |
| Node.js, production | -alpine, or distroless for less attack surface |
| Node.js with native modules | -slim |
| Python, almost always | -slim (Alpine forces source builds) |
| Java | eclipse-temurin:*-jre-alpine, JRE not JDK |
| Needs a debugger in-image | -slim; distroless has no shell |
| Base for internal images | debian:bookworm-slim or alpine:3.20 |
Common mistakes
| Mistake | Consequence |
|---|---|
FROM node:latest | Non-reproducible; breaks with no code change |
| Alpine for Python | Source builds, slow CI, often a bigger image |
Building without --pull | Inherits a stale cached base and its CVEs |
FROM scratch with CGO_ENABLED=1 | no such file or directory at start |
scratch with no CA certificates | Every HTTPS call fails |
COPY --from=0 | Silently wrong after a stage is inserted |
| JDK in the runtime image | Hundreds of MB and a compiler for an attacker |
--platform hardcoded | Only builds for one architecture |
Quick reference
| Task | Command |
|---|---|
| Named stage | FROM node:22-alpine AS build |
| Pin by digest | FROM node:22-alpine@sha256:… |
| Find the digest | docker inspect <img> --format='{{index .RepoDigests 0}}' |
| Refresh the base | docker build --pull -t myapp . |
| Build one stage | docker build --target build -t myapp:debug . |
| Multi-arch build | docker buildx build --platform linux/amd64,linux/arm64 --push . |
| Compare variants | docker images node |
Key takeaways
FROMdecides size, patch surface and your C library. Almost every other optimisation is smaller than this one.- Alpine means musl, which is great for Go and Rust and actively harmful for Python.
- Distroless gets you small without musl, at the cost of having no shell to debug with.
scratchneeds a static binary, plus CA certificates and timezone data copied in.- Tags move; digests do not. Pin a digest and let Renovate or Dependabot bump it.
docker build --pullor your base image quietly rots in the local cache.- Name your stages.
COPY --from=0breaks the moment someone inserts a stage.
Check your understanding
An Alpine image that got bigger
A team switches a Python service from python:3.12-slim to python:3.12-alpine expecting a smaller image. The build takes eight minutes instead of forty seconds, and the final image is larger. What happened?
Frequently Asked Questions
Should I use Alpine or slim for my Docker base image?
It depends entirely on the language. Alpine uses musl instead of glibc, which is fine for Go and Rust but costly for anything relying on prebuilt native binaries. For Python specifically, prefer -slim: PyPI's manylinux wheels are glibc-only, so Alpine forces pip to compile from source, which makes builds many times slower and often produces a larger image than the slim variant. Node.js sits in between. Alpine is fine until you need bcrypt or sharp, at which point -slim saves the trouble.
Why shouldn't I use the latest tag in a Dockerfile?
Because a tag is a mutable pointer, not a fixed image. node:latest resolves to something different over time, so the same Dockerfile and the same source produce different images on different days, and a build can break with no change on your side. The same applies more subtly to node:22, which advances through 22.x. Pin a full version tag at minimum, and a digest when reproducibility genuinely matters. Then use Renovate or Dependabot to bump it, so updates arrive as reviewable pull requests.
What is a distroless image and when should I use one?
A distroless image contains your application's runtime and nothing else, no shell, no package manager, no coreutils. It is glibc-based, so it avoids Alpine's musl compatibility problems while still being small, and the missing tooling removes most of what an attacker would use after gaining execution. The cost is debugging: docker exec … sh fails because there is no shell. Use the :debug tag variant when you need one, or kubectl debug to attach an ephemeral container in Kubernetes.
Why does my FROM scratch container exit immediately with "no such file or directory"?
The binary is dynamically linked and the file the kernel cannot find is the dynamic linker, not your program, which is why the message names nothing useful. Build statically: for Go that means CGO_ENABLED=0 go build, for Rust the musl target. Two other things scratch does not provide are TLS root certificates and timezone data, so copy /etc/ssl/certs/ca-certificates.crt from the build stage if your application makes HTTPS calls, or use gcr.io/distroless/static, which includes both.
How do I pin a Docker base image to a digest?
Run docker inspect node:22-alpine --format='{{index .RepoDigests 0}}' to get the digest, then write FROM node:22-alpine@sha256:… in the Dockerfile. A digest is content-addressed, so it always resolves to the exact same bytes. Keep the human-readable tag alongside it for legibility. Pair this with Renovate or Dependabot, which raise a pull request when the underlying tag moves, that way you get reproducible builds and still receive security patches, rather than trading one for the other.
What is the difference between multiple FROM lines and multiple Dockerfiles?
Multiple FROM lines create stages within a single build, and only the final stage becomes the image. Earlier stages exist purely so later ones can copy artefacts out with COPY --from=build, which is how compilers and dev dependencies stay out of what ships. That is almost always better than maintaining separate build and runtime Dockerfiles, because the stages share one file, one build command and one cache. Use --target when you want to stop at an intermediate stage for debugging or testing.