Layers & Caching
Understand how Docker's layer system works and how to use build caching to make your builds dramatically faster.
What Are Docker Image Layers?
A Docker image isn't a single big file. It's a stack of thin, read-only snapshots called layers. There is one layer attached to for each instruction in your Dockerfile that makes a change to the filesystem.
Here's a simple Node.js Dockerfile with each layer labeled:
FROM node:18-alpine # Layer 1: base OS + Node runtime (~127MB)
WORKDIR /app # Layer 2: metadata only, takes up 0 bytes
COPY package*.json ./ # Layer 3: adds package.json (~2KB)
RUN npm install # Layer 4: adds node_modules (~40MB)
COPY . . # Layer 5: adds your source code (~100KB)
CMD ["node", "app.js"] # Layer 6: metadata only, takes up 0 bytes
Visually, the final image looks like this:
Docker Image Layers (top = newest)
CMD ["node", "app.js"]← metadata, no sizeCOPY . .← your app codeRUN npm install← node_modulesCOPY package*.json .← package.jsonWORKDIR /app← metadata, no sizeFROM node:18-alpine← base imageImage layers are read-only and shared between containers
Each layer only stores the difference from the layer below it, similar to how Git commits work. This means if two different images use node:18-alpine as their base, that base layer is stored only once on your disk and shared between them.
When you run a container, Docker adds one extra thin writable layer on top of the stack. The image layers underneath stay read-only and untouched, which is why multiple containers can run from the same image without interfering with each other.
How Docker's Build Cache Works
After Docker builds a layer, it caches it. The next time you build, Docker checks each instruction from top to bottom. If nothing has changed in that instruction or the files it touches, Docker skips re-executing it and uses the cached version instead. This is what makes rebuilds so much faster:
First build (nothing cached):
FROM node:18-alpine ✓ downloaded fresh
WORKDIR /app ✓ created
COPY package*.json ./ ✓ copied
RUN npm install ✓ installed, let's say it took 30 seconds
COPY . . ✓ copied
→ Total: ~35 seconds
Second build (only your app code changed):
FROM node:18-alpine ✓ CACHED (skipped)
WORKDIR /app ✓ CACHED (skipped)
COPY package*.json ./ ✓ CACHED (skipped)
RUN npm install ✓ CACHED (skipped), saves 30 seconds
COPY . . ✗ REBUILT (source code changed)
→ Total: ~2 seconds
There's one critical rule about how cache invalidation works: when a layer is invalidated, every layer that comes after it is also rebuilt, even if those later instructions didn't change. This is the key insight that shapes how you should order your Dockerfile.
Order of Dockerfile Matters
Put instructions that change rarely near the top. Put instructions that change often near the bottom.
The most common mistake beginners make is this:
The wrong way, rebuilds dependencies every single time
FROM node:18-alpine
WORKDIR /app
COPY . . # Copies everything, including your source code
RUN npm install # Invalidated every time ANY file changes
CMD ["node", "app.js"]
The problem here: COPY . . copies your source code along with package.json. Every time you change even one line of code, that COPY layer is invalidated, and since RUN npm install comes after it, npm has to reinstall everything from scratch in every single build.
The right way, reuses the dependency cache unless packages change
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./ # Copy ONLY the dependency manifest first
RUN npm install # This only reruns when package.json changes
COPY . . # Copy your source code after dependencies are installed
CMD ["node", "app.js"]
Now npm install is cached as long as package.json and package-lock.json stay the same. Changing your app code only rebuilds the last COPY layer. A 35-second build becomes a 2-second build.
Note: This isn't a Node.js-specific trick - it applies everywhere. The pattern is always the same: copy the dependency file first, install, then copy your source code.
Hands-On Practice: Watch the Cache in Action
The best way to understand caching is to see it yourself:
# Step 1: Build the image for the first time (everything runs fresh)
docker build -t cachetest:v1 .
# Step 2: Build again without changing anything
docker build -t cachetest:v1 .
# Step 3: Make a small change to your app code, then build again
docker build -t cachetest:v1 .
On the second build, you'll see Using cache next to every step. On the third build after a code change, you'll see Using cache for the dependency steps but REBUILT for the source code step. That's the cache doing exactly what it should.
When You Need to Skip the Cache
Sometimes you want Docker to ignore the cache, for example, to pick up the latest security patches from apt even though the instruction string hasn't changed:
# Force a completely fresh build (no cached layers)
docker build --no-cache -t myapp:latest .
If you only want to bust the cache from a specific point forward (without rebuilding everything), you can use a build argument as a cache-busting trick:
ARG CACHE_BUST=1
RUN apt-get update && apt-get upgrade -y
# Pass a new value to force this layer and everything after it to rebuild
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp:latest .
Cleaning Up the Build Cache
The build cache can quietly grow to gigabytes over time. Here's how to manage it:
# See how much space the cache is using
docker system df
# Remove build cache that's no longer needed
docker builder prune
# Remove all build cache, including layers still referenced by images
docker builder prune -a
docker system df is a good habit to run occasionally. It gives you a quick overview of how much space images, containers, volumes, and build cache are each taking up.
Key Takeaways
- Docker images are stacks of read-only layers, and each Dockerfile instruction that changes the filesystem creates a new one.
- Layers are cached after the first build. If nothing changed, Docker reuses the cached layer and skips re-executing it.
- When a layer is invalidated, all layers below it are rebuilt too, this is the most important caching rule to internalize.
- Always copy your dependency manifest (
package.json,requirements.txt, etc.) and run your install command before copying your source code. This one change can turn a 30-second build into a 2-second build. - Use
docker build --no-cachewhen you need a guaranteed fresh build, such as picking up security patches. - Run
docker builder pruneperiodically to reclaim disk space from accumulated build cache.
Frequently Asked Questions
Why does Docker rebuild everything after I change one file?
Because you're probably copying all your files (including source code) before installing dependencies. When COPY . . comes before RUN npm install, any file change invalidates the install layer. Fix it by copying your dependency manifest first, installing, then copying the rest of your code.
How does Docker know if a layer has changed?
For COPY and ADD instructions, Docker compares the actual file contents, not just timestamps. For RUN instructions, Docker compares the instruction string itself. If the string is identical, Docker assumes the result is the same and uses the cache.
Does the cache work across different machines?
Not by default. The local build cache lives only on your machine. In CI/CD pipelines you can use cache backends (like storing layers in a registry) to share cache across machines, but that's an advanced topic.
Why do some layers show 0B in docker history?
Those are metadata-only instructions like WORKDIR, ENV, EXPOSE, and CMD. They don't add or modify any files in the image, so they take up no space, but they still create a layer.
When should I use --no-cache?
Use it when you need to pull in external updates that Docker can't detect, most commonly when running apt-get update or pip install to get the latest security patches. Since the instruction string hasn't changed, Docker would normally use the cache and miss the new patches.