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 api
Output
REPOSITORY   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 api
Output
REPOSITORY   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 exec a 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.txt

Now 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:py
Output
SIZE      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=build only the artifacts into a slim or distroless runtime stage.
  • Prefer -slim for Python and Node; use alpine or distroless mainly for static binaries.
  • Files deleted in a later layer still count; clean up in the same RUN or 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.

Up next · Lesson 7Environment Variables, Config and SecretsConfigure Docker containers with environment variables, .env files and build args,, and keep secrets safe with BuildKit secret mounts and Compose secrets.