Bazel CI Cache: Why GitHub Actions Cache Won’t Save You

2026/02/22

BUILDbazelcaching

Your Bazel monorepo’s --disk_cache is 200GB. GitHub Actions gives you a 10GB cache limit and charges you by the minute to compress and upload it. You do the math.

This post is about the operational problem that comes after you understand Bazel’s cache layers (if you need that primer, see the breakdown). You know what --disk_cache does. You know AC+CAS. The question is: how do you keep that cache alive across CI runs without burning 20 minutes on cache logistics before your build even starts?

The problem

Bazel’s disk cache is the thing that makes your second build fast. It maps action digests to outputs – same inputs, same command, skip the work. Locally, you set --disk_cache=/tmp/bazel-cache and forget about it.

In CI, every job starts from scratch. The runner is ephemeral. The cache directory is empty. Your 45-minute build runs in full every single time.

The obvious move is to persist the cache between runs. The non-obvious part is that every persistence strategy has a different cost profile, and the wrong choice is worse than no cache at all.

Strategy 1: actions/cache

GitHub Actions’ built-in actions/cache works like this:

  1. Restore: Download a tarball from GitHub’s blob storage, decompress it
  2. Build: Do your thing
  3. Save: Compress the cache directory, upload the tarball

Simple. Works great for node_modules (200MB) or Go’s module cache (maybe a gig). Falls apart completely for Bazel.

Why it doesn’t work

Constraint Impact
10GB cache size limit A real monorepo’s disk cache is 50-200GB+
Save time Compressing and uploading 10GB: 200-400s
Restore time Downloading and decompressing: 150-300s
Network bound GitHub’s cache CDN is fast, but physics is faster
Key strategy What do you key on? hashFiles('BUILD')? Everything changes constantly

Even if you could fit your cache under the limit, you’re spending 5-10 minutes on cache round-trip alone. For a build that takes 15 minutes with a warm cache, that’s a 30-50% overhead just on cache logistics.

And the key strategy problem is real. Bazel’s disk cache isn’t like a lockfile – there’s no single file that represents “the state of the cache.” You can key on the commit hash, but then every commit is a cache miss. You can key on a branch name, but then parallel jobs clobber each other. There’s no good answer here because actions/cache wasn’t designed for caches this large or this granular.

When it actually works

Small repos. If your disk cache stays under 2-3GB and your build is long enough that the cache round-trip overhead is worth it, go ahead. It’s zero-infra and works out of the box. Just don’t expect it to scale.

Strategy 2: self-hosted runner with persistent disk

If you own the runner, the disk persists between jobs. No upload, no download, no compression. The cache is just… there.

# .bazelrc
build --disk_cache=/opt/bazel-cache

That’s it. The directory survives between runs. Warm cache from the start.

The tradeoffs

Pros:

Cons:

# Prune cache entries older than 14 days
find /opt/bazel-cache -atime +14 -delete

When it works

Teams that already run self-hosted runners and have one or two primary repos building on them. If you’re managing your own infra anyway, adding a persistent disk is trivial. If you’re on GitHub-hosted runners, this isn’t an option.

Strategy 3: cloud disk (EBS/PVC)

This is the approach that actually scales. Instead of moving the cache to the runner, you move the runner to the cache.

Mount a cloud disk – EBS volume on AWS, Persistent Volume on Kubernetes – to the runner’s cache directory. The cache lives on the volume. The runner is ephemeral; the volume is not.

# K8s pod spec for a CI runner
volumes:
  - name: bazel-cache
    persistentVolumeClaim:
      claimName: bazel-disk-cache
containers:
  - name: runner
    volumeMounts:
      - name: bazel-cache
        mountPath: /opt/bazel-cache

Why this wins

The key insight is 用空间换时间 – trade space for time.

