Running Bazel on GitHub Actions with Remote Cache
Set --remote_cache in a checked-in bazelrc, pin the repository cache and output base on the runner, and keep both warm on WarpBuild snapshot runners.
Last verified:
Point Bazel at a remote cache from GitHub Actions by setting --remote_cache in a checked-in rc file that the workflow selects with --config=ci, then pin the repository cache and the output base to paths that live on the runner. On WarpBuild the same job runs on a warp-ubuntu-latest-x64-16x label and can boot from a snapshot that already holds the output base and the repository cache, so the remote cache only has to serve action results.
Those two mechanisms solve different problems, and teams usually ship one and wonder why the build is still slow. This page covers the rc file, the workflow, how the remote cache and runner-local state overlap, how to map --jobs to a runner size, and what the whole thing costs per month.
Overview
A Bazel build on a fresh GitHub Actions runner pays for four things before it compiles anything: fetching external repositories, loading and analyzing the build graph, materializing the execution root, and executing actions. A remote cache addresses exactly one of those four. Understanding which one keeps you from buying the wrong fix.
Bazel keeps four distinct pieces of state, and only one of them has a network form.
| Layer | Flag | What it holds | Shared across machines |
|---|---|---|---|
| Repository cache | --repository_cache | External archives keyed by SHA-256 | No, local disk only |
| Disk cache | --disk_cache | Action results and output blobs | No, local disk only |
| Remote cache | --remote_cache | The same action results and output blobs | Yes |
| Output base | --output_user_root | Analysis cache, action graph, symlink forest, persistent workers | No, local disk only |
The disk cache and the remote cache are the overlap. Both store the same two things: an action cache entry keyed by the action key (command line, input digests, environment, execution platform) and the content-addressed blobs that entry points at. When both flags are set, Bazel treats the disk cache as a local tier in front of the remote cache. It checks disk, then the remote endpoint, and on a remote hit it writes the result back to disk so the next action in the same build reads it locally. A hit in either tier means the action never runs.
The repository cache and the output base have no remote equivalent. External archives downloaded by http_archive, a Maven resolver, or an npm repository rule land in the repository cache on disk and stay there. The output base holds the analysis cache: the loaded packages, the configured targets, and the action graph. On a runner that has never seen this workspace, Bazel reloads and reanalyzes everything even when every single action then resolves to a remote cache hit. On a large monorepo that pass is measured in tens of seconds to minutes and it does not shrink as your remote cache hit rate improves.
That is the gap. The remote cache removes action execution. Snapshot runners remove repository fetching and analysis, because they carry the whole runner disk forward, output base included.
A Bazel workspace that builds Linux servers and an iOS app can therefore point both jobs at warp- labels. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on any other runner type are silently ignored and the job runs normally. Plan the macOS half of a Bazel build around the remote cache and a pinned repository cache.
One more layout decision matters before the configuration. Bazel's remote cache is chatty and latency sensitive, so the distance between the runner and the cache endpoint shows up in wall clock time. BYOC runs on AWS, GCP, and Azure if you would rather keep the runners in the same account and region as the cache bucket. Terraform support exists for BYOC on AWS.
Configuration
Keep the rc file in the repository so a developer can reproduce a cache hit locally, and keep the runner-specific absolute paths in a second rc file that only the workflow loads. That split avoids hardcoding /home/runner into the file your team uses on laptops.
The checked-in workspace .bazelrc holds the hermeticity settings that decide whether two machines agree on an action key at all.
common --enable_bzlmod
build --incompatible_strict_action_env
build --experimental_repository_cache_hardlinks
build --remote_cache_compression
build --remote_timeout=600
build --keep_going
test --test_output=errors--incompatible_strict_action_env is the flag that decides your cache hit rate. Without it Bazel passes the client PATH into action environments, so an action key computed on a runner with a different PATH will never match one computed on a laptop. --remote_cache_compression is the Bazel 7 and later spelling; older releases call it --experimental_remote_cache_compression.
The runner rc file carries the absolute paths and the endpoint. Load it with bazel --bazelrc=ci/.bazelrc build, which is why the startup flag works from here.
startup --output_user_root=/home/runner/.cache/bazel
build:ci --repository_cache=/home/runner/.cache/bazel-repo
build:ci --disk_cache=/home/runner/.cache/bazel-disk
build:ci --remote_cache=grpcs://bazel-cache.internal.example.com:443
build:ci --remote_instance_name=main
build:ci --remote_upload_local_results=true
build:ci --remote_download_outputs=toplevel
build:ci --announce_rc
build:ci --show_timestamps
build:ci --color=no
build:ci --curses=no
build:ci-pr --config=ci
build:ci-pr --noremote_upload_local_resultsThree choices in that file are worth stating out loud.
--output_user_root is pinned because Bazel otherwise derives the output base from a platform default, and on macOS that default sits under /var/tmp. Snapshot runners do not persist /tmp, which is cleaned on reboot, and booting from a snapshot is a reboot. Pinning the output base under the runner home puts the analysis cache inside the snapshot.
--remote_download_outputs=toplevel keeps intermediate outputs in the remote content store instead of writing every one of them to the runner disk. That cuts download bytes on a warm cache and it also means less of the build lands in the snapshot, so read the tradeoff before turning it on for a workspace whose test steps consume intermediate artifacts. Use --remote_download_outputs=all on the job that produces release artifacts.
Pull request jobs read the cache and do not write to it. A fork or an unreviewed branch that can write action results into the shared cache is a supply chain problem, and --noremote_upload_local_results closes it while still giving pull requests full read hits from main.
The workflow
This is the whole job. The main branch boots from the base image and writes a fresh snapshot at the end; every other branch boots from that snapshot.
name: bazel
on:
push:
branches: [main]
pull_request:
concurrency:
group: bazel-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-16x;snapshot.key=bazel-main' }}
steps:
- uses: actions/checkout@v5
- name: Report snapshot state
run: echo "booted from snapshot ${WARPBUILD_SNAPSHOT_KEY:-none}"
- name: Build
env:
CACHE_TOKEN: ${{ secrets.BAZEL_CACHE_TOKEN }}
run: |
bazel --bazelrc=ci/.bazelrc build //... \
--config=${{ github.ref == 'refs/heads/main' && 'ci' || 'ci-pr' }} \
--remote_header=x-api-key=${CACHE_TOKEN} \
--jobs=16
- name: Test
env:
CACHE_TOKEN: ${{ secrets.BAZEL_CACHE_TOKEN }}
run: |
bazel --bazelrc=ci/.bazelrc test //... \
--config=${{ github.ref == 'refs/heads/main' && 'ci' || 'ci-pr' }} \
--remote_header=x-api-key=${CACHE_TOKEN} \
--jobs=16
- name: Trim the disk cache before snapshotting
if: github.ref == 'refs/heads/main'
run: |
bazel --bazelrc=ci/.bazelrc shutdown
du -sh /home/runner/.cache/bazel-disk
find /home/runner/.cache/bazel-disk -type f -atime +3 -delete
- name: Cleanup credentials
if: github.ref == 'refs/heads/main'
run: |
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-main"
fail-on-error: true
wait-timeout-minutes: 60The token arrives through --remote_header on the command line rather than through the rc file, because anything written to disk before snapshot-save runs ends up inside the snapshot. The cleanup step exists for the same reason. WarpBuild provisions runners at the organization level and GitHub may hand a snapshot-backed runner to a different job in the organization, so remove credentials before capturing.
Two details from the snapshot runner docs shape the branch logic above. snapshot.enabled=true always boots from the base image and lets you capture a fresh snapshot, while snapshot.key=<alias> boots from the existing snapshot for that alias and falls back to the base image when none exists. Snapshots are deleted after 15 days, so the main job doubles as the refresher. If main is quiet, add a scheduled trigger so the alias never expires. Drive the snapshot lifecycle from your own tooling if a scheduled workflow is the wrong place for it.
The WarpBuild cache is a separate mechanism from the Bazel remote cache and the two do not conflict. Use WarpBuilds/cache@v1 for the things Bazel does not manage, such as a toolchain tarball or a container image layer, and leave the Bazel disk cache to the snapshot. The general pattern behind that split is written up in persistent caches on GitHub Actions runners.
Sizing
Bazel is a graph scheduler, so runner sizing is a question about the width of your action graph rather than the size of your repository. --jobs sets how many actions Bazel runs at once. Its default is the host CPU count, which is a reasonable floor and a bad ceiling once the remote cache is warm.
The rule that holds across workspaces: on a cold cache, every action slot is doing compiler work, so --jobs should equal the vCPU count. On a warm cache, most action slots are waiting on a network download from the remote content store, so --jobs can run one and a half to two times the vCPU count without starving anything.
| Label | vCPU | RAM | Storage | Cold cache --jobs | Warm cache --jobs | USD per minute |
|---|---|---|---|---|---|---|
warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | 8 | 12 to 16 | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | 16 | 24 to 32 | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | 32 | 48 to 64 | $0.064 |
warp-ubuntu-latest-arm64-8x | 8 | 32 GB | 150GB SSD | 8 | 12 to 16 | $0.012 |
warp-ubuntu-latest-arm64-16x | 16 | 64 GB | 150GB SSD | 16 | 24 to 32 | $0.024 |
warp-ubuntu-latest-arm64-32x | 32 | 128 GB | 150GB SSD | 32 | 48 to 64 | $0.048 |
Every Ubuntu size carries 4 GB of RAM per vCPU, which is the number that decides whether --jobs at the vCPU count is safe. A C++ or Rust link step can hold several gigabytes on its own, so a graph with many concurrent links will hit memory before it hits CPU. Cap the estimate explicitly rather than letting Bazel guess:
build:ci --local_ram_resources=HOST_RAM*0.7
build:ci --local_cpu_resources=HOST_CPUSJVM language rules add a second consumer. Persistent workers for Java, Kotlin, and Scala each hold a JVM heap for the life of the Bazel server, on top of the Bazel server's own heap. On a 16 vCPU runner with 64 GB, four workers at 8 GB each plus a 8 GB server heap leaves roughly 24 GB for actions, which is enough for --jobs=16.
build:ci --worker_max_instances=4
startup --host_jvm_args=-Xmx8gA Gradle build in the same repository has the same daemon and heap question with different flag names; running Gradle builds on GitHub Actions covers that side.
Storage is the other constraint. Ubuntu runners carry a 150GB SSD. A snapshot-backed runner accumulates disk cache entries across every run that boots from the alias, and the disk cache has no automatic bound in older Bazel releases. The find prune in the workflow above keeps it stable; recent Bazel releases also expose --experimental_disk_cache_gc_max_size if you prefer Bazel to manage it.
Concurrency sizing is the easy part. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. A monorepo that fans out to forty target-shard jobs on a busy afternoon runs them together instead of queueing behind itself.
Bottlenecks
Cache misses that should have been hits. Run the same commit twice on two different runners and compare the process summary line. If the second run shows local executions where you expected remote hits, the action keys disagree. The usual causes are an unpinned PATH (fixed by --incompatible_strict_action_env), an --action_env entry carrying a timestamp or a commit SHA, a toolchain resolved from the host instead of a registered hermetic toolchain, and a mismatch of execution platform between an x64 runner and an ARM64 runner. Cache entries do not cross architectures, so a warp-ubuntu-latest-arm64-16x job and a warp-ubuntu-latest-x64-16x job maintain separate key spaces by design.
Analysis time on a cold runner. This is the cost the remote cache cannot touch. Read it directly with --profile and bazel analyze-profile, which reports the loading and analysis phases separately from execution. If those phases add up to more than a minute, a snapshot alias is the fix.
Snapshot boot overhead. Boot times for snapshot runners can be slower than the default runners and take 45 to 60 seconds. Snapshot restore is billed at $0.04 per job. Both are fixed costs you pay before Bazel starts, so the snapshot only pays off when it removes more work than that. On a 16x runner at $0.032 per minute, the $0.04 restore fee equals 75 seconds of runner time; on an 8x at $0.016 per minute it equals 150 seconds; on a 32x at $0.064 per minute it equals about 38 seconds. Compare that number against the loading plus analysis plus fetch time from your profile before enabling the alias everywhere.
Repository fetch churn. A workspace that resolves external dependencies over the network every run will show a long fetch phase even with a warm remote cache. Pin --repository_cache so the archives survive, and pin every http_archive with a sha256 so the cache can actually key on them. An unpinned archive is refetched every time.
Remote cache throughput. Large outputs move slowly over a long link. --remote_cache_compression and --remote_download_outputs=toplevel both cut bytes on the wire. Placing the runner near the cache cuts latency, which matters more than bandwidth for a graph with tens of thousands of small actions. BYOC runs on AWS, GCP, and Azure if the cache lives in your own account.
Blind spots during the build. CI observability reports system metrics from the runner agent correlated with the job logs, which tells you whether a 20 minute build was CPU bound, memory bound, or waiting on the network. That distinction decides whether the answer is a bigger label or a lower --jobs. When a cache miss only reproduces on the runner, the Action Debugger pauses the workflow and opens an SSH session on the machine so you can run bazel aquery against the real execution root.
Proof
Bazel reports its own cache behavior, so verification takes one line of output rather than a stopwatch. Every build ends with a process summary:
INFO: 4128 processes: 3611 remote cache hit, 214 internal, 303 linux-sandbox.The ratio of remote cache hits to executed processes is the number to track over time. Push it into a metric and watch it after every toolchain bump, because a toolchain change that breaks hermeticity shows up here days before anyone notices the build got slower. Force a full rebuild with --noremote_accept_cached when you want to confirm that uploads are landing, and read the loading and analysis phases from bazel analyze-profile to see what the snapshot is saving you.
A worked monthly cost model
Take a Bazel monorepo running 1,200 GitHub Actions builds per month across pull requests and main, at a median of 11 minutes of wall clock each on 16 vCPU. That is 13,200 runner minutes.
| Line item | Rate | Quantity | Monthly USD |
|---|---|---|---|
warp-ubuntu-latest-x64-16x minutes | $0.032 per minute | 13,200 minutes | $422.40 |
| Snapshot restore | $0.04 per job | 1,200 jobs | $48.00 |
| Snapshot storage | $0.025 per snapshot-hour | 2 aliases for 730 hours | $36.50 |
| Cache storage | $0.20 per GB-month | 30 GB | $6.00 |
| Cache write and restore operations | $0.0001 per operation | 2,400 operations | $0.24 |
| Total | $513.14 |
The same 13,200 minutes on a GitHub-hosted 16-core Linux x64 larger runner costs 13,200 multiplied by $0.042, which is $554.40, and GitHub-hosted runners have no snapshot equivalent to fold in. GitHub list prices for Linux x64 larger runners were checked on 2026-08-13 at github.com/pricing and in the GitHub Actions billing documentation. The full size ladder is broken down in what larger GitHub Actions runners cost.
| vCPU | WarpBuild label | WarpBuild USD per minute | GitHub-hosted Linux x64 USD per minute | Difference per minute | Lower list price |
|---|---|---|---|---|---|
| 8 | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 | $0.006 | 27 percent |
| 16 | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 | $0.010 | 24 percent |
| 32 | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 | $0.018 | 22 percent |
Those rows hold minutes constant on both sides.
The snapshot line items pay for themselves through shorter jobs. If your profile shows 90 seconds of loading, analysis, and repository fetch on a cold runner, and the snapshot removes it, 1,200 jobs times 1.5 minutes is 1,800 minutes, which at $0.032 per minute is $57.60 of runner time against $84.50 of snapshot restore and storage. Move the workspace to a 32x label, where minutes cost more and the fixed restore fee stays flat, and the same 1,800 minutes are worth $115.20 against the same $84.50. Sizing and snapshot economics move together, which is why the sizing table above is worth revisiting after any large change to the action graph.
See the full per-minute rate list on the pricing page.
FAQ
Do I still need a remote cache if I use snapshot runners?
Yes. The remote cache shares action results across every runner, every branch, and every developer machine, so a job that never ran before can still skip execution. A snapshot carries one runner's local disk forward, which removes analysis and repository fetch work but gives you nothing another machine already computed. Run both.
Which Bazel directories survive a snapshot?
Everything on disk except /tmp, which is cleaned on reboot and a snapshot boot is a reboot. Pin the output base with the startup flag --output_user_root and the repository cache with --repository_cache to paths under the runner home so both land inside the snapshot.
Can I use snapshot runners for macOS Bazel builds?
No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally without snapshot behavior, so rely on the remote cache and a pinned repository cache there.
What --jobs value should I use on a 16 vCPU runner?
Start at --jobs=16 so every compile action gets a full core. Once the remote cache is warm and most actions resolve to downloads instead of compiles, raise it to 24 or 32 to keep the network busy while the CPUs wait.
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.