Why Your Docker Layer Cache Misses Every Run

A Docker layer cache misses every run when an early layer changes: a whole-context COPY, a build arg consumed too early, lockfile churn, or a reset builder.

Last verified:

A Docker layer cache misses on every GitHub Actions run because one instruction near the top of the image changes each time, and BuildKit invalidates every layer after the first instruction whose inputs moved. In practice the trigger is a COPY . . placed above the dependency install, a build arg consumed early, lockfile churn, a multi-arch build that keeps one cache per architecture, or a builder whose cache was reset.

This guide is the diagnosis half of the problem. Keeping a layer cache alive between runs is covered in Docker layer caching on GitHub Actions, and the trade-offs between exported cache backends are covered in comparing buildx cache backends on GitHub Actions. What follows is how to find the layer that broke, the Dockerfile change that fixes the common case, the platform-side causes that no Dockerfile edit will touch, and a time model that prices a single cache miss.

Diagnosis

BuildKit computes a cache key per instruction from the parent layer digest, the instruction text, and the digests of the inputs that instruction reads. A COPY reads the contents and mode of the files it copies. A RUN reads the command string plus whatever its parent layer contains. Change any of those and the layer misses, and every layer below it misses with it, whatever the cache backend is.

Find the first uncached instruction

Run the build with plain progress output so every step prints:

docker buildx build --progress=plain -t acme/api:diag .

Read the log from the top and stop at the first step without a CACHED prefix. Everything after that point is a miss by definition, so the steps below it carry no information. The single instruction you stopped at is the diagnosis, and the four causes below are what usually sits there.

A whole-context COPY above the dependency install

COPY . . before the package install is the most common cause. Its cache key covers every file in the build context, so any edit anywhere in the repository invalidates it and the dependency install below it.

Two details make this worse than teams expect. A missing or thin .dockerignore pulls files into the context that the image never uses, so a README edit, a workflow file change, or a stray .git directory rewrites the digest. And generated files that land in the context before the build carry churn of their own: a version file written by a release step, a .env rendered from templates, or code generation output that embeds a timestamp or a build ID rewrites bytes on every run even when the logical content is identical.

Keep generated files out of the context, or generate them after the dependency layers. Where a generator insists on stamping a timestamp, normalize it with SOURCE_DATE_EPOCH or strip the stamped line before the copy.

Lockfile churn

Once ordering is right, the dependency layer is only as stable as the lockfile. Three patterns break it: a dependency bot opening lockfile-only pull requests most days, a build step that regenerates the lockfile instead of honoring it, and a range constraint that resolves to a new patch release inside the build.

Measure the churn rather than guessing at it. On the default branch, count how many of the last 100 commits touched a dependency input:

git log --oneline -100 -- package.json package-lock.json | wc -l

That number over 100 is roughly the miss rate you should expect for the dependency layer, and it is the input the cost model at the end of this guide takes. If it comes back near 40, the fix belongs in dependency policy rather than in the Dockerfile.

Build args baked into early layers

A build arg invalidates every instruction that references it. The trap is a version stamp declared and consumed at the top of the file:

ARG GIT_SHA
ENV APP_VERSION=${GIT_SHA}

GIT_SHA changes on every commit, so the ENV layer misses on every commit, and the dependency install placed below it misses too. The same applies to BUILD_DATE, to a CACHEBUST arg someone added during an incident and never removed, and to any arg carrying a per-run value. The value has to reach the image, so the fix is placement rather than removal: consume it after the layers you want to keep.

Multi-arch builds split the cache per architecture

For multi-arch builds, each architecture runs on a separate builder instance, which means one cache per architecture and one billed session per architecture. A workflow that builds linux/amd64 on pull requests and linux/amd64,linux/arm64 on release tags keeps the amd64 half warm and leaves the arm64 half cold on most releases.

Two profiles never share a cache either, so a per-repository profile naming convention that drifted, or a typo in profile-name, sends a build to a builder that has never seen the image. Multi-stage images add one more path: an invalidated shared base stage cascades into every stage built from it, which is covered in multi-stage Docker builds on GitHub Actions.

The platform-side causes

Three causes sit outside the Dockerfile, and no ordering change touches them.

The first is the TTL. A WarpBuild builder profile cache is reset automatically after 10 days without use. A profile driven by a nightly workflow stays warm. A profile driven by a release workflow that runs every two or three weeks is cold on most releases, and the symptom is a cold build with no code change to explain it.

The second is a manual reset. Cache reset is an API call, so it is easy for a teammate to run one during an incident and easy for nobody to remember afterwards. The recovery is one warm build.

The third is eventual consistency. The cache on a profile is shared between concurrent builds but eventually consistent, so a layer produced by one in-flight build may not be visible to another build already in flight; a later build picks it up after synchronization. Two pull requests opened a minute apart will both build the same base layers once. This is a property of concurrency rather than a fault, and it is worth knowing before you spend an afternoon on it. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps.