A 200GB EBS volume costs roughly $20/month (gp3). That volume holds your entire disk cache. When a CI job starts, the cache is already mounted and immediately usable. No download step, no decompression, no 5-minute warm-up. From the build’s perspective, the cache was always there.

Compare that to the actions/cache approach where you’re burning 5-10 minutes of compute time (and developer wait time) on every run just to shuttle bytes around. The cloud disk pays for itself in the first week.

Cache lifecycle

Concern Solution
Growth Same pruning cron as self-hosted, or --disk_cache_gc flags in newer Bazel versions
Corruption Snapshot the volume periodically; restore from snapshot if corrupted
Multi-runner One volume per runner, or use ReadWriteMany (RWX) volumes with caution
Cold start First run after volume creation is a full build. Every run after that benefits

Works especially well with

Strategy 4: remote cache server

Bazel natively supports remote caching via the --remote_cache flag. Point it at a gRPC or HTTP server, and Bazel uploads/downloads cached actions over the network.

build --remote_cache=grpc://cache.internal:9092

Options include bazel-remote, BuildBuddy, EngFlow, or a plain nginx serving files over HTTP.

Why it’s not enough on its own

Remote cache solves the sharing problem – every CI runner and developer can hit the same cache. But it has two structural limitations:

Network latency. Every cache lookup is a network round-trip. For a build with 10,000 actions, that’s 10,000 round-trips to check the cache plus transfers for each hit. In practice, the download overhead for large outputs (compiled libraries, test data) can dominate the build time you’re trying to save.

The link phase is local-only. Linker actions produce outputs that depend on the absolute paths and toolchain configuration of the machine they run on. These actions typically can’t use remote cache hits because the cached output was produced on a different machine with different paths. For C++/Go monorepos with significant link times, this means the most expensive actions are always re-executed regardless of cache state.

When to use it

Remote cache is great for sharing cache state across machines and developers. It complements a local disk cache – Bazel checks the local cache first (fast), then falls back to remote (slower but broader coverage). The ideal setup is both: cloud disk for the local fast path, remote cache for cross-runner sharing.

Comparison

Strategy Restore time Max size Infra cost Sharing Complexity
actions/cache 150-400s 10GB Free Per-repo Low
Self-hosted persistent 0s Disk size Runner cost Per-runner Low
Cloud disk (EBS/PVC) 0s Volume size ~$20/mo per volume Per-runner Medium
Remote cache server Network-dependent Server storage Server cost Cross-runner High

Bazel vs Go: why this is harder

Go’s build cache (GOCACHE) is simpler to persist in CI. It’s typically 1-5GB, and actions/cache handles it fine. But the simplicity comes with tradeoffs:

Aspect Bazel Go
Cache granularity Action-level (each compiler/linker invocation) Package-level
Input tracking Strict – every input and config is hashed File modification time + content
Dirty cache risk Very low – hermetic by design Possible with CGO_ENABLED, build tags, etc.
Cache size 50-200GB+ for a monorepo 1-5GB typically

Bazel’s strictness is why the cache is so large – it tracks everything, which means more unique cache entries. But it’s also why the cache is safe. A Bazel cache hit means the inputs genuinely haven’t changed. A Go cache hit means the files look the same, probably.

The CI YAML pattern

Whatever strategy you choose, the CI config follows a pattern. Here’s what the caches section looks like for a K8s-based runner with EBS:

caches:
  - backend: ebs
    key: bazel-disk-cache-${RUNNER_ID}
    size: 200Gi
    mountPath: /opt/bazel-cache

And in .bazelrc:

build --disk_cache=/opt/bazel-cache
build --remote_cache=grpc://cache.internal:9092  # optional, for sharing

The key determines which volume gets mounted. Key per runner avoids write contention. Key per branch gives you cache isolation but more cold starts. Key per repo is the simplest and usually sufficient.

The takeaway

Stop trying to upload 200GB tarballs to GitHub’s blob storage. Mount a $20 cloud disk and move on with your life.