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:
- Restore: Download a tarball from GitHub’s blob storage, decompress it
- Build: Do your thing
- 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:
- Zero cache latency – disk is local
- No size limits – whatever the disk holds
- Dead simple configuration
Cons:
- Cache corruption is your problem now. A killed build can leave partial writes in the cache. Bazel is generally resilient to this, but “generally” isn’t “always.”
- Unbounded growth. Without pruning, the cache grows forever. Set up a cron job:
# Prune cache entries older than 14 days
find /opt/bazel-cache -atime +14 -delete
- Single-tenant by default. Multiple repos on one runner share a cache, which is usually fine (CAS is content-addressed). Multiple runners pointing at the same disk via NFS gets dicey – Bazel’s disk cache doesn’t handle concurrent writes gracefully.
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
- actions-runner-controller (ARC) on K8s – runners are pods, volumes are PVCs, this is the native pattern
- AWS CodeBuild with EBS – mount the volume in the buildspec
- Any K8s-based CI – Tekton, Argo Workflows, Jenkins on K8s
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.