Remote Build Cache
A remote build cache stores build outputs on a shared store, keyed by the inputs that produced them, so a target built on one machine is reused elsewhere.
A remote build cache is a shared store of build outputs, indexed by a key derived from the inputs that produced each output, which several machines read from and write to over the network. When a build computes a key that already exists in the store, it downloads the stored output instead of running that unit of work again.
The term belongs to build tools rather than to any one platform. Bazel, Gradle, Turborepo, Nx, and other tools implement the same idea with their own key derivation and their own transport, and the store behind them can be an object bucket, an HTTP endpoint, or a gRPC service.
Definition
A remote build cache has two halves, and both are needed before the word applies.
The first half is a key. The build tool splits the work into units, one compilation, one code generation step, one test task, then hashes everything that unit consumes into a single identifier. The second half is the entry that identifier points at: the files the unit produced, plus enough metadata to place them back on disk in the right locations.
A cache lookup therefore answers one question. Given these exact inputs, has any machine already produced the outputs? A hit means the unit does not run at all. Its outputs arrive from the network and the build carries on as if the work had happened locally.
The word remote separates this from a purely local cache. Both forms store the same thing, and most tools support both at once with the local disk sitting as a first tier in front of the network. The difference is who can read an entry. A local cache only helps a machine that has already done the work. A remote cache helps every machine that shares the endpoint, including a build server, a colleague's laptop, and a runner that was created a minute ago and has never seen the repository.
What goes into the key
Key derivation is where cache hit rates are won and lost, and each tool documents its own recipe.
Gradle computes a build cache key for a cacheable task from the task implementation class and its classpath, the values of the task's input properties, the contents of its input files, and the names of its output properties (Gradle build cache documentation).
Bazel computes an action key from the command line of the action, the digests of its input files, the environment variables the action is allowed to see, and the execution platform (Bazel remote caching documentation).
The two recipes differ in detail and agree on the principle. Everything that can change the output has to be in the key, and nothing that cannot change the output should be.
That second rule is the one teams break. Four inputs commonly leak into a key and turn every build into a miss:
- Absolute paths. A workspace checked out at one path on a laptop and another path on a runner produces different keys for identical source unless the tool normalizes paths.
- Timestamps and build numbers. A task that stamps the current time or the run number into a generated file has a different output on every run by construction, so nothing it produces is reusable.
- Environment variables. A tool that passes the caller's full environment into an action makes the key depend on
PATH, on shell configuration, and on any variable a runner image happens to set. - Toolchain versions. A different compiler patch release, JDK, or language runtime between the writer and the reader is a real input change, and a correct cache treats it as one.
The first three are configuration mistakes. The fourth is the cache doing its job, and the fix is pinning the toolchain so every machine agrees on which version is in use.
Reads, writes, and who is allowed to do each
Every remote build cache has two directions, and they are usually configured separately.
A read happens before a unit of work runs. The tool computes the key, asks the endpoint whether it holds an entry, and downloads it on a hit.
A write happens after a unit of work runs locally. The tool uploads the outputs under the key it computed, so that the next machine to compute that key gets a hit.
Splitting the two permissions is the standard safety measure. A build triggered by an unreviewed branch or a fork can be given read access and denied write access, which keeps an untrusted commit from placing content under a key that a later trusted build would download and treat as its own output. Bazel expresses the read-only mode as --noremote_upload_local_results, and Gradle as push = false on the remote cache block.
Two operational properties follow from the network hop. Entries are content addressed, so an entry can be verified against the digest it is stored under rather than trusted because of where it came from. And distance costs time, because a lookup that has to cross a region boundary pays that latency on every unit of work, which is why teams place the cache endpoint close to the machines that use it.
Remote build cache, dependency cache, and container layer cache
Three caches show up in the same pipeline and are often confused because all three make a repeated build do less work. They store different things and fail in different ways.
| Cache kind | What one entry holds | Key derived from | Written by |
|---|---|---|---|
| Remote build cache | Outputs of one unit of your own build: compiled classes, jars, generated sources, test results | Hash of that unit's inputs: source contents, flags, toolchain version, dependency digests | The build tool, during a build |
| Dependency cache | Third party packages fetched from a registry: npm, Maven, PyPI, Go modules, crates | A lockfile hash, or the package coordinate plus the published checksum | The package manager, or a cache step that wraps the package directory |
| Container layer cache | Filesystem layers produced by image build instructions | The parent layer digest plus the instruction and the digests of the files it copies in | The image builder, during an image build |
Three practical differences fall out of that table.
Invalidation blast radius differs. A changed source file invalidates one build cache entry and the entries downstream of it. A changed lockfile invalidates the whole dependency cache entry, because the key covers the entire resolved set. A changed instruction invalidates that image layer and every layer after it, because each layer is keyed on its parent.
Ownership differs. Build cache entries are outputs your repository produced, so they are only as trustworthy as the machine that wrote them. Dependency cache entries are reproducible from a registry and verifiable against a published checksum.
Placement differs. A dependency cache and a layer cache are both useful on a single machine that runs repeat builds. A build cache is worth the network hop mainly when several machines build the same targets, which is the normal situation in a monorepo where every pull request builds the parts of the graph nobody touched.
The three compose. A build with all three configured downloads its packages from the dependency cache, skips the compilation and test units that already have entries in the build cache, and reuses unchanged image layers when it builds a container at the end.
What a remote build cache leaves alone
A cache hit removes execution of a unit of work. Everything a build does outside those units still happens on every machine.
Loading and analysis still run. A build tool has to read the build files, resolve the dependency graph, and decide which units exist before it can compute a single key, and on a large monorepo that pass is real work that a remote cache does not shorten.
Fetching external repositories still happens unless a separate local cache holds them, because those archives are inputs rather than outputs.
Units that lack deterministic output stay uncacheable. A task that talks to a network service, reads the clock, or writes outside its declared output paths has no reliable key, and most tools provide a way to mark such a task as uncacheable so the build stops trying.
Downloads are not free. An entry whose outputs are hundreds of megabytes has to cross the network on a hit, so tools expose controls for which outputs are materialized on disk and which are left in the store until something needs them.
Example
The clearest demonstration is one repository built twice on two different machines, where the second machine has never seen the workspace.
A Gradle monorepo turns on the build cache in settings.gradle.kts and points the remote half at an HTTP endpoint, with writes limited to trusted builds:
buildCache {
local {
isEnabled = true
}
remote<HttpBuildCache> {
url = uri("https://build-cache.internal.example.com/cache/")
isPush = System.getenv("CACHE_PUSH") == "true"
credentials {
username = System.getenv("CACHE_USER")
password = System.getenv("CACHE_PASSWORD")
}
}
}The first build runs on a machine with an empty cache. Every cacheable task executes, and each one uploads its outputs under the key it computed. The console marks the executed tasks with no status suffix.
The second build runs on a different machine, at the same commit, with the same toolchain. Nothing in the source changed, so every key matches and the console reports where each output came from:
$ ./gradlew :services:api:test --build-cache
> Task :libs:common:compileJava FROM-CACHE
> Task :libs:common:jar FROM-CACHE
> Task :libs:protocol:compileJava FROM-CACHE
> Task :services:api:compileJava FROM-CACHE
> Task :services:api:test FROM-CACHE
BUILD SUCCESSFUL
19 actionable tasks: 2 executed, 17 from cacheFROM-CACHE is the token to read. The test task compiled nothing and executed no test methods. Gradle computed the key for :services:api:test, found an entry, and unpacked the recorded test results into the build directory.
The two tasks that executed are the ones whose keys did not match. That is the normal shape of a warm build: a small changed set runs, the unchanged remainder of the graph is downloaded.
Bazel reports the same event with a different vocabulary. Its summary line counts the processes that resolved to a cache entry instead of running:
$ bazel build //services/api:image --config=ci
INFO: Analyzed target //services/api:image (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
INFO: 412 processes: 389 remote cache hit, 23 internal.
INFO: Build completed successfully, 412 total actionsremote cache hit counts actions whose key was found on the endpoint. internal counts work Bazel did in process, such as writing symlinks, which never had a remote form. The line above describes a build where almost nothing was recompiled.
Wiring the endpoint into a GitHub Actions job
On GitHub Actions the cache configuration is a small part of the job. The runs-on label picks the machine, and the build tool reads its endpoint from configuration and secrets:
name: build
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Build and test
env:
CACHE_USER: ${{ secrets.BUILD_CACHE_USER }}
CACHE_PASSWORD: ${{ secrets.BUILD_CACHE_PASSWORD }}
CACHE_PUSH: ${{ github.ref == 'refs/heads/main' }}
run: ./gradlew build --build-cacheCACHE_PUSH is the read and write split from earlier expressed as one workflow expression. Builds on main upload their outputs. Pull request builds read entries and upload nothing.
Label shapes such as warp-ubuntu-latest-x64-8x encode an operating system, an architecture, and a machine size, and GitHub treats the whole string as one opaque routing key, so pointing this job at a different fleet is a change to the runs-on line and nothing else. The WarpBuild caching documentation covers the cache actions available to a job, and the remote Docker builders documentation covers the layer cache that sits behind image builds rather than build tool outputs.
One detail is worth checking before reading hit rates. A build tool's remote cache and a workflow level cache step are different mechanisms, and a job can use both: the cache step restores directories such as a package manager store, while the build tool talks to its own endpoint for task outputs. Counting a FROM-CACHE line as evidence that the workflow cache step worked is a common misreading.
Related Terms
- Docker layer cache, defined: what an image layer entry holds, how the key chains to the parent layer, and why one changed instruction invalidates everything after it.
- Artifact cache, defined: how a workflow stores and restores directories between jobs and runs, and how that differs from a build tool's own cache.
- Running Bazel on GitHub Actions with a remote cache: the rc file, the flags, and how the remote cache overlaps with runner local state.
- Running Gradle builds on GitHub Actions: the Gradle User Home, the build cache, and the configuration cache in one workflow.
- Nx builds on GitHub Actions: task graph caching in an Nx monorepo and how affected project selection interacts with it.
- What is a remote Docker builder?: a persistent machine that runs image builds and keeps its layer cache between jobs.
- WarpBuild caching documentation: the cache actions available inside a GitHub Actions job and how their keys work.
- WarpBuild pricing: per minute rates by runner type and cache storage rates.
FAQ
What is the difference between a remote build cache and a dependency cache?
A dependency cache holds third party packages downloaded from a registry, keyed by a lockfile hash or by the coordinate and checksum of each artifact. A remote build cache holds the outputs your own build produced, keyed by a hash of the inputs to each task or action. One removes downloads from a registry, the other removes compilation and test execution.
Does a remote build cache work across branches and machines?
Yes, that is the property the word remote carries. The key is derived from the inputs of a unit of work rather than from a branch name or a machine identity, so a branch that touches one package hits the cache for every package it did not touch, and a laptop can reuse an entry a build server wrote.
Why does my remote build cache miss on every run?
Almost always because an input that has nothing to do with the source is part of the key. The usual culprits are absolute paths that differ between machines, a timestamp or build number injected into the task, an environment variable such as PATH leaking into the action environment, and a toolchain version that differs between the writer and the reader.
Should pull request builds write to the remote build cache?
Most teams let pull requests read the cache and block them from writing to it, so an unreviewed branch cannot place an output under a key that a later trusted build would download. Bazel spells this as --noremote_upload_local_results and Gradle as push = false on the remote cache block.
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.