The core idea
An image's size is the sum of its layers, and layers only ever add. So making an image smaller comes down to two questions: which base do you start from, and what does each of your steps leave behind?
Smaller images pull faster, start faster on new servers, cost less to store and contain fewer packages that can have vulnerabilities. But size is a means, not a goal. Don't trade away debuggability for the last few megabytes.
Where the bytes are
Every number in this lesson was measured on the notes API (on an arm64 Mac; amd64 sizes differ slightly):
| Image | Disk usage | Content size (compressed) |
|---|---|---|
node:22 (full Debian base) | 1.63 GB | 416 MB |
first Dockerfile on node:22-slim | 425 MB | 95.5 MB |
…with a .dockerignore | 393 MB | 90.6 MB |
| …as a multi-stage build | 349 MB | 80.4 MB |
multi-stage on node:22-alpine | 238 MB | 61.7 MB |
| multi-stage on distroless | 212 MB | 52.2 MB |
The biggest single decision is the base image. node:22 ships compilers, git and Python, which are useful for building
native modules and useless at runtime. Your own layers are usually small next to it.
The four changes that matter
1. Pick a slimmer base. -slim, -alpine and distroless variants exist for exactly this. The trade-offs between
them are the next lesson (02.07 Choosing a base image).
2. Multi-stage builds. Build tools stay in a stage that's thrown away (02.04 Multi-stage builds).
3. A .dockerignore. Stops local node_modules, .git and build output riding along (02.03 Build context and .dockerignore).
4. Clean up in the same step that made the mess. This is the one people get wrong. Watch:
# A: install curl
RUN apt-get update && apt-get install -y curl
# B: install, then clean up in a separate step
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# C: install only what's needed and clean up in the same step
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*$ docker imagesIMAGE DISK USAGE CONTENT SIZEapt:A 404MB 101MBapt:B 404MB 101MBapt:C 363MB 84.4MBnode:22-slim 349MB 82.6MB
Output trimmed. B saved nothing: the package lists are still in the earlier layer, and the rm only adds a layer that
hides them. C is 41 MB smaller because --no-install-recommends skips optional packages, and the cleanup happens
before the layer is saved.
Your Dockerfile downloads a 200 MB tarball, extracts a 5 MB binary, and deletes the tarball, each in its own RUN. Roughly how much do these steps add to the image?
Finding what's big
docker history shows each layer's size, so the culprit usually jumps out:
$ docker history apt:BCREATED BY SIZERUN /bin/sh -c rm -rf /var/lib/apt/lists/* #… 20.5kBRUN /bin/sh -c apt-get update && apt-get ins… 37.2MB...
For a file-by-file view of each layer, the open-source tool dive (dive notes-api) lets you browse layers and
see exactly which files each one added or changed.
Language-specific habits
- Node:
npm ci --omit=devin the final stage; don't shipnode_modulesfrom your machine. - Python:
pip install --no-cache-dir, and build wheels in an earlier stage. - Go, Rust: compile a static binary in a build stage and copy only that binary into a tiny final image.
- apt:
--no-install-recommends, andrm -rf /var/lib/apt/lists/*in the sameRUN.
Build the notes API on node:22-slim and on node:22-alpine (change both FROM lines). Compare them with docker images, then run docker history on each and find the largest layer you added yourself.
STUCK? ▸
No change after adding a cleanup line: it's probably in its own RUN. Merge it into the step that created the files.
Recap
Interview questions
Your team's image is 1.5 GB. How would you make it smaller?