You change one line of code, rebuild your Docker image, and it takes 5 minutes. The same image that built in 30 seconds last week. You’re staring at the build output wondering why RUN npm install is re-running when nothing in package.json changed.
Welcome to Docker layer caching. It’s powerful, it’s fast, and it’s easy to break.
How Layer Caching Works
Docker builds images in layers. Each instruction (FROM, RUN, COPY, etc.) creates a new layer. Docker caches each layer. When you rebuild, Docker checks if the layer’s inputs changed. If not, it reuses the cached layer instead of re-executing.
Here’s the key: Docker determines if a layer changed by comparing the instruction and the files it operates on.
Example:
FROM node:18-slimWORKDIR /appCOPY package.json .RUN npm installCOPY . .RUN npm run buildCMD ["npm", "start"]The second time you build:
FROM node:18-slim— cached (same base image)WORKDIR /app— cached (just a metadata change)COPY package.json .— cache hit if package.json hasn’t changedRUN npm install— cache hit if the previous layer was a cache hitCOPY . .— cache miss if any file in the directory changedRUN npm run build— re-runs because the previous layer missedCMD ["npm", "start"]— cached (metadata only)
This is efficient. You changed one .js file, so only the build step re-runs. npm install doesn’t re-run. Good.
The Common Cache-Killer: COPY Too Early
Most developers do this (and destroy their cache):
FROM node:18-slimWORKDIR /appCOPY . . # ← Changes every time you edit a fileRUN npm installRUN npm run buildCMD ["npm", "start"]Now, when you edit any file and rebuild:
COPY . .— cache miss (contents changed)RUN npm install— re-runs from scratch (takes 60+ seconds)RUN npm run build— re-runs- Everything downstream is slow
You’re rebuilding node_modules even though package.json hasn’t changed. This kills your iteration speed during development.
The Fix: Layer Stability Order
Arrange your Dockerfile so stable, slow things come first:
FROM node:18-slimWORKDIR /app
# Stable: package.json rarely changesCOPY package.json package-lock.json ./RUN npm install # Cached unless package.json changes
# Variable: source code changes frequentlyCOPY . .RUN npm run build # Re-runs when you edit source
CMD ["npm", "start"]Now, when you edit a .js file and rebuild:
COPY package*.json .— cache hitRUN npm install— uses cache (2ms, not 60s)COPY . .— cache miss (expected)RUN npm run build— re-runs (just the build, not npm install)
Total rebuild time: 10 seconds instead of 70. That’s the difference between a snappy feedback loop and a coffee break.
Multi-Stage Example
Here’s a production-grade Dockerfile for a Node app with good cache behavior:
# Stage 1: Dependencies (slow, stable)FROM node:18-slim AS baseWORKDIR /appCOPY package*.json ./RUN npm install --omit=dev
# Stage 2: Builder (moderate, semi-stable)FROM node:18-slim AS builderWORKDIR /appCOPY package*.json ./RUN npm installCOPY . .RUN npm run build
# Stage 3: Runtime (fast, minimal)FROM node:18-slimWORKDIR /appCOPY --from=base /app/node_modules ./node_modulesCOPY --from=builder /app/dist ./distCOPY package*.json ./EXPOSE 3000CMD ["node", "dist/index.js"]Each stage gets its own cache. The runtime stage is tiny—just dependencies and built output. If you change docs or tests, only the builder stage re-runs.
Pro Tips for Cache Optimization
1. Wildcard COPY conservatively:
# Bad: Invalidates on any file changeCOPY . .
# Good: Explicit about what you needCOPY package.json package-lock.json ./COPY src ./srcCOPY tsconfig.json ./2. Combine RUN commands to reduce layers (when it makes sense):
# More layers, but easier to cacheRUN apt-get updateRUN apt-get install -y curl
# Fewer layers, less granular cachingRUN apt-get update && apt-get install -y curlCombine only when you need to (e.g., dependencies that must be installed together).
3. Use .dockerignore to avoid spurious invalidations:
If your .dockerignore is missing, every file in your repo affects layer caching, even if you don’t copy it.
4. Check cache hits in the build output:
$ docker build -t myapp:v1 .Step 3/7 : COPY package*.json ./ ---> Using cache # ← Cache hit!Step 4/7 : RUN npm install ---> Using cache # ← Cache hit!Step 5/7 : COPY . . ---> 7a8f3c2d1e0b # ← Cache missThe Benchmark
Same Node.js app, two approaches:
Bad (COPY . . early):
- First build: 90s
- Rebuild after code change: 65s (npm install re-runs)
- Rebuild after changing package.json: 65s
Good (package.json first):
- First build: 90s
- Rebuild after code change: 12s (npm install cached)
- Rebuild after changing package.json: 65s
The good approach is 5x faster for the most common case: iterating on code.
Checklist
- Put stable files (package.json, requirements.txt, go.mod) early
- Put changing files (source code) later
- Use explicit COPY instead of COPY . .
- Check for “Using cache” in build output
- Test: edit a source file, rebuild, should be fast
- Test: edit a dependency file, rebuild, should re-install (expected)
Layer caching is free optimization. Use it.