Bazel Disk Cache on GitHub Actions Runners
Bazel's --disk_cache is deleted with the GitHub Actions runner. Carry it forward with a keyed cache action and a size cap, or boot from a snapshot runner.
Bazel's --disk_cache writes action results and output blobs into a directory on the machine that runs the build, and a GitHub Actions runner is deleted when its job finishes, so that directory is gone before the next run starts. Two mechanisms carry it forward: save and restore the directory with a cache action keyed on the files that decide your action keys, or run the job on a machine that already holds the directory, which is what a snapshot runner does.
This guide covers how the disk cache differs from a remote cache, the workflow that moves it with a size cap and a stable key, the prepared-machine option for directories too large to move, and the arithmetic that picks between them.
Diagnosis
Start by confirming the cache is missing rather than misconfigured. Bazel prints a process summary at the end of every build, and the disk cache shows up in it:
INFO: 3184 processes: 2601 disk cache hit, 118 internal, 465 linux-sandbox.On GitHub Actions that line usually reads zero disk cache hits on every run, including a rerun of the same commit. Three separate causes produce that symptom, and they take different fixes.
The directory lives on a machine that is thrown away
The disk cache holds two things: action cache entries keyed by the action key, and the content-addressed blobs those entries point at. Both are plain files under the directory you pass to --disk_cache, with no network protocol and no server involved, as the Bazel remote caching documentation describes. A hosted GitHub Actions runner is provisioned per job and destroyed after it, so nothing under that directory outlives the job that wrote it.
The directory outgrows what a cache action can move
Bazel writes an entry for every executed action and applies no ceiling by default. On a monorepo with a wide action graph the directory reaches tens of gigabytes within a week of builds. A cache action is a transfer wrapped in a workflow step: it compresses and uploads the paths at the end of a run, then downloads and decompresses them at the start of the next one, and the runner is billed for the wall clock time it spends waiting. That transfer scales with the size of the directory rather than the size of the change, so a pull request touching one file pays the full restore.
Storage limits bite at the same time. GitHub caps the combined size of all caches in a repository at 10 GB by default, evicts entries in least recently used order once the total passes the cap, and deletes any entry unused for 7 days, per the GitHub dependency caching documentation checked on 2026-08-13. A 12 GB disk cache does not fit under that ceiling at all, and cache entries are scoped to the branch that wrote them plus the base branch, so several active branches each hold their own copy.
The key churns on every commit
A key built from hashFiles('**/*.go') or any other source glob writes a brand new full-size entry on every commit, and the entry you restore is always one commit stale. The disk cache is content addressed internally, so it does not need a key that tracks source changes. It needs a key that changes when the action keys change, which happens on a Bazel version bump, a toolchain change, or an rc file edit.
Measure before choosing a fix. Add the restore step seconds and the post-run save seconds from a recent run, then compare that total against the execution time the disk cache hits would remove. When the transfer is larger, moving the directory is the wrong mechanism no matter how good the hit rate gets.
Fix
Three placements exist for Bazel cache state on GitHub Actions, and the choice is a size question.
| Property | Disk cache (--disk_cache) | Remote cache (--remote_cache) |
|---|---|---|
| What it stores | Action cache entries and the output blobs they point at | The same entries and blobs |
| Where it lives | A directory on the runner filesystem | A gRPC or HTTP endpoint you operate or buy |
| Shared across machines | No | Yes |
| How an action reaches it | Local file reads | A network round trip per action |
| Cost on GitHub Actions | The transfer that moves the directory in and out, or nothing when the machine keeps it | The service, its storage, and latency per action |
| Write access on pull requests | The workflow's own cache scope | Needs a token and a read-only mode for forks |
| Breaks down when | The directory grows past what you can move per run | The endpoint is far from the runner or unavailable |
The disk cache is the cheaper option in three situations. First, when there is no cache service to run: the flag is a directory path, and the operational cost is zero. Second, when the compressed directory stays small enough that its restore costs less runner time than the actions it skips, which is the arithmetic in the cost model below. Third, when the machine keeps the directory between runs, because a local tier that never transfers has no per-run cost at all.
A remote cache stays the right answer for sharing results across machines, branches, and laptops. The two combine cleanly: Bazel checks the disk cache first, falls through to the remote endpoint, and writes remote hits back to disk so the next action in the same build reads them locally. Remote build cache covers that layering, running Bazel on GitHub Actions with a remote cache covers the endpoint side, and Bazel remote execution on GitHub Actions covers the case where the actions themselves run off the runner. The general pattern behind all of this is written up in persistent caches on GitHub Actions runners.
Configuration
Pin the directory in an rc file the workflow loads, so the path is identical on every run and no step has to guess it.
startup --output_user_root=/home/runner/.cache/bazel
build:ci --disk_cache=/home/runner/.cache/bazel-disk
build:ci --experimental_disk_cache_gc_max_size=20G
build:ci --repository_cache=/home/runner/.cache/bazel-repo
build:ci --incompatible_strict_action_env
build:ci --announce_rc--incompatible_strict_action_env is the flag that decides whether two runners agree on an action key at all, because without it Bazel passes the client PATH into action environments. --experimental_disk_cache_gc_max_size is the in-Bazel ceiling on recent releases; older releases need the prune step in the workflow below.
Moving the directory with a cache action
name: bazel
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
- name: Restore Bazel disk cache
id: disk-cache
uses: WarpBuilds/cache/restore@v1
with:
path: ~/.cache/bazel-disk
key: bazel-disk-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('MODULE.bazel.lock', '.bazelversion', '.bazelrc', 'ci/.bazelrc', 'toolchains/**') }}
restore-keys: |
bazel-disk-${{ runner.os }}-${{ runner.arch }}-
- name: Build and test
run: |
bazel --bazelrc=ci/.bazelrc test //... --config=ci --jobs=8
- name: Cap the disk cache before saving
run: |
bazel --bazelrc=ci/.bazelrc shutdown
du -sh ~/.cache/bazel-disk
find ~/.cache/bazel-disk -type f -atime +3 -delete
- name: Save Bazel disk cache
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/cache/save@v1
with:
path: ~/.cache/bazel-disk
key: ${{ steps.disk-cache.outputs.cache-primary-key }}Four details in that job are the whole technique.
The key hashes workspace files rather than sources. MODULE.bazel.lock, .bazelversion, the rc files, and the toolchain definitions are the inputs that move action keys wholesale; a source edit invalidates individual actions inside the cache and leaves the rest usable. The restore-keys prefix means a Bazel bump falls back to the previous entry instead of starting empty.
runner.arch belongs in the key because action results do not cross architectures. An ARM64 job and an x64 job maintain separate key spaces by design, so one shared entry would restore blobs the other can never hit. A workspace that builds on more than one of them keeps one entry per platform.
The save runs on the default branch only. Pull requests read the shared entry through restore-keys and write nothing, which keeps one authoritative entry instead of one per branch and removes the upload from the critical path of every review cycle.
bazel shutdown runs before the prune so the Bazel server is not writing into the directory while find walks it, and du -sh prints the size into the job log so the growth curve is visible without extra tooling. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 with no repository size ceiling, is enabled by default on Linux runners, and is documented in the WarpBuild caching docs.
The prepared-machine option
When the directory is too large to move on every run, stop moving it. A snapshot runner captures the whole runner VM at a chosen point and boots later jobs from that image, so the disk cache is already on the filesystem when the job starts and nothing transfers.
jobs:
build:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-8x;snapshot.key=bazel-disk-main' }}
steps:
- uses: actions/checkout@v5
- name: Build and test
run: |
bazel --bazelrc=ci/.bazelrc test //... --config=ci --jobs=8
- name: Cap and clean before capture
if: github.ref == 'refs/heads/main'
run: |
bazel --bazelrc=ci/.bazelrc shutdown
find ~/.cache/bazel-disk -type f -atime +3 -delete
rm -rf $HOME/.ssh $HOME/.aws
git clean -ffdx
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: "bazel-disk-main"
fail-on-error: true
wait-timeout-minutes: 60Five constraints from the snapshot runner documentation shape that job. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on any other runner type are silently ignored while the job runs normally. The /tmp directory does not persist because it is cleaned on reboot and a snapshot boot is a reboot, which is why the rc file pins the disk cache under the runner home. Snapshots are deleted after 15 days, so the default-branch job doubles as the refresher. Boot from a snapshot takes 45 to 60 seconds. The prune still matters, because an alias that is rebuilt from itself accumulates entries across every run that boots from it, against a 150GB SSD.
Snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface. CI observability is the one worth wiring up here, because it reports runner system metrics correlated with the job logs, which is how you tell a build that is waiting on a cache transfer from one that is CPU bound.
Cost or Time Model
The fees that change with the mechanism, from the pricing page:
| Line | Rate on hosted runners |
|---|---|
| Cache storage | $0.20 per GB-month |
| Cache write or restore | $0.0001 per operation |
| Snapshot restore | $0.04 per job |
| Snapshot storage | $0.025 per snapshot-hour |
Both cache lines are included at no charge on BYOC runners. Runner rates for the sizes a Bazel build usually lands on:
| Label | vCPU | RAM | USD per minute | Minutes equal to one snapshot restore |
|---|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | 5.00 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | 2.50 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | 1.25 |
warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | 3.33 |
warp-ubuntu-latest-arm64-16x | 16 | 64 GB | $0.024 | 1.67 |
The last column is the fixed hurdle: a snapshot has to remove at least that much setup time per run before it pays for the restore fee alone.
Worked model
Assumptions, stated so you can substitute your own step timings:
- 600 workflow runs per month, one in six of them on the default branch.
- A disk cache directory that compresses to 6 GB, restoring in 2.50 minutes and saving in 2.50 minutes.
- Base image boot of 0.25 minutes, snapshot boot of 1.00 minute, snapshot capture of 4.00 minutes on each of 100 default-branch runs.
| Stage | Cache action path | Snapshot path |
|---|---|---|
| Runner boot | 0.25 min | 1.00 min |
| Restore state | 2.50 min | 0.00 min |
| Save state, amortized | 0.42 min | 0.67 min |
| Total per run | 3.17 min | 1.67 min |
The snapshot path removes 1.50 minutes per run, or 900 minutes per month. Priced out on warp-ubuntu-latest-x64-8x at $0.016 per minute:
| Line | Monthly |
|---|---|
| Runner minutes removed, 900 at $0.016 | $14.40 |
| Cache storage no longer held, 6 GB at $0.20 | $1.20 |
| Cache operations no longer run, 700 at $0.0001 | $0.07 |
| Snapshot restores added, 600 at $0.04 | $24.00 |
| Snapshot storage added, 720 snapshot-hours at $0.025 | $18.00 |
| Net | $26.33 more expensive |
At this size the cache action wins, which is the point of running the arithmetic before the migration. Two variables move the result. Runner rate is the weaker one: the same workload on warp-ubuntu-latest-x64-16x at $0.032 per minute is worth $28.80 of removed minutes against the same $42.00 of snapshot fees, which narrows the gap without closing it. Directory size is the one that decides. Grow the disk cache to 13 GB compressed, where the restore takes 5.50 minutes and the save 5.50, and the cache path costs 6.67 minutes per run against 1.67 on the snapshot path. That is 3,000 minutes per month, worth $96.00 on the 16x label against $42.00 of snapshot fees.
The rule that falls out: the transfer is the variable cost and the snapshot fees are fixed, so the crossover moves with directory size and runner rate together. Repositories under a few gigabytes of compressed disk cache stay on the cache action; monorepos above roughly 10 GB stop moving the directory.
FAQ
Can I cache the Bazel disk cache with a cache action?
Yes, with three adjustments. Key the entry on the files that decide action keys (MODULE.bazel.lock, .bazelversion, the rc files, toolchain definitions) rather than on source hashes, so the entry survives ordinary commits. Save from the default branch only, so pull requests read the shared entry and never write near-duplicates. Cap the directory before the save step, because the restore and save transfer grows with the directory and is billed as runner minutes.
How large should I let the Bazel disk cache grow on a runner?
Cap it at the size whose restore costs less runner time than the actions the hits remove. Recent Bazel releases enforce a ceiling with --experimental_disk_cache_gc_max_size; older releases need a prune step such as find ~/.cache/bazel-disk -type f -atime +3 -delete after bazel shutdown. WarpBuild Ubuntu runners carry a 150GB SSD, so the transfer cost binds long before the disk does.
Should I use the disk cache or a remote cache on GitHub Actions?
Both, when you operate a cache service. Bazel checks the disk cache first and writes remote hits back to disk, so the disk cache acts as a local tier in front of the remote endpoint. When there is no service to operate, the disk cache is the whole mechanism, and the question becomes how to keep the directory on the machine between runs.
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.