Docker Layer Caching on GitHub Actions

Docker layer caches miss on GitHub Actions because every job builds on a fresh buildx instance. Move the cache onto a builder that outlives the job.

Last verified:

Docker layer caching misses on almost every GitHub Actions run for one structural reason: the job gets a fresh machine, so the buildx instance it creates starts with an empty layer store and rebuilds or re-downloads every layer. The durable fix is to run the build on a builder that outlives the job, which is what a WarpBuild remote Docker builder profile gives you: a dedicated build VM that keeps its layer cache on local disk between runs, selected by name from the workflow.

Per-minute rates for job runners and builder profiles are on the pricing page, and the model at the end of this guide works them through.

This guide covers the four reasons a layer cache misses, the workflow change that fixes the common case, the exact configuration for Warpbuilds/build-push-action, and a worked time and cost model you can re-run with your own numbers. For the wider picture of Docker on GitHub Actions, start at Docker builds on GitHub Actions.

Diagnosis

Before changing anything, work out which of these four is producing your misses. They have different fixes and they stack.

A fresh buildx instance per job

docker/setup-buildx-action creates a builder inside the job. The container that backs it is created when the step runs and destroyed when the job ends, along with the machine underneath it. Its layer store therefore starts empty on every run. Nothing in a default buildx setup carries state between two jobs.

The symptom is a build log where every RUN and every COPY line prints as executed, with no CACHED prefix, on a commit that touched one source file. If you see CACHED on none of your layers even when the Dockerfile and the lockfile are unchanged since the last green run, the builder is the problem and no Dockerfile change will help.

cache-from and cache-to that never populate

The usual response is to add an external cache backend:

      - uses: docker/build-push-action@v6
        with:
          cache-from: type=gha
          cache-to: type=gha,mode=max

This can work, and it fails quietly in three common ways. First, cache-to runs at the end of the build, so a failed or cancelled build writes nothing and the next run is still cold. Second, the cache scope is keyed by branch by default, so a pull request branch reads its own empty scope rather than the cache written by the last build on the default branch. Third, mode=max stores every intermediate layer, which makes the export large, and a large export competes with the size and eviction limits of whatever backend you pointed it at. See working around the GitHub Actions cache size limit for how those limits bite.

The symptom here is a build that spends real time in importing cache manifest and exporting cache steps and still prints no CACHED lines. That means the manifest resolved but the layer digests did not match, which sends you to the next two causes.

COPY ordering that invalidates early layers

BuildKit invalidates a layer when its inputs change, then invalidates every layer after it. A Dockerfile that copies the whole context before installing dependencies invalidates the dependency install on every commit, because the context includes the source file you just edited.

FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]

Every commit changes something under COPY . ., so npm ci reruns even when no dependency moved. The ordered version separates the two inputs:

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

Now npm ci only reruns when package.json or package-lock.json changes. The --mount=type=cache line adds a second layer of protection: the package manager's own download cache lives in a BuildKit cache mount, which survives on a persistent builder and disappears with a per-job buildx instance. That difference is one concrete reason a persistent builder reuses more work than a per-job buildx instance on the same Dockerfile.

Apply the same ordering to every language. Copy go.mod and go.sum before the rest of a Go tree, pyproject.toml and the lock file before a Python tree, Cargo.toml and Cargo.lock before a Rust tree.

Lockfile churn

Once ordering is correct, the dependency layer is only as stable as the lockfile. Three patterns break it:

  • A dependency bot that opens lockfile-only pull requests daily, so the default branch cache is invalidated most days.
  • A build step that regenerates the lockfile rather than honoring it, which produces a different hash per run even with identical dependencies.
  • A range constraint that resolves to a new patch release, which rewrites the lockfile inside the build.

Check the frequency yourself. Run git log --oneline --since="30 days ago" -- package-lock.json | wc -l on the default branch. If that number is close to your commit count, the dependency layer will miss most of the time whatever caching backend you use, and the fix belongs in dependency policy rather than in the workflow.

Fix

Move the build off the job runner and onto a builder profile that keeps its cache. A WarpBuild builder profile is one dedicated Docker builder VM with a persistent layer cache on local disk, addressed from the workflow by profile-name. Creating one and sizing it is covered in set up remote Docker builders for GitHub Actions. Jobs come and go; the profile and its cache stay. Remote Docker builders are part of the WarpBuild product surface alongside snapshot runners, CI observability, an MCP server, and the Action Debugger.

Here is a working before and after on a pull request build that pushes to GitHub Container Registry.

Before, with a per-job buildx instance and an external cache backend:

name: docker
on:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

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

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/acme/api:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

After, with a builder profile that keeps its cache:

name: docker
on:
  pull_request:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-2x
    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
          tags: ghcr.io/acme/api:${{ github.sha }}
          profile-name: api-amd64
          timeout: 600000

The change reads as four edits:

- runs-on: ubuntu-latest
+ runs-on: warp-ubuntu-latest-x64-2x

- - uses: docker/setup-buildx-action@v3

- - uses: docker/build-push-action@v6
+ - uses: Warpbuilds/build-push-action@v6
    with:
      context: .
      push: true
      tags: ghcr.io/acme/api:${{ github.sha }}
-     cache-from: type=gha
-     cache-to: type=gha,mode=max
+     profile-name: api-amd64
+     timeout: 600000

Two of those edits deserve a note. The docker/setup-buildx-action step goes away because the WarpBuild action configures the remote builder itself. The cache-from and cache-to lines go away because a cached builder profile keeps its layers on disk and reuses them for subsequent builds, so the export and import round trip buys nothing.

The job runner shrinks too. With the build running on the builder, the job checks out the repository, authenticates to the registry, and waits, so warp-ubuntu-latest-x64-2x is usually enough. For this workflow pick a Linux runner, since macOS runners do not support nested virtualization and cannot run Docker.

Configuration

The action inputs that matter

InputValueNotes
profile-nameName of the builder profileSelects the builder VM and therefore the cache. Required.
api-key${{ secrets.WARPBUILD_API_KEY }}Not required on WarpBuild runners. Required when the job runs on a GitHub-hosted runner or any other machine.
timeoutMilliseconds to wait for the builderDefaults to 10 minutes for Warpbuilds/build-push-action.
platformslinux/amd64,linux/arm64Both architectures must be enabled on the profile in the WarpBuild UI, otherwise the build fails with exec format error.

Running from a GitHub-hosted runner works with the same action plus the key:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - 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
          api-key: ${{ secrets.WARPBUILD_API_KEY }}

If your build has custom steps between setup and docker build, use Warpbuilds/docker-configure@v1 instead. It configures the builder and outputs its details, leaving the build command to you. Invoke it immediately before the build step, since the builder is billed from the moment it is assigned.

What persists between runs and what resets

This is the part teams get wrong, so be precise about the boundaries.

ItemBehavior between runs
Docker layer cache on the builder diskPersists on the builder profile until 10 days of no use, or until you reset it.
BuildKit cache mounts written by RUN --mount=type=cachePersist on the builder disk with the layer cache.
Job runner filesystem and workspaceReset every job. Runner storage is ephemeral and is deleted when the runner terminates.
Registry credentials from docker/login-actionReset every job. They live on the job runner.
amd64 and arm64 layers inside one multi-arch profileKept separately. Each architecture runs on a separate builder instance, so each keeps its own cache.
Layers in a different builder profileNever shared. Each profile is one builder VM with one cache.

Two boundaries follow from that table. The cache is scoped to the builder profile, so every job naming the same profile-name reaches the same cache regardless of which repository it runs in, and no cache is shared between two profiles or across your organization as a whole. And for jobs building at the same time on one profile, the cache is shared but eventually consistent, so a layer produced by one in-flight build may not be visible to another until it syncs; a later build picks it up.

Dependency caches that live outside the Docker build follow different rules, covered in persistent caches for GitHub Actions jobs.

Those two facts drive profile layout. One profile per image gives each image a clean, dense cache. One profile shared across a monorepo's images gives you fewer sessions to pay for and a cache that has to hold more. Split when images have different base layers; share when they do not.

Resetting and inspecting a profile

Cache reset is one of them, so list your profiles and reset the one you want:

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"

Reset after a base image change that leaves a large volume of dead layers, or when a profile's disk is close to full. The full input list, the bake action, the CLI flow, and the session billing rules live in the remote Docker builders documentation; the drop-in cache action for dependency caching is covered 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 printed in your own job log before you use the result to make a decision.

Assumptions

  • A Node.js service image, 1.8 GB final size, seven layers, built from node:22-slim.
  • Build step durations assumed for a 4 vCPU class machine. The model holds those durations constant across every option, so the only variable is the layer cache. A 16 vCPU builder has more cores than a 4 vCPU job runner and the model gives it no credit for that.
  • 600 image builds per month. One build in ten is cold, meaning a base image bump, a Dockerfile change, or a lockfile change invalidates the dependency layer: 60 cold and 540 warm.
  • 0.5 minutes of per-job overhead for checkout, registry login, and action setup.
  • For the external cache backend option, 2.0 minutes per warm build to import and export the layer cache, and 1.0 minute extra on a cold build to export it in full. This is the number you should replace first, since it varies most.
  • Builds arrive in bursts and share a builder session. Starts are staggered by 0.5 minutes.

Cold versus warm layer cache

StepColdWarm
Pull node:22-slim base image0:250:00
Install system build dependencies1:100:00
COPY package.json package-lock.json0:020:00
npm ci, 1,400 packages2:300:00
COPY . .0:030:03
npm run build2:002:00
Export and push changed layers1:500:40
Total8:002:43

Adding the 0.5 minutes of job overhead, a warm pull request build occupies its job runner for 3.2 minutes and a cold one for 8.5 minutes.

Rates used

Linux x64 job runners, from the WarpBuild pricing page:

Runner labelShapeStoragePrice per minute
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x16 vCPU, 64 GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32 vCPU, 128 GB150GB SSD$0.064

Builder profiles, billed per session from when the builder action starts until the job completes:

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.

The GitHub-hosted baseline is the 4-core Linux larger runner at $0.012 per minute, from the GitHub Actions minute multipliers reference and the GitHub pricing page, checked on 2026-08-13. For a same-shape reference, warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for that runner: 33 percent lower list price. GitHub list price checked on 2026-08-13.

Monthly model, 600 builds

OptionJob minutesBuilder minutesMonthly cost
A. GitHub 4-core larger runner, in-job buildx, no layer cache5,100 at $0.0120$61.20
B. GitHub 4-core larger runner, in-job buildx, external cache backend3,378 at $0.0120$40.54
C. warp-ubuntu-latest-x64-2x plus a 16 vCPU profile, 4 builds per session2,238 at $0.004709.5 at $0.06$51.52
D. Same as C, 8 builds per session2,238 at $0.004504.75 at $0.06$39.24

Reading the table straight: on these assumptions options C and D cut the wall clock a pull request waits from 8.5 minutes to 3.2 minutes, because the warm path skips the base pull, the system dependencies, and npm ci entirely. On list price, C already beats the uncached option A by $9.68 per month, and the crossover against the externally cached option B lands near eight builds sharing a session. Since the model gives the 16 vCPU builder no credit for having more cores than the 4 vCPU job runner, a real crossover arrives earlier than eight.

Two levers move that crossover. Session sharing is the first: concurrent jobs on one profile are billed as a single session from the first job's start to the last job's completion, so batching pull request builds onto one profile spreads the session cost. Disk size is the second: a 100 GB profile disk holds a 1.8 GB image's layers for many branches, and a profile serving several large images wants the 200 GB size before eviction starts costing you hit rate.

The layer cache on a builder profile is billed inside the session rate; the separate storage rate of $0.20 per GB-month and $0.0001 per operation applies to the WarpBuilds/cache action used for dependency caching rather than to builder profile disks. That is enough to run this model against your own repository before you commit to it.

FAQ

Why does my Docker layer cache miss on every GitHub Actions run?

A GitHub Actions job runs on a fresh machine, so the buildx instance created inside the job starts with an empty layer store. Unless the cache is exported to and imported from an external backend, or the build runs on a builder that outlives the job, every run rebuilds every layer.

Do I still need cache-from and cache-to with a WarpBuild builder profile?

No. The builder profile keeps its layer cache on the builder VM's local disk, so cache-from and cache-to are not required and should be removed. Leaving them in place adds an export and import round trip that the persistent cache already makes unnecessary.

Is the WarpBuild layer cache shared across repositories?

The cache belongs to the builder profile rather than to a repository or to your organization as a whole. Every job that names the same profile-name reaches the same builder VM and the same cache, whichever repository it runs in. Two different profiles never share a cache.

How long does a builder profile keep its layer cache?

The builder cache has a TTL of 10 days. A builder profile that goes unused for more than 10 days is reset automatically. You can also reset a profile's cache on demand through the WarpBuild API.

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.