Docker · Lesson 6 of 11
Multi-stage Builds and Small Images
Shrink Docker images with multi-stage builds, slim and distroless base images, BuildKit cache mounts and smart layer ordering for faster, safer deploys.
- Intermediate
- 17 min read
- 4 objectives
Before this lessonLesson 5: Docker Compose
What you will learn
- Explain why image size matters
- Write multi-stage builds for Go, Node and Python
- Pick between slim, alpine and distroless bases
- Speed up builds with BuildKit cache mounts
Your Progress
0 of 11 lessons 0%
- Lessons0 / 11
- Completed0
- Est. time left~ 3 hours
Create a free account to keep your progress on every device.
Tip: pressing Next marks this lesson complete automatically.
The Dockerfile lesson introduced multi-stage builds with a short example. This lesson goes deeper, because image size is one of the easiest wins in container work. A 1.2 GB image takes longer to push from CI, longer to pull on every new server, and carries hundreds of packages (compilers, shells, package managers) that attackers love and your app never uses.
The core idea: tools you need to build the app are not tools you need to run it. Build in one stage, copy only the result into a clean final stage.
Measuring the problem
Here is a naive Go Dockerfile. It works, but ships the entire Go toolchain:
FROM golang:1.23
WORKDIR /src
COPY . .
RUN go build -o /app ./cmd/api
CMD ["/app"]docker build -t api:naive .
docker images apiREPOSITORY TAG IMAGE ID CREATED SIZE api naive 8b21f0c3d9e4 12 seconds ago 912MB
A multi-stage Go build
Go compiles to a single static binary, so the final stage can be almost empty:
# ---- build stage ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/api ./cmd/api
# ---- runtime stage ----
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
USER nonroot
ENTRYPOINT ["/api"]docker build -t api:slim .
docker images apiREPOSITORY TAG IMAGE ID CREATED SIZE api slim 4c7e9a1b2f30 5 seconds ago 14.8MB api naive 8b21f0c3d9e4 2 minutes ago 912MB
From 912 MB to under 15 MB. COPY --from=build is the key line: it reaches into the earlier stage and copies just one file. Everything else in the build stage is discarded. CGO_ENABLED=0 produces a fully static binary so it runs without a C library.
Choosing a base image
- Full images (
python:3.12,node:22): Debian plus build tools. Good for build stages, too large for runtime. - Slim (
python:3.12-slim,node:22-slim): Debian with the extras removed. The safe default for runtime stages; uses glibc so prebuilt wheels and native modules just work. - Alpine (
node:22-alpine): tiny, but uses musl libc. Some Python wheels and native Node modules must compile from source or behave differently. Great for Go and static binaries, use with care for Python. - Distroless / Chainguard: only the runtime and its libraries, no shell or package manager. Smallest attack surface, but you cannot
docker execa shell into them (the debugging lesson shows how to work around that).
Multi-stage for Python
Python is interpreted, so you still need Python at runtime. The win comes from building wheels (which may need gcc) in one stage and installing only the results into a slim stage. A virtual environment makes the copy clean:
FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM python:3.12-slim
COPY --from=build /venv /venv
ENV PATH="/venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /app
COPY . .
RUN useradd --create-home app
USER app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]build-essential (around 250 MB) never reaches the final image. The same shape works for Node: run npm ci and npm run build in the first stage, then in the second stage run npm ci --omit=dev and copy only dist/.
Faster builds with cache mounts
Layer caching helps only while requirements.txt is unchanged. Add one dependency and pip downloads everything again. BuildKit (the default builder since Docker 23) supports cache mounts: a directory that persists between builds but is never stored in the image.
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txtNow changing one package only downloads that package. The same trick works with target=/root/.npm for npm, /go/pkg/mod for Go, and /root/.m2 for Maven.
Inspecting what you built
To see which layers are taking up space, check the history of your final image:
docker history --format "table {{.Size}}\t{{.CreatedBy}}" api:pySIZE CREATED BY 0B CMD ["uvicorn" "main:app" "--host" "0.0.0.0" "--port"... 0B USER app 412kB RUN /bin/sh -c useradd --create-home app 86kB COPY . . # buildkit 0B WORKDIR /app 0B ENV PATH=/venv/bin:/usr/local/bin:... PYTHONUNBUFFERED=1 61.3MB COPY /venv /venv # buildkit ...
If COPY . . is unexpectedly large, your .dockerignore is missing something like .git, node_modules or a data folder. The open-source tool dive gives an interactive view of each layer's files if you want to dig further.
Building a specific stage
Stages are also handy for development and testing. --target stops the build at a named stage:
docker build --target build -t api:build-env .
docker run --rm api:build-env go test ./...Recap
- Smaller images push, pull and start faster and contain fewer vulnerable packages.
- Build in a full image, then
COPY --from=buildonly the artifacts into a slim or distroless runtime stage. - Prefer
-slimfor Python and Node; use alpine or distroless mainly for static binaries. - Files deleted in a later layer still count; clean up in the same
RUNor in a discarded stage. - BuildKit cache mounts keep package downloads between builds without bloating the image.
# Write your solution here
Finished reading? Mark this lesson complete to track your progress.
