Dockerfile FROM and Choosing a Base Image

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

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:

TagApprox. sizeWhat you get
node:22~1.1 GBFull Debian, compilers, git, everything
node:22-slim~240 MBDebian with the extras stripped out
node:22-alpine~135 MBAlpine Linux, musl libc
gcr.io/distroless/nodejs22~150 MBRuntime only (no shell, no package manager)
scratch + static binary~8 MBNothing 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.

Exercise

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

SituationBase image
Go or Rust, static binaryscratch, 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)
Javaeclipse-temurin:*-jre-alpine, JRE not JDK
Needs a debugger in-image-slim; distroless has no shell
Base for internal imagesdebian:bookworm-slim or alpine:3.20

Common mistakes

MistakeConsequence
FROM node:latestNon-reproducible; breaks with no code change
Alpine for PythonSource builds, slow CI, often a bigger image
Building without --pullInherits a stale cached base and its CVEs
FROM scratch with CGO_ENABLED=1no such file or directory at start
scratch with no CA certificatesEvery HTTPS call fails
COPY --from=0Silently wrong after a stage is inserted
JDK in the runtime imageHundreds of MB and a compiler for an attacker
--platform hardcodedOnly builds for one architecture

Quick reference

TaskCommand
Named stageFROM node:22-alpine AS build
Pin by digestFROM node:22-alpine@sha256:…
Find the digestdocker inspect <img> --format='{{index .RepoDigests 0}}'
Refresh the basedocker build --pull -t myapp .
Build one stagedocker build --target build -t myapp:debug .
Multi-arch builddocker buildx build --platform linux/amd64,linux/arm64 --push .
Compare variantsdocker images node

Key takeaways

  • FROM decides 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.
  • scratch needs 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 --pull or your base image quietly rots in the local cache.
  • Name your stages. COPY --from=0 breaks the moment someone inserts a stage.

Check your understanding

Exercise

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.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Docker Playground

Run the commands in a simulated terminal, no install needed.

When it breaks

Related Resources