One more symptom belongs here because it looks like a cache problem and is really a sizing problem. A Failed to commit cache (Docker) error usually means the layers are large and the runner is small; layers above about 5GB on a 2x runner will produce it. Move the job to a larger runner size.

Fix

Reorder the Dockerfile so the layers you want to keep sit above the inputs that change most often. Here is a Python service before and after.

Before, with the whole context copied first and a version stamp at the top:

FROM python:3.12-slim
ARG GIT_SHA
ENV APP_VERSION=${GIT_SHA}
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y --no-install-recommends build-essential
RUN pip install -r requirements.txt
RUN python manage.py collectstatic --noinput
CMD ["gunicorn", "app.main:app"]

After, with dependencies above the source copy and the stamp at the bottom:

FROM python:3.12-slim
WORKDIR /app
RUN apt-get update \
    && apt-get install -y --no-install-recommends build-essential \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
COPY . .
RUN python manage.py collectstatic --noinput
ARG GIT_SHA
ENV APP_VERSION=${GIT_SHA}
CMD ["gunicorn", "app.main:app"]

The edits read as three moves:

  FROM python:3.12-slim
- ARG GIT_SHA
- ENV APP_VERSION=${GIT_SHA}
  WORKDIR /app
- COPY . .
- RUN apt-get update && apt-get install -y --no-install-recommends build-essential
- RUN pip install -r requirements.txt
+ RUN apt-get update \
+     && apt-get install -y --no-install-recommends build-essential \
+     && rm -rf /var/lib/apt/lists/*
+ COPY requirements.txt ./
+ RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
+ COPY . .
  RUN python manage.py collectstatic --noinput
+ ARG GIT_SHA
+ ENV APP_VERSION=${GIT_SHA}
  CMD ["gunicorn", "app.main:app"]

What each commit type now rebuilds:

Commit typeFirst invalidated instructionLayers rebuilt
Source file onlyCOPY . .source copy, collectstatic, export
requirements.txt editCOPY requirements.txt ./pip install and everything below
Base image bumpFROM python:3.12-slimall
New GIT_SHA with no code changeARG GIT_SHAfinal config layer only

The --mount=type=cache line adds a second layer of protection. Even when requirements.txt changes, pip reuses its own download cache from the builder disk instead of refetching every wheel, and wheels built from source stay built. That cache mount lives on the builder, so it survives with the layer cache and disappears with a per-job buildx instance.

Ordering only pays off if the cache survives the job, which is what a builder profile provides: one dedicated build VM per profile with a persistent layer cache on local disk, selected from the workflow by name. Remote Docker builders sit in the WarpBuild product surface alongside snapshot runners, CI observability, an MCP server, and the Action Debugger.

Configuration

Symptom to change

Symptom in the build logCauseChange
First non-CACHED step is COPY . . above the installwhole context copied firstcopy the manifest and lockfile first, then install, then copy the source
First non-CACHED step reads $GIT_SHA or $BUILD_DATEbuild arg consumed earlydeclare and consume the arg below the dependency layers
Dependency layer misses on most commitslockfile churnmeasure with git log, pin ranges, batch bot updates
arm64 misses while amd64 hitsone cache per architecturebuild both architectures on the default branch
Everything misses after a quiet periodprofile cache reset after 10 unused daysschedule a warmup build inside the window
Two concurrent builds both missshared cache is eventually consistentstagger the burst, or let the next build pick it up
Failed to commit cache (Docker)layers above about 5GB on a small runnermove the job to a larger runner size

Keep both architectures warm

Building both architectures on the default branch keeps both halves of a multi-arch profile fed, and passing the version stamp as a build arg is safe once the arg is consumed at the bottom of the Dockerfile:

name: docker
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ghcr.io/acme/api:${{ github.sha }}
          profile-name: api-multiarch
          build-args: |
            GIT_SHA=${{ github.sha }}

Both architectures must be enabled on the profile in the WarpBuild UI, otherwise the build fails with exec format error. Keep Docker builds on the Linux runners, since macOS runners do not support nested virtualization and cannot run Docker.

Trim the build context

.git
.github
docs
*.md
tests/fixtures
node_modules
.venv

Every path removed here is a path that can no longer invalidate a COPY.

Reset a profile on purpose

Reset after a base image change that leaves a large volume of dead layers, or when a profile disk is close to full:

curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \
  "https://api.warpbuild.com/api/v1/builder-profiles?per_page=30&page=1"

curl -s -X POST \
  -H "Authorization: Bearer $WARPBUILD_API_KEY" \
  "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset"

Session billing, the bake action, and the full input list are in the remote Docker builders documentation, and the drop-in cache action for dependency caching outside the Docker build is in the WarpBuild caching documentation.

Cost or Time Model

The model below is arithmetic on stated assumptions rather than a measurement. Replace each row with the step durations in your own build log before you act on the result.

Assumptions

  • A Python service image, 2.6 GB final size, eight layers, built from python:3.12-slim.
  • 700 image builds per month.
  • 0.5 minutes of job overhead per build for checkout, registry login, and action setup.
  • Builds arrive in bursts of four on one builder profile, staggered by 0.5 minutes, and a session is billed from the first job start to the last job completion.
  • Job runner warp-ubuntu-latest-x64-4x at $0.008 per minute and a 16 vCPU builder profile at $0.06 per minute, both from the pricing page.

One cache miss, step by step

StepColdWarm
Pull python:3.12-slim0:200:00
apt-get install build-essential1:350:00
COPY requirements.txt ./0:020:00
pip install, 180 packages, 3 wheels built from source4:100:00
COPY . .0:080:08
collectstatic0:350:35
Export and push changed layers2:100:47
Total9:001:30

With overhead, a cold build occupies its job runner for 9.5 minutes and a warm one for 2.0 minutes.

One avoidable miss therefore costs 7.5 extra job minutes at $0.008 and, for a build billed in a session of its own, 7.5 extra builder minutes at $0.06. That is $0.51 and 7.5 minutes of wall clock that a pull request spends waiting. Keep the number in mind when someone proposes adding a CACHEBUST arg at the top of the Dockerfile.

Rates used

Runner labelShapePrice per minute
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.008
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016
warp-ubuntu-latest-x64-16x16 vCPU, 64 GB$0.032
Builder sizeDiskPrice per minuteArchitectures
16 vCPU, 32 GB100GB$0.06amd64, arm64, multi
32 vCPU, 64 GB200GB$0.12amd64, arm64, multi
64 vCPU, 128 GB200GB$0.24amd64, arm64, multi
96 vCPU, 192 GB600GB$0.36amd64
96 vCPU, 192 GB2TB$0.52amd64
192 vCPU, 384 GB600GB$0.72amd64
192 vCPU, 384 GB2TB$0.88amd64

arm64 and multi-arch profiles top out at 64 vCPU. The 96 vCPU and 192 vCPU sizes are amd64 only.

Monthly cost at three miss rates

A burst of four warm builds holds a session for 3.0 minutes, which is 0.75 builder minutes per build. A burst of four cold builds holds it for 10.5 minutes, which is 2.625 builder minutes per build.

Miss rateCold buildsJob minutesBuilder minutesMonthly cost
100 percent7006,650 at $0.0081,837.5 at $0.06$163.45
40 percent2803,500 at $0.0081,050.0 at $0.06$91.00
8 percent561,820 at $0.008630.0 at $0.06$52.36

Moving from an invalidation on every run to an invalidation on one run in twelve takes the monthly bill from $163.45 to $52.36 on these assumptions and cuts the wait on a pull request from 9.5 minutes to 2.0 minutes. The miss rate is the lever the Dockerfile ordering controls, and the git log count above is how you find your current position on this table.

For a baseline, GitHub's 4-core Linux larger runner lists at $0.012 per minute in the GitHub Actions minute multipliers reference, checked on 2026-08-13. Running all 700 builds cold on that runner with no persistent cache is 6,650 minutes, or $79.80 per month, with every pull request waiting the full 9.5 minutes.

Rates for every runner size and builder size are on the pricing page.

FAQ

Why does my Docker cache miss when the Dockerfile did not change?

The Dockerfile is only one input. BuildKit invalidates a layer when the inputs of that instruction change, then invalidates every layer after it, so a source file that lands in an early COPY or a build arg consumed near the top rebuilds the rest of the image. Run the build with --progress=plain and find the first step that prints without a CACHED prefix; that instruction is the one whose inputs moved.

Do build args invalidate the Docker layer cache?

Yes, for every instruction that references them. An ARG GIT_SHA or ARG BUILD_DATE declared and consumed above the dependency install changes value on every commit, so the dependency layer and everything after it rebuilds each run. Declare the ARG and the ENV that consumes it below the dependency layers.

Why did my layer cache go cold after a quiet week?

A WarpBuild builder profile cache has a TTL of 10 days and a profile that goes unused for longer than that is reset automatically. A manual cache reset through the WarpBuild API has the same effect. A profile driven only by a release workflow that runs every two weeks will be cold on most releases.

Why do two builds that start at the same time both miss?

The layer cache on a builder profile is shared between concurrent builds but eventually consistent, so a layer written by one in-flight build may not be visible to another build already running. Later builds pick it up after the cache synchronizes. Multi-arch builds are a second case, since each architecture runs on its own builder instance with its own cache.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.