2.2 · Optimizing the build: layers, cache and multi-stage

Level 2 · Dockerfile & build

2.2Optimizing the build: layers, cache and multi-stage

Goal: Cut build time and image size by 10x with cache and multi-stage.
Estimated time: 14 min · self-paced reading · If you only have 5 min: read “The essentials in 30s” + the diagram.

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.

Common misinterpretation: "COPY . . on the first line is simpler and changes nothing". Wrong: the slightest change invalidates the cache from the first layer and replays everything, installation included. Idir's rule: stable dependencies first (cached), changing code after — and ship only the runnable in multi-stage.
A fast, lean build Dependencies first (cache), code after, lean delivery Dependencies (cache) Stablefirst, cached installCode (changing) Modifiedoften, replayed aloneLean runtime Multi-stage,slim, measured
Dependencies first (cache), code after, lean delivery
Text version of the diagram (SEO / screen readers / translation): 1) Idir ORDERS: manifests and installation first (cached layers), code after — a code change no longer replays npm install. 2) He SEPARATES: tooled build stage to compile, slim stage receiving only the result via COPY --from=build. 3) He MEASURES: docker images and docker history before/after, context slimmed by .dockerignore. Lesson: order makes speed, multi-stage makes size, measurement proves both.

Did you know?

Idir once saw a build reinstall 400 MB of dependencies for a comma moved in a comment — COPY . . on the first line.
True: one misplaced line can cost minutes on every build.

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

  1. Build your "naive" app (all in one block) and note size + duration.
  2. Reorder: dependencies first, code after; rebuild after a code change.
  3. Convert to multi-stage (tooled build, slim runtime) and measure again.
  4. Compare history before/after and keep the winning recipe.
Your builds are fast and your images lean, with figures to back it up.

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.

Section exit checklist
  • 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.
Self-assessment 1-5: does my COPY . . come after the install?does my final image hold anything besides the runnable?

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.

Check your understanding

For a fast build, where do you place COPY . . (the code)?

Additional resources (optional, to go further)

Level 2 examFresh, harder questions to test what you've learned