Skip to content
compiler.dev

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:

WhereSurvives runsShared across runnersNotes
Runner diskNoNoFresh VM each job
actions/cache of the disk cacheYesYes, with scope limitsLimited by 10 GB and branch scope
Remote cache serverYesYesThe 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:

  1. Matrix over target groups. Query test targets, split the list, run each slice in its own job. All jobs share the remote cache.
  2. 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

  1. 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.
  2. Everyone writes the cache. One bad entry spreads. Restrict writes.
  3. Shallow clone with target determination. The base commit is missing.
  4. Unbounded disk cache. It will push out your other caches.
  5. Different toolchains per runner. Pin Bazel with .bazelversion and toolchains in MODULE.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.

Run this monorepo build on compiler.dev 200 free minutes

Start free