Sharing a Docker Layer Cache Across Jobs
Two GitHub Actions jobs share a Docker layer cache only when both build on one persistent builder. Point every job at the same WarpBuild builder profile.
Last verified:
Two GitHub Actions jobs share a Docker layer cache only when both builds run on a builder that outlives either job, and a buildx instance created inside a job is never that builder. On WarpBuild the shared builder is a builder profile: one dedicated Docker builder VM with a persistent layer cache on local disk, selected from the workflow by profile-name, so every job naming the same profile builds against the same cache whichever workflow, branch, or repository it came from.
Per-minute rates for job runners and builder profiles are on the pricing page, and the model at the end works a fan-out through them.
This guide covers the three reasons jobs do not share layers today, the profile model that fixes it, the concurrency and consistency boundaries you need to know before you fan out, and a session billing example with real rates. For the wider picture, start at Docker builds on GitHub Actions.
Diagnosis
Three separate mechanisms keep two jobs from sharing layers. They stack, and each one alone is enough to make every job rebuild from scratch.
A fresh VM per job
Every GitHub Actions job is provisioned on its own machine and that machine is destroyed when the job ends. A layer cache is disk state, so it lives and dies with the machine underneath the job that produced it.
The consequence shows up hardest in a matrix. Three jobs building three service images off the same commit run on three machines. They share a base image, they usually share a dependency stage, and they each build both from scratch because none of them can read the other two disks.
Check this against your own logs before changing anything. Open the second and third matrix job of a green run and count CACHED lines against the first job's log. If all three logs execute the same base stage with no CACHED prefix, the jobs never shared anything and no Dockerfile change will make them.
Per-job buildx state
docker/setup-buildx-action creates a builder inside the job. The container backing that builder is created when the step runs and removed when the job ends, and its layer store, its BuildKit history, and any cache mounts written by RUN --mount=type=cache go with it.
There is also no address to share. A buildx builder created in job A exists only for the life of job A, so job B has nothing to point at even if it wanted to. Two steps inside one job share a builder. Two jobs never do by default.
Cache scoping that does not cross branches
The usual workaround is an external cache backend through cache-from and cache-to. That moves the layers off the machine, and then scoping rules decide who can read them.
GitHub's cache isolation means a branch reads its own scope and the default branch scope, and never another branch's scope. A pull request build therefore cannot read what a sibling pull request wrote, which is exactly the sharing most teams are trying to get. Fan-out makes it worse in a second way: matrix jobs that start at the same time all read the scope as it existed before any of them wrote, because cache-to runs at the end of a build. Concurrent jobs write over each other and the last writer wins.
The net effect is that an external backend gives you sharing across time on one branch, at the cost of an export and import round trip per build, and gives you close to nothing across concurrent jobs. The tradeoffs between the individual backends are worked through in buildx cache backends for GitHub Actions.
A fourth cause is worth ruling out while you are here. If jobs on one branch also miss against their own earlier runs, the problem is layer invalidation rather than sharing, and why a Docker layer cache invalidates on GitHub Actions covers the Dockerfile ordering and lockfile churn behind it.
Fix
Move the build off the job runner and onto a builder profile that both jobs name.
A WarpBuild builder profile is one optimized, dedicated Docker builder VM with caching. Multiple jobs can use the same profile in parallel, and those builds run on that same VM in parallel against the same layer cache. The cache belongs to the profile rather than to a repository, a branch, or a workflow, so sharing follows the profile name and nothing else. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface.
Here is a three service fan-out where every job shares one cache:
name: images
on:
pull_request:
jobs:
build:
runs-on: warp-ubuntu-latest-x64-2x
strategy:
fail-fast: false
matrix:
service: [api, worker, scheduler]
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: .
file: services/${{ matrix.service }}/Dockerfile
push: true
tags: ghcr.io/acme/${{ matrix.service }}:${{ github.sha }}
profile-name: platform-amd64
timeout: 600000Three points about that file.
The three matrix jobs run on three job runners and one builder. Whatever the api build pulls and installs lands on the profile disk, and the worker and scheduler builds reuse it on the next run.
There is no docker/setup-buildx-action step, because the WarpBuild action configures the remote builder itself. There is no cache-from or cache-to either. A cached profile keeps its layers on local disk and reuses them for subsequent builds, so the export and import round trip buys nothing and costs wall clock.
The job runner is small on purpose. With the build executing on the builder, the job checks out the repository, authenticates to the registry, and waits, so warp-ubuntu-latest-x64-2x carries it. Pick a Linux runner here, since macOS runners do not support nested virtualization and cannot run Docker.
Sharing extends past one workflow. A nightly job, a release workflow on the default branch, and a developer running Warpbuilds/build-push-action from a GitHub-hosted runner with an API key all reach the same cache when they name platform-amd64. The job runners driving those builds can be WarpBuild-hosted runners or BYOC runners in your own AWS, GCP, or Azure account, and the profile is addressed the same way from either.
Configuration
Inputs that decide what gets shared
| Input | Value | Effect on sharing |
|---|---|---|
profile-name | Name of the builder profile | The sharing key. Same name means same VM and same cache. Two names never share. |
api-key | ${{ secrets.WARPBUILD_API_KEY }} | Required when the job runs on a GitHub-hosted runner or any machine outside WarpBuild. Not required on WarpBuild runners. |
platforms | linux/amd64,linux/arm64 | Each architecture runs on a separate builder instance with its own cache. Enable both on the profile first, otherwise the build fails with exec format error. |
timeout | Milliseconds to wait for the builder | Defaults to 10 minutes. Raise it when a burst of jobs queues behind a busy profile. |
If your build has custom steps between setup and docker build, use Warpbuilds/docker-configure@v1 and keep your own build command. Invoke it immediately before the build step, since the builder is billed from the moment it is assigned.
Sizing a profile for concurrent jobs
There is no limit on how many builds run concurrently on a profile, and the documented recommendation is roughly 8 vCPU and 16 GB of memory per concurrent build job. That turns profile size into a concurrency budget:
| Profile size | Disk | Price per minute | Concurrent build jobs at the recommended minimum |
|---|---|---|---|
| 16 vCPU, 32 GB | 100GB | $0.06 | 2 |
| 32 vCPU, 64 GB | 200GB | $0.12 | 4 |
| 64 vCPU, 128 GB | 200GB | $0.24 | 8 |
| 96 vCPU, 192 GB | 600GB | $0.36 | 12 |
| 96 vCPU, 192 GB | 2TB | $0.52 | 12 |
| 192 vCPU, 384 GB | 600GB | $0.72 | 24 |
| 192 vCPU, 384 GB | 2TB | $0.88 | 24 |
Rates are from the pricing page and the remote Docker builders documentation, checked on 2026-08-13. The final column is arithmetic on the documented 8 vCPU recommendation rather than a measurement, so treat it as a starting point and watch your build durations as concurrency climbs. arm64 and multi-arch profiles cap at 64 vCPU; the 96 vCPU and 192 vCPU sizes are amd64 only.
Capacity on the runner side is separate and unconstrained. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps.
What concurrent builds actually see
This is the boundary to understand before you fan out fifteen jobs at one profile.
The cache is shared between concurrent builds and it is eventually consistent. A layer produced by one in-flight build may not be visible to another build running at the same moment, and it becomes visible to later builds once synchronization occurs. A cold fan-out therefore builds the shared base stage in more than one job, and the run after it reuses a single copy.
Three consequences follow.
First, a cold profile costs you once, not forever. The first fan-out after a base image bump pays for parallel duplicate work; the next one does not.
Second, serializing a warmup build ahead of the fan-out is often the wrong trade under session billing, because a session bills wall clock rather than CPU. Three cold builds running side by side finish in one build's wall clock. A warmup job that runs first and then hands off to the matrix adds its own duration to the total. The warmup wins only when the concurrent jobs would contend for the profile's vCPU, which the sizing table above is there to prevent.
Third, cache scope is the profile. One profile per image gives each image a dense cache and more sessions to pay for. One profile across a monorepo's images gives fewer sessions and a cache that has to hold more. Split when images have different base layers, share when they do not.
The builder cache has a TTL of 10 days, and a profile that goes unused for longer is reset automatically. You can also reset it through the API after a base image change leaves a large volume of dead layers:
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"Dependency caches that live outside the Docker build follow different rules and are covered in the WarpBuild caching documentation.
Cost or Time Model
Everything below is arithmetic on stated assumptions rather than a measurement. Replace each duration with the numbers printed in your own job logs before you make a decision on it.
How a session is billed when jobs overlap
A builder session is measured from when the builder action starts until the job completes, and concurrent jobs on one profile share a single session billed from the first job's start to the last job's completion.
Take two jobs on one 16 vCPU profile at $0.06 per minute:
| Event | Time | Job |
|---|---|---|
| Builder assigned | 10:00:00 | J1 |
| Builder assigned | 10:02:00 | J2 |
| Job completes | 10:06:00 | J1 |
| Job completes | 10:09:00 | J2 |
J1 occupies the builder for 6 minutes and J2 for 7 minutes, which is 13 minutes of build time. The bill is one session from 10:00:00 to 10:09:00, so 9 minutes at $0.06 equals $0.54 rather than $0.78. Overlap is the lever: the more of your fan-out that runs at the same time, the more build minutes ride inside one session.
Two footnotes on that. A multi-arch profile creates one session per architecture, each billed independently, so an amd64 plus arm64 build of the same image is two sessions. And the job runner and the builder are two separate resources, so both appear on the bill.
A monthly fan-out model
Assumptions:
- Three service images built from a shared base stage, 200 pull request runs per month, so 600 image builds.
- Matrix jobs start 0.5 minutes apart, because runner assignment is not simultaneous.
- A warm build occupies the builder for 2.5 minutes and a cold one for 7.0 minutes. One run in ten is cold: 20 cold runs, 180 warm.
- Job runner overhead of 0.5 minutes per job for checkout, registry login, and action setup.
- Durations are held constant across both options, so the only variable is how many sessions the work is spread over.
With one shared profile, a warm run bills from the first job's start at 0.0 to the last job's completion at 3.5 minutes, and a cold run bills 8.0 minutes:
| Line | Minutes | Rate | Cost |
|---|---|---|---|
| Builder, 180 warm runs at 3.5 min | 630 | $0.06 | $37.80 |
| Builder, 20 cold runs at 8.0 min | 160 | $0.06 | $9.60 |
Job runners, warp-ubuntu-latest-x64-2x | 2,070 | $0.004 | $8.28 |
| Total | $55.68 |
With one profile per service, the same work spreads over three sessions per run, so warm runs bill 3 x 2.5 minutes and cold runs 3 x 7.0 minutes:
| Line | Minutes | Rate | Cost |
|---|---|---|---|
| Builder, 180 warm runs at 7.5 min | 1,350 | $0.06 | $81.00 |
| Builder, 20 cold runs at 21.0 min | 420 | $0.06 | $25.20 |
Job runners, warp-ubuntu-latest-x64-2x | 2,070 | $0.004 | $8.28 |
| Total | $114.48 |
The shared profile saves $58.80 per month on these assumptions, and the saving comes entirely from session overlap rather than from faster builds. Split the profile anyway when the three images share no base layers, since then the shared cache holds three unrelated working sets on one disk and eviction starts costing you hit rate.
For a baseline, a GitHub-hosted ubuntu-latest job on a private repository is billed at $0.006 per minute from the GitHub Actions minute multipliers reference, checked on 2026-08-13. That runner gives the fan-out no shared builder at all, so each of the three jobs carries the full cold build every time unless you add an external cache backend and accept its scoping rules.
Every cost number here carries its rate, its source, and a checked-on date, and the same rates appear on the pricing page. The one flat item on the platform is SSO, which costs $250 per month whatever the user count.
FAQ
Can two GitHub Actions jobs share the same Docker layer cache?
Only if both builds run somewhere that outlives a single job. A buildx instance created inside a job dies with the job, so a second job has nothing to attach to. Give both jobs the same WarpBuild builder profile through profile-name and they build against one VM and one layer cache, in the same workflow or in different repositories.
Do concurrent builds on one profile see each other's layers?
Not reliably while they are still running. The cache on a builder profile is shared but eventually consistent, so a layer produced by one in-flight build may not be visible to another until it synchronizes. Later builds pick it up, which means a fan-out that starts cold can build the same shared stage more than once.
How is a builder profile billed when several jobs use it at once?
Per session. Concurrent jobs on one profile are billed as a single session measured from the first job's builder start to the last job's completion, so two overlapping six and seven minute builds bill as one nine minute session rather than thirteen minutes. Multi-arch profiles run one session per architecture, and the job runner and the builder are two separate resources on the bill.
How large should a builder profile be for a matrix of jobs?
Size it against concurrency. The documented recommendation is roughly 8 vCPU and 16 GB of memory per concurrent build job, so a 16 vCPU, 32 GB profile comfortably carries two builds at once and a 64 vCPU, 128 GB profile carries eight. arm64 and multi-arch profiles cap at 64 vCPU.
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.