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:
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:
$ docker build -t my-api .$ docker history my-apiIMAGE CREATED CREATED BY SIZEa9ded8c2f9c7 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:
| Instruction | Reused when… |
|---|---|
COPY / ADD | the step before was reused and the copied files have the same contents. Docker checksums them; timestamps don't matter. |
RUN | the step before was reused and the command text is identical. |
| Everything else | the 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.
With the Dockerfile above, you change one line in src/index.js and rebuild. Which steps run again?
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:
- FROM node:22-slimRUNpull
- WORKDIR /appRUN0.1s
- COPY . .RUN0.4s
- RUN npm ciRUN41s
- CMD ["node", "src/index.js"]RUN0s
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:
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
Use the reordered Dockerfile. Build once, change a source file, then build again with plain progress output so you can read each step.
$ 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
Every code change re-runs npm install during the Docker build. Why, and how do you fix it?
A teammate removes a 300 MB file with RUN rm in the last step, but the image is the same size. Why?