sehaj/learn
Docker/02 Images

02.02 · with code · 14 min

Layers and the build cache

Why a one-line change can rebuild your whole image, and how to order a Dockerfile so it doesn't.

The core idea

An image isn't one big file. It's a stack of read-only layers, each holding the files one step of your Dockerfile added or changed. When you rebuild, Docker reuses every step whose inputs haven't changed and only runs the rest.

So fast builds come down to one habit: put the things that change least at the top of the Dockerfile. The rest of this lesson is why that works, and how to see it happen.

Every step leaves a record

Here's a typical first Dockerfile for a Node API:

Dockerfile
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "src/index.js"]

FROM brings in the base image's layers. COPY and RUN change files, so each adds a new layer on top. CMD only changes the image's settings (which command to start), so it adds no files at all.

You can list an image's layers with docker history:

❯terminal
$ docker build -t my-api .
$ docker history my-api
IMAGE CREATED CREATED BY SIZE
a9ded8c2f9c7 Less than a second ago CMD ["node" "src/index.js"] 0B
<missing> Less than a second ago RUN /bin/sh -c npm ci # buildkit 9.68MB
<missing> 1 second ago COPY . . # buildkit 61.4kB
<missing> 2 minutes ago WORKDIR /app 8.19kB
<missing> 5 days ago CMD ["node"] 0B
<missing> 5 days ago ENTRYPOINT ["docker-entrypoint.sh"] 0B
...

Read it bottom to top: that's the order the layers stack. Lines from "5 days ago" come from node:22-slim. Of the layers you added, npm ci is by far the biggest (here, one dependency: Express), and it's the slow one to rebuild.

Layers are also shared. If ten images on your machine start FROM node:22-slim, those base layers are stored once, and docker pull only downloads layers you don't already have.

How Docker decides a step is cached

Before running a step, Docker asks: have I run exactly this before, on top of exactly the same layers? What "exactly this" means depends on the instruction:

InstructionReused when…
COPY / ADDthe step before was reused and the copied files have the same contents. Docker checksums them; timestamps don't matter.
RUNthe step before was reused and the command text is identical.
Everything elsethe step before was reused and the line is identical.

That RUN rule surprises people. Docker never looks at what a command would do; it only compares the text. RUN apt-get update stays cached for months, even as new package versions ship.

Predict first

With the Dockerfile above, you change one line in src/index.js and rebuild. Which steps run again?

Commit to an answer before you read on.

One miss rebuilds everything after it

Each step's cache is tied to the exact layers beneath it. Once one step misses, every step after it is building on a layer Docker has never seen, so none of them can be reused. Step through it:

How the cache breaks · step 1 / 5
Dockerfile · source copied firstbuild ~42s
  1. FROM node:22-slimRUNpull
  2. WORKDIR /appRUN0.1s
  3. COPY . .RUN0.4s
  4. RUN npm ciRUN41s
  5. CMD ["node", "src/index.js"]RUN0s
First build. Every step runs. Installing dependencies is the slow one.

Timings are illustrative.

Order your Dockerfile for the cache

npm ci only needs package.json and package-lock.json. Copy those first, install, then copy the rest of your source:

Dockerfile
FROM node:22-slim
WORKDIR /app

# Dependencies change rarely: install them first.
COPY package.json package-lock.json ./
RUN npm ci

# Source changes all the time: copy it last.
COPY . .
CMD ["node", "src/index.js"]

Now editing your code only invalidates the final COPY. Dependencies only reinstall when the lockfile changes, which is exactly when they should.

The same rule works for any language: copy the dependency manifest (requirements.txt, go.mod and go.sum, Cargo.toml), install, then copy the source. Order steps from least often changed to most often changed.

See it yourself

CheckpointWatch the cache work

Use the reordered Dockerfile. Build once, change a source file, then build again with plain progress output so you can read each step.

❯terminal
$ docker build -t my-api .
$ echo "// touch" >> src/index.js
$ docker build --progress=plain -t my-api .
#6 [2/5] WORKDIR /app
#6 CACHED
#7 [3/5] COPY package.json package-lock.json ./
#7 CACHED
#8 [4/5] RUN npm ci
#8 CACHED
#9 [5/5] COPY . .
#9 DONE 0.0s

Output is trimmed. You're looking for CACHED on RUN npm ci and real work only on the last COPY.

STUCK? ▸

If RUN npm ci isn't CACHED, check that COPY . . comes after it, and that your edit didn't touch package.json or the lockfile.

Common mistakes

Recap

Interview questions

MidfreqMust-knowDebugging

Every code change re-runs npm install during the Docker build. Why, and how do you fix it?

MidfreqGood to knowDebugging

A teammate removes a 300 MB file with RUN rm in the last step, but the image is the same size. Why?

Report a mistakeEdit this page

Used in

☰ Images02.02 · 8/44next →