Fastest CI for Bazel Monorepos on GitHub Actions
Last updated
Bazel is already incremental, so fast CI is mostly about two things: a shared remote cache, so a fresh runner does not rebuild what another run built, and, for large repos, running only the targets a change can affect. Bazel's cache is correct by design, which makes it safe to share. The work is wiring it into GitHub Actions.
Why CI is slow even with Bazel
A fresh GitHub Actions runner has an empty output base and an empty disk cache. Bazel then rebuilds every target the first time. Incrementality only helps if state survives between runs. You have three places to keep it:
| Where | Survives runs | Shared across runners | Notes |
|---|---|---|---|
| Runner disk | No | No | Fresh VM each job |
actions/cache of the disk cache | Yes | Yes, with scope limits | Limited by 10 GB and branch scope |
| Remote cache server | Yes | Yes | The right answer at scale |
Remote cache
The Bazel remote caching docs (checked October 2026) use --remote_cache to set the endpoint (HTTP, HTTPS, gRPC or gRPCS) and describe credentials in the URL for simple setups, which is only safe over HTTPS. Put flags in .bazelrc under a ci config so developers and CI share one definition:
# .bazelrc
build:ci --remote_cache=https://cache.example.com
build:ci --remote_upload_local_results=false
The docs describe --remote_upload_local_results=false as making a build read-only with respect to the remote cache, and they recommend that only the CI system has write access. Take that one step further: let only trusted builds on main write.
# passed from the workflow for main only
build:ci-write --config=ci
build:ci-write --remote_upload_local_results=true
Pull requests then read what main built and cannot poison it. Hosted and self-hosted cache servers exist; pick one that fits your security needs. I am not recommending a vendor here because the choice depends on your data location and scale.
Disk cache fallback
If you have no server yet, --disk_cache points Bazel at a local directory. Save it with actions/cache:
- uses: actions/cache@v6
with:
path: ~/.cache/bazel-disk
key: bazel-${{ runner.os }}-${{ hashFiles('MODULE.bazel', 'MODULE.bazel.lock', '.bazelversion') }}-${{ github.sha }}
restore-keys: |
bazel-${{ runner.os }}-${{ hashFiles('MODULE.bazel', 'MODULE.bazel.lock', '.bazelversion') }}-
- run: bazel build --disk_cache=~/.cache/bazel-disk //...
The disk cache grows without limit by default. The Bazel docs mention garbage-collection flags --experimental_disk_cache_gc_max_size and --experimental_disk_cache_gc_max_age starting in Bazel 7.4. Set one, or you will hit the repository's 10 GB cap and evictions; see cache limits.
Affected-only runs: target determination
For a big monorepo, bazel test //... on every PR wastes time even with caching, because analysis and cache lookups for thousands of targets add up. Target determination finds which targets changed between two commits.
The open-source Target Determinator (bazel-contrib/target-determinator, checked October 2026) takes a before-revision and prints affected targets to stdout, one per line, relative to the currently checked-out revision. It ships as release binaries. Its README also describes a driver binary that runs tests on the affected targets directly, and skips affected tests tagged manual by default.
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Find affected targets
run: |
BASE=$(git merge-base origin/${{ github.base_ref }} HEAD)
./tools/target-determinator -bazel bazel "$BASE" > affected.txt
- name: Test affected
run: |
if [ -s affected.txt ]; then
bazel test --config=ci --target_pattern_file=affected.txt
fi
Check the README for the exact command line of your downloaded version: its flag list (-bazel, -bazel-opts, -targets, -working-directory) and the positional before-revision argument are documented there. Pin the version you download and verify the checksum.
Because the tool compares two revisions, it needs the base commit present locally, so use fetch-depth: 0. Note that it can only be as precise as your build graph. A change to MODULE.bazel, .bazelrc or a toolchain legitimately affects everything.
Is affected-only worth it? With a warm remote cache, unaffected targets are cache hits and cost seconds, so for small and medium repos plain bazel test //... is simpler and just as fast. Add target determination when analysis time itself is the problem.
Sharding and parallelism
Bazel parallelises actions across cores with --jobs, which defaults to a count based on CPU. A bigger machine speeds up cold builds directly. For test sharding inside Bazel, use shard_count on test rules. To split across machines:
- Matrix over target groups. Query test targets, split the list, run each slice in its own job. All jobs share the remote cache.
- Remote execution. Bazel can execute actions on a remote cluster (
--remote_executor), which scales beyond one machine but needs infrastructure and hermetic builds.
strategy:
matrix:
slice: [0, 1, 2, 3]
steps:
- run: |
bazel query 'kind(".*_test", //...)' --output=label | sort > all.txt
split -n l/4 -d all.txt part-
bazel test --config=ci --target_pattern_file=part-0${{ matrix.slice }}
See test sharding in GitHub Actions for balancing slices by duration.
A complete workflow
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
bazel:
runs-on: [self-hosted, Linux, compiler-ci] # or ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Build and test (PR, read-only cache)
if: github.event_name == 'pull_request'
run: bazel test --config=ci //...
- name: Build and test (main, writes cache)
if: github.event_name == 'push'
run: bazel test --config=ci-write //...
Bazel is CPU and memory hungry. The runs-on line is the one compiler.dev-specific change: it runs the same workflow on an 8 vCPU, 16 GB machine at $0.012 per minute, with Bazel's jobs scaling to the cores. Larger sizes (16 and 32 vCPU) are available; see pricing and the runner size picker.
Common mistakes
- Non-hermetic actions. Actions that read the clock, the network or the host environment give different outputs per machine and defeat sharing. Fix the action, not the cache.
- Everyone writes the cache. One bad entry spreads. Restrict writes.
- Shallow clone with target determination. The base commit is missing.
- Unbounded disk cache. It will push out your other caches.
- Different toolchains per runner. Pin Bazel with
.bazelversionand toolchains inMODULE.bazel.
Compare with Nx and Turborepo if part of your repo is JavaScript.
FAQ
Do I need a remote cache for Bazel in CI?
Practically yes. Without one, every fresh runner rebuilds from scratch. A disk cache in actions/cache is a stopgap.
Is target determination required?
No. With a warm remote cache, //... is mostly cache hits. It pays off when analysis of a huge target graph is slow.
Should PRs upload to the remote cache?
Safer not to. Let trusted main builds write and PRs read.
How big should the CI machine be?
Bazel uses all cores it is given. More cores speed up cold builds nearly linearly until memory or I/O limit; measure on your repo.
Related
- Fastest CI for Nx Monorepos on GitHub ActionsMake Nx CI fast on GitHub Actions. nx affected with nx-set-shas, Nx remote cache, task distribution, fetch-depth and a complete workflow.
- Fastest CI for pnpm Workspaces on GitHub ActionsFast CI for pnpm workspaces on GitHub Actions. Changed-package filters, store caching, fetch-depth, matrix sharding and a full workflow you can copy.
- Fastest CI for Turborepo Monorepos on GitHub ActionsSpeed up Turborepo CI on GitHub Actions. --filter for changed packages, remote cache with TURBO_TOKEN, fetch-depth, sharding and a complete workflow.
- Speed Up Bazel in GitHub ActionsSpeed up Bazel in GitHub Actions with setup-bazel, a disk cache, repository cache, a remote cache, and the right --jobs setting. Working YAML.