Bazel Remote Execution from GitHub Actions
Point Bazel at a remote execution endpoint from GitHub Actions, keep loading and analysis on the runner, and size the runner for the work that remains.
Last verified:
Bazel remote execution from GitHub Actions means pointing the build at a Remote Execution API endpoint with --remote_executor, so a pool of workers runs the compile, link, and test actions while the runner keeps loading, analysis, action key computation, input upload, and output fetch. The runner size then follows that residual work rather than peak --jobs parallelism, which usually means a warp-ubuntu-latest-x64-4x label at $0.008 per minute instead of a 16 vCPU one.
This guide covers how remote execution differs from remote caching in terms of what leaves the runner, the flags and platform properties that make the switch work, the workflow and rc file, and a cost model comparing a cache-only job on a large runner against a remote execution job on a small one.
Diagnosis
Both mechanisms speak the same Remote Execution API and both are configured with a grpcs:// endpoint, so teams often assume that turning on remote caching already covers execution. It does not. The two move different phases of the build off the machine.
| Phase of a Bazel build | Remote cache only | Remote execution |
|---|---|---|
| Loading and analysis | Runner | Runner |
| Action key computation | Runner | Runner |
| Input upload | On writes after a miss | For every input blob the workers lack |
| Action execution: compile, link, test | Runner | Worker pool |
| Output storage | Runner disk plus a cache upload | Worker CAS, fetched on demand |
| Peak CPU demand | Runner, sized to --jobs | Worker pool |
A remote cache, set up for Bazel with --remote_cache, answers one question: has anyone produced this exact action key before. When the answer is yes the action runs nowhere, which is why cache hit rates dominate warm pull request runs. When the answer is no, a cache-only setup executes the action on the GitHub Actions VM, so the worst case on a cache-only build stays a full local build. That worst case arrives on schedule: a dependency bump, a toolchain change, a compiler flag change, or an edit to a widely included header invalidates most of the task graph at once.
Bazel reports the split at the end of every invocation, and that line is the diagnosis:
INFO: 9,041 processes: 7,900 remote cache hit, 1,105 linux-sandbox, 36 internal.The second bucket is the one to read. A large linux-sandbox, local, or worker count on your slowest jobs means execution is happening on the runner, and no cache key tuning changes that number. For per-action detail, add --execution_log_json_file to the invocation and count actions by strategy and duration; recent Bazel releases also offer --execution_log_compact_file for the same data in a smaller format.
The second symptom is a runner sized for a phase that occupies part of the job. A 16 vCPU label with --jobs=16 is saturated during execution and close to idle during loading, analysis, and output fetch. WarpBuild CI observability shows that shape per step, which is the fastest way to confirm how many minutes of a 16 vCPU label are actually spent compiling.
Two failure modes decide whether remote execution helps before you configure anything. The first is platform mismatch. An action key includes the execution platform properties, so if the properties the workflow sets differ from what the workers advertise, actions either fail to schedule or compute distinct keys and re-execute on every run. The second is input transfer. A job whose inputs are large and whose content addressable store is cold or far away spends its minutes uploading blobs, and a runner-local disk cache is the better first move there. Measure the upload seconds from the execution log before switching a workflow over.
Fix
Set the executor, then reshape three defaults that assume local execution. The remote build execution documentation covers the endpoint side; the parts that matter on a GitHub Actions runner are below.
--jobs changes meaning. Under remote execution it bounds in-flight remote actions rather than local processes, so a value tied to the runner core count starves the pool. Values between 50 and 200 are normal on a 4 vCPU runner.
Output downloads have to be capped. Without a policy the runner pulls every intermediate output back over the network and the transfer replaces the compile time you removed. Set --remote_download_outputs=toplevel so the runner fetches the outputs of requested targets only, and keep it explicit so older Bazel versions and local runs agree with GitHub Actions.
Execution platform properties need one owner. Either set them on a platform target with exec_properties or pass --remote_default_exec_properties, and pick one: the flag applies only when the execution platform carries no properties of its own, so a platform target silently wins and the flag becomes dead configuration.
Actions that cannot leave the machine stay put. Targets tagged no-remote, local, or requires-network run on the runner under any configuration, and a --strategy override per mnemonic keeps a test runner local without disabling remote execution for compiles. Count those actions before sizing, because they are the residual CPU load.
Reachability is the last piece. An endpoint on a private network needs the runner inside that network: the WarpBuild networking addon joins the runner to your Tailscale tailnet through the network.name=<config> dynamic label, and the addon is listed at $0 per minute on the pricing page. A public endpoint needs only an authorization header from a repository secret.
One platform boundary is worth stating early. A Linux worker pool cannot execute Apple toolchain actions, so the iOS half of a Bazel workspace stays on a macOS runner with a remote cache while the server half dispatches to the pool.
Size the runner for the residual work
After the switch, the runner does checkout, Bazel startup, loading and analysis of the target pattern, action key computation with its cache lookups, input upload for missing blobs, output fetch for top level results, and any locally tagged actions. That profile is network bound and moderately RAM bound, with one or two cores busy during analysis.
| Label | Shape | Per minute | Fits |
|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | Small workspaces where every action is remote |
warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | Default under remote execution |
warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | Large analysis phase, or tagged local tests |
warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | Keep only while execution stays on the runner |
Rates are from the pricing page and the cloud runners documentation, checked on 2026-08-13. The same sizing logic applies to every tool in the monorepo guide that supports a worker pool.
Configuration
Keep the endpoint configuration in a checked-in rc file under a ci config so a developer can reproduce an action key locally.
common --enable_bzlmod
build --incompatible_strict_action_env
build --remote_local_fallback=false
build:ci --remote_executor=grpcs://rbe.internal.example.com:443
build:ci --remote_instance_name=projects/build/instances/default
build:ci --remote_timeout=3600
build:ci --jobs=120
build:ci --remote_download_outputs=toplevel
build:ci --experimental_remote_cache_compression
build:ci --extra_execution_platforms=//platforms:rbe_linux_x64
build:ci --host_platform=//platforms:rbe_linux_x64
build:ci --execution_log_json_file=/home/runner/bazel-exec.jsonThe platform target carries the properties the scheduler matches against, pinned by digest so a worker image rebuild does not change action keys by surprise:
platform(
name = "rbe_linux_x64",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
exec_properties = {
"OSFamily": "Linux",
"container-image": "docker://ghcr.io/example/bazel-worker@sha256:3f9c1d2b",
},
)The workflow itself gets smaller as the work leaves the runner:
name: bazel
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: warp-ubuntu-latest-x64-4x;network.name=rbe-tailnet
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Build and test
env:
RBE_TOKEN: ${{ secrets.RBE_TOKEN }}
run: |
bazel test --config=ci \
--remote_header=Authorization="Bearer ${RBE_TOKEN}" \
//...
- name: Upload execution log
if: always()
uses: actions/upload-artifact@v4
with:
name: bazel-execution-log
path: /home/runner/bazel-exec.json
retention-days: 7Three details in that file carry the behavior. The runs-on label is a 4 vCPU size with the networking addon attached, so the runner reaches a private endpoint without one being exposed publicly. --jobs=120 comes from the rc file rather than the core count. And the execution log is uploaded on every run, including failures, so the strategy split is available when a job gets slower next month.
Lockfile keyed downloads still belong in a cache action rather than in the worker pool. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 and is documented on the caching page; use it for the repository cache directory so an http_archive fetch does not run on every job.
Cost or Time Model
Assumptions, stated so you can substitute your own step timings:
- A Bazel monorepo with 9,000 actions in a full build of
//..., of which 8,600 are compile or test actions eligible for the worker pool. - 700 pull request runs per month, 15 percent of them on a cold graph after a dependency or toolchain bump.
- Runner rates from the pricing page:
warp-ubuntu-latest-x64-16xat $0.032 per minute,warp-ubuntu-latest-x64-4xat $0.008 per minute. - Worker pool compute is billed by whoever operates the pool and sits outside this model, which prices the GitHub Actions runner side only.
| Stage | Cache only, 16 vCPU | Remote execution, 4 vCPU |
|---|---|---|
| Boot and checkout | 0.70 min | 0.70 min |
| Bazel startup, loading, analysis | 1.60 min | 1.90 min |
| Action key lookups and input upload | 0.50 min | 1.30 min |
| Execution, warm run at 600 actions | 3.20 min | 1.10 min |
| Output fetch and artifact upload | 0.60 min | 0.80 min |
| Total, warm run | 6.60 min | 5.80 min |
| Execution, cold run at 8,600 actions | 31.00 min | 4.60 min |
| Total, cold run | 34.40 min | 9.30 min |
Analysis takes longer on the smaller runner, and input upload roughly doubles, because both are real costs of the switch. The cold run is where the shape changes: 34.40 minutes becomes 9.30, and that is the run a developer waits on after a dependency bump.
Monthly, at 595 warm runs and 105 cold runs:
| Line | Cache only, 16 vCPU | Remote execution, 4 vCPU |
|---|---|---|
| Warm run minutes | 3,927 | 3,451 |
| Cold run minutes | 3,612 | 977 |
| Total minutes | 7,539 | 4,428 |
| Rate per minute | $0.032 | $0.008 |
| Monthly runner cost | $241.25 | $35.42 |
The cold run share drives that gap. Halve it to 5 percent and the cache-only path falls to 5,593 minutes at $178.98 while the remote execution path falls to 4,183 minutes at $33.46, so the ordering holds and the distance narrows. Repositories that rarely invalidate the graph should reach for a remote cache and a warm disk cache first.
Against GitHub-hosted list prices
The cache-only column is the one that maps to a GitHub-hosted larger runner, since the comparison there is a runner size rather than a mechanism. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13, from the GitHub Actions billing reference. The same 7,539 cache-only minutes cost $316.64 at the GitHub-hosted rate, against $241.25 on the WarpBuild label and $35.42 once the actions move to a worker pool and the label drops to 4 vCPU.
Every number above carries a source and a checked-on date, and the step timings are assumptions you should replace with your own execution log before committing to a size.
FAQ
What is the difference between Bazel remote caching and remote execution?
A remote cache moves stored results off the runner: on a hit the action never runs anywhere. Remote execution moves the work itself off the runner: on a miss the action runs on a worker in the pool instead of on the GitHub Actions VM. Loading, analysis, and action key computation stay on the runner under both.
Do I still need a remote cache when remote execution is on?
Yes, and with --remote_executor set you already have one, because the same endpoint serves the action cache unless --remote_cache points somewhere else. A cache hit costs one lookup and skips execution entirely, so the cache is the cheaper of the two paths and remote execution is the fallback for everything it misses.
What runner size fits a job that dispatches its actions to a worker pool?
Start at warp-ubuntu-latest-x64-4x, 4 vCPU and 16 GB at $0.008 per minute. The residual work is checkout, Bazel startup, loading and analysis, action key computation, input upload, and output fetch, which is network heavy rather than core heavy. Move to warp-ubuntu-latest-x64-8x when analysis of a large graph is the long pole or when tagged local actions still run on the runner.
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.