2.2Optimizing the build: layers, cache and multi-stage
The essentials in 30 seconds
- Order drives the cache: Docker replays layers up to the first modified one — stable first, changing after.
- Multi-stage = build fat, ship lean: compile in a tooled stage, ship only the runnable — COPY --from=build.
- Measure before optimizing: docker images and docker history prove the gain — aim under 200 MB for a web app.
Running mini-case — Idir and the build reinstalling everything
Idir's packaged app (2.1) works, but every modified comma reruns npm install and the build takes minutes; the image exceeds one gigabyte. Idir reorders: stable dependencies first (package*.json + install), changing code after — a code change no longer replays the installation. Then he switches to multi-stage: a tooled stage compiles, a slim stage receives only the result via COPY --from=build. Measured verdict: from 1 GB down to about 150 MB. In 2.3, Idir will tag this lean image; in 2.5, he will harden it.
Following the 2.1 thread: Idir's app becomes fast to build and lean to ship — and continues in 2.3, 2.4 and 2.5.
1. The cache: order changes everything
Docker replays layers in order and reuses the cache up to the first modified layer: everything after it is replayed. So put the stable first (dependencies: COPY package*.json then install) and the changing after (the code: COPY . .). Reversing the order re-downloads everything on every modified comma. Idir copies the manifests and installs BEFORE the code: a code change only replays the last layers.
2. Multi-stage: build fat, ship lean
We compile in a tooled image ("build" stage with the full SDK) then copy only the result into a lightweight runtime image (COPY --from=build). The SDK, sources and caches stay on the dock: the final image holds only what is needed to run. Each FROM opens a stage; we ship only the last one. Idir builds in a full image and ships in node:20-slim.
3. Measure before optimizing
docker images gives the size, docker history the weight per layer: optimize what you measure. Realistic target: a web app under 200 MB (slim base + multi-stage). A complete .dockerignore (node_modules, .git, dist) slims the context sent to the daemon. Below the threshold, hunt useless dependencies, not bytes. Idir notes size and duration before/after: figures to back it up, not gut feeling.
Did you know?
Line order is lifetime.
🔎 Concrete example — 🔎 Concrete example — Node: from 1 GB to 150 MB
Idir's Dockerfile, optimized and measured version:
# stage 1: build
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# stage 2: lean runtime
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/server.js"]
Reading: the SDK and sources stay in the build stage, dropped afterwards; the final stage holds only the runnable.
Lesson: docker history proves which layers melted — measure before applauding.
Section vocabulary
- Build cache
- Reusing unchanged layers between two builds: everything is replayed from the first modified layer on.
Simple example: Idir no longer replays npm install when only his code changes.
What it is not: a smaller final image.
Reflex: stable first to cache.
- Multi-stage
- Several FROM instructions: build in a tooled stage and ship only the last, lean stage.
Simple example: Idir compiles in node:20 then ships in node:20-slim.
What it is not: a simple reordering of layers.
Reflex: build fat, ship lean.
- COPY --from
- Copying from a previous stage (not from the context): bring back only the compiled result.
Simple example: Idir copies /app/dist from the build stage to the slim stage.
What it is not: a classic COPY from the context.
Reflex: ship only the runnable.
- history
- The layer-by-layer detail of an image (docker history): weight and origin of each layer.
Simple example: Idir compares history before/after to prove the gain.
What it is not: the total size alone (docker images).
Reflex: measure per layer before optimizing.
In practice — In practice — Melting an image down
- Build your "naive" app (all in one block) and note size + duration.
- Reorder: dependencies first, code after; rebuild after a code change.
- Convert to multi-stage (tooled build, slim runtime) and measure again.
- Compare history before/after and keep the winning recipe.
Script ready to say — justifying the optimization (30 s)
"Before, every change reran the whole installation and the image exceeded one gigabyte. Now dependencies are cached, only the code is replayed, and multi-stage ships only the runnable: about 150 MB. Proof: docker images and history before/after. Faster to build, lighter to deploy."
Say while showing Idir's two measurements, figures to back it up.
Copy-paste template — multi-stage Node Dockerfile
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/server.js"]
Dead cache
COPY . . on the first line: every build reinstalls everything — Idir moves dependencies before the code.
SDK shipped
Prod image with the full SDK: Idir switches to multi-stage and ships only dist.
Heavy context
800 MB context: Idir writes a .dockerignore (node_modules, .git, dist) and the build breathes again.
Blind optimization
"It feels lighter": Idir pulls out docker history and proves the gain layer by layer.
✓ Best practices
- Dependencies first, code after, always.
- A complete .dockerignore (node_modules, .git, dist).
- Ship slim, build full.
✗ Common pitfalls
- COPY . . on the first line (dead cache).
- Shipping the SDK to production.
- Optimizing without measuring (history/images).
Key takeaways
- Layer order drives the cache (stable first, changing after).
- Multi-stage: ship only the runnable (COPY --from=build).
- We measure: docker images (size) + docker history (per layer).
- Complete .dockerignore: the context must not carry the useless.
48h challenge — apply tomorrow
On your packaged app (2.1): note size + duration, reorder dependencies then code, convert to multi-stage, then compare history before/after.
- I can order a Dockerfile for the cache (stable then changing).
- I can write a multi-stage build shipping only the runnable.
- I measure with images + history before claiming a gain.
Frequently asked questions
Why does my build replay npm install every time?
Because COPY . . comes before RUN npm ci: the slightest modified file invalidates the cache from that layer on. Copy package*.json and install BEFORE the code: only a dependency change will replay the installation.
Does multi-stage slow the build down?
A little (two stages to build), but it divides the shipped size and the attack surface. In CI with cache, the overhead is marginal next to the deployment and security gain.
For a fast build, where do you place COPY . . (the code)?