02-building-images

Layers and the Build Cache: Why Your Rebuilds Are Slow

One rule governs Docker build speed and image size. Learn how layer caching invalidates, why instruction order matters, and what .dockerignore quietly prevents.

September 13, 2026
dockerdockerfilelayersbuild-cachedockerignoreimage-size

Two Dockerfiles, One Rule

These two Dockerfiles produce an identical image. One rebuilds in about a second after a code change; the other takes a minute or more.

dockerfile
# Slow on every code change
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
dockerfile
# Fast — dependencies survive code changes
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]

The difference is four lines rearranged, and understanding why it works is most of what you need to know about building images well.

Every Instruction Makes a Layer

From the first guide of this roadmap: an image is a stack of read-only layers. Those layers come from your Dockerfile — each instruction that changes the filesystem produces one.

Docker caches each layer. On a rebuild it walks the Dockerfile from the top, reusing cached layers for as long as it can. The rule that decides "as long as it can" is the one to memorise:

When a layer's inputs change, that layer is rebuilt — and so is every layer after it.

Cache invalidation cascades downward. It never recovers partway down.

Now the two Dockerfiles explain themselves. In the first, COPY . . sits above RUN npm ci. Edit any source file and that COPY's inputs have changed, so it rebuilds — and npm ci below it rebuilds too, reinstalling every dependency because one line of your code moved.

In the second, only package.json and the lockfile are copied before the install. Editing application code doesn't touch those, so the expensive RUN npm ci layer stays cached. Only the final COPY . . re-runs.

The general principle, applicable in any language: order instructions from least-frequently-changed to most-frequently-changed. Your base image changes rarely. Your dependency manifest changes occasionally. Your source code changes constantly. Arrange the Dockerfile in that order and the cache does the rest. Requirements files, go.mod, pom.xml and Cargo.toml all get the same treatment.

What counts as "inputs changed"

It depends on the instruction:

  • For COPY and ADD, Docker compares the contents of the files being copied. Touching a file's timestamp without changing its bytes doesn't bust the cache.
  • For RUN, Docker compares the command string only. It does not — and cannot — know whether the outside world changed.

That second point causes a genuinely nasty bug, and it's worth seeing clearly.

⚠️

RUN apt-get update followed by a separate RUN apt-get install -y curl is a trap. If the install line changes later but the update line doesn't, Docker reuses a cached package index that might be months old, and the install pulls stale or missing packages. Always combine them in a single RUN, so they invalidate together:

RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

Check yourself

A Python project's Dockerfile does COPY . . followed by RUN pip install -r requirements.txt. Developers complain that every rebuild reinstalls all dependencies, even for a one-character change to a comment. What's the fix?

The Build Context

When you run docker build ., that trailing . is the build context — the directory whose contents are packaged up and sent to the builder before the build starts.

If your project directory contains a 400MB node_modules, a .git history and a local dist/ folder, all of it gets sent, every build. You'll see it in the very first line of output: transferring context: 412.3MB.

This costs more than time. COPY . . copies whatever arrived, which means a careless build can ship into your image:

  • .git — your entire commit history, including any secret ever committed and later removed
  • .env — local credentials
  • node_modules — host-built native modules that may not even work in the container
  • Test fixtures, build artefacts, editor directories

The fix is a .dockerignore file beside your Dockerfile. It uses the same patterns as .gitignore and excludes files from the context entirely:

gitignore
.git
.gitignore
node_modules
npm-debug.log
.env
.env.*
dist
coverage
.vscode
.idea
Dockerfile
.dockerignore
README.md

Excluding paths here makes the context smaller, the build faster, and the cache more stable — an editor writing to .vscode/ can otherwise invalidate a COPY . . for no reason at all.

💡

.dockerignore is not the same as .gitignore, and one does not imply the other. Plenty of projects gitignore node_modules but have no .dockerignore at all — so Git ignores the directory while Docker faithfully ships it. If you create one file today, make it this one.

The Layer Rule That Bites Hardest

Here is the behaviour that surprises even experienced engineers:

Deleting a file in a later layer does not remove it from the image.

Layers are stacked, not merged. A deletion in layer 5 adds a marker that hides the file from the final view — but the bytes are still sitting in layer 3, still downloaded on every pull, still extractable by anyone with the image.

So this does not work:

dockerfile
COPY secrets.json /app/secrets.json
RUN ./configure.sh && rm /app/secrets.json   # the file is still in the image

The image is no smaller, and secrets.json is fully recoverable from the earlier layer. The same logic applies to build tools you install and then uninstall: you pay for both operations and shrink nothing.

There are two real solutions, and both get their own guide in this phase:

  • For size — multi-stage builds, where the final image is built fresh and only the finished artefact is copied across.
  • For secrets — BuildKit secret mounts, where the credential is available during a build step but never written to any layer.

Check yourself

A Dockerfile copies a private SSH key, uses it to clone a dependency, then runs rm on the key in the same RUN instruction — all in one layer. Is the key recoverable from the published image?

Reading What You Built

Two commands turn all of this from theory into something you can check.

bash
# Which instruction created which layer, and how big is each one?
docker history myapp:latest
 
# Total image size, compared against others
docker images

docker history lists layers newest-first with the instruction that produced each and the size it added. When an image is unexpectedly large, this points at the culprit in seconds — usually a COPY that brought in more than intended, or an install step that left its package cache behind.

Get in the habit of running it after a build, especially on a Dockerfile you didn't write yourself. It's the fastest way to see what a build actually produced rather than what it appeared to do.

What's Next

You now know how caching and layers behave with the ordinary builder. The next guide covers BuildKit — the default builder in modern Docker — which adds cache mounts that persist across builds, secret mounts that solve the leak problem above, and multi-platform builds that fix the ARM-laptop-to-x86-server mismatch.