🎉 DevOps Interview Prep Bundle is live — 1000+ Q&A across 20 topicsGet it →
All Articles

Docker Multi-Stage Build Cache Miss: Fix in 5 Minutes

Multi-stage Docker builds re-running every layer from scratch even when nothing relevant changed? Here is exactly how to diagnose layer ordering, build context, and BuildKit cache export issues causing multi-stage cache misses.

Shubham4 min read
Share:Tweet

Multi-stage builds add a specific layer-caching failure mode single-stage Dockerfiles don't have — a cache miss in an early stage cascades to every stage that depends on it, even stages that changed nothing themselves. Here is how to find where the cache actually breaks.

Step 1: See Which Layers Are Actually Cache Missing

bash
docker build --progress=plain -t myapp . 2>&1 | grep -E "CACHED|RUN|COPY"
 
# #8 [build 3/6] RUN npm ci
# #8 CACHED                          ← this hit cache
# #9 [build 4/6] COPY . .
# #9 0.421s                          ← this did NOT hit cache — everything after it won't either

--progress=plain shows every step's cache status explicitly — find the first non-CACHED line, that's where the chain actually breaks, not necessarily where you assumed.

Cause 1: COPY Instruction Ordering Invalidates Cache Too Early

dockerfile
# BAD — copying everything before installing dependencies means
# ANY file change (even a README edit) invalidates the npm install cache
FROM node:20 AS build
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build

Docker's layer cache is invalidated the moment any file in a COPY changes — copying the whole repo before the dependency install means every single code change re-runs npm ci from scratch, even though only the lockfile matters for that step.

Fix — copy only what each step actually needs, in dependency order:

dockerfile
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                    # Only invalidated when package-lock.json changes
COPY . .
RUN npm run build             # Only this re-runs on code changes

Cause 2: Multi-Stage COPY --from Breaking the Chain Between Stages

dockerfile
FROM node:20 AS build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
 
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html    # This step's cache depends
                                                        # on the ENTIRE build stage's output

If the build stage cache misses for any reason (even a genuinely unrelated dependency bump), the COPY --from=build in the final stage also misses, because it depends on the full output of the previous stage, not just the specific files it copies.

bash
# Confirm this is actually what's happening
docker build --progress=plain -t myapp . 2>&1 | grep -A 2 "COPY --from"

This is largely expected behavior, not really fixable by reordering — but you can minimize its blast radius by keeping each stage as narrowly scoped as possible so a cache miss in one doesn't force a rebuild of unrelated work.

Cause 3: Build Context Includes Files That Change on Every Build

bash
# Check what's actually in your build context
docker build --progress=plain -t myapp . 2>&1 | grep "transferring context"
# => transferring context: 245MB    ← way too large, likely including node_modules, .git, build artifacts

If .dockerignore is missing or incomplete, files like .git, local node_modules, or build output directories get included in the build context — and if any of THOSE files change between builds (like .git always does), it can invalidate COPY . . layers unnecessarily.

Fix — a proper .dockerignore:

# .dockerignore
.git
node_modules
dist
*.log
.env
coverage

Cause 4: CI Runner Not Persisting BuildKit Cache Between Runs

yaml
# BAD — every CI run starts with zero Docker layer cache, defeating the point
- name: Build
  run: docker build -t myapp .

On ephemeral CI runners (GitHub-hosted runners, fresh VMs), there's no persisted Docker layer cache from the previous run at all — every build is a full cold build regardless of your Dockerfile structure.

Fix — explicitly export/import BuildKit cache using GitHub Actions cache or a registry:

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: myapp:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max
bash
# Or cache via a registry, for non-GitHub-Actions CI
docker buildx build \
  --cache-from type=registry,ref=myregistry/myapp:buildcache \
  --cache-to type=registry,ref=myregistry/myapp:buildcache,mode=max \
  -t myapp .

mode=max is important — the default mode=min only caches the final image layers, not intermediate build stage layers, which defeats multi-stage caching specifically.

Cause 5: ARG Values Changing Invalidate Everything Downstream

dockerfile
# If BUILD_DATE changes every single build (common mistake), every layer
# after this ARG is declared invalidates on every build, by design
ARG BUILD_DATE
RUN echo "Built at $BUILD_DATE" > /build-info.txt
COPY package.json ./    # This now misses cache too, even though nothing here changed
RUN npm ci

Fix — move volatile ARGs as late as possible, after the cacheable steps:

dockerfile
COPY package.json package-lock.json ./
RUN npm ci               # Cacheable, unaffected by BUILD_DATE now
COPY . .
RUN npm run build
 
ARG BUILD_DATE            # Moved to the end — only affects steps after this point
RUN echo "Built at $BUILD_DATE" > /build-info.txt

Diagnostic Checklist

bash
docker build --progress=plain -t myapp . 2>&1 | grep -E "CACHED|RUN|COPY|transferring context"
cat .dockerignore                                # confirm it excludes volatile/large files
docker buildx build --cache-from ... --cache-to ...   # confirm CI is actually persisting cache

More Docker troubleshooting? Read our Docker build slow no cache fix and Docker Compose network not found fix.

🔧

Today I Fixed

Short real fixes from production — posted daily

Browse fixes
Newsletter

Stay ahead of the curve

Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.

Related Articles

Comments