Designing a Gradle Build Cache for CI: Requirements, Boundaries, and Failure Modes
An architecture note on Gradle's build cache for CI: the minimal CI-only-push design, trust boundaries around cached outputs, how to verify FROM-CACHE hits, and the failure modes that matter.
21 Nov 2025, 13:08 UTC

The problem: CI agents rebuild the same outputs every run
Ephemeral CI runners start with an empty workspace, so every build recompiles sources and reruns tests that a teammate's machine produced an hour ago. Gradle's build cache addresses this by storing task outputs keyed by a hash of their inputs — source files, compiler settings, dependency versions — and restoring them instead of re-executing the task. This note covers the smallest design that works for a team CI setup, where the trust boundaries sit, and what would make the design wrong.
Requirements
- Cache hits must be correct: a hit must produce byte-identical (or behaviorally identical) output to a real execution.
- CI agents must be able to push and pull; developers should pull but pushing from laptops is optional and riskier.
- The cache backend must be reachable with low latency from CI agents; a slow cache can cost more time than it saves on small tasks.
- Only deterministic tasks participate. Anything embedding timestamps, absolute paths, or random IDs in outputs is a poison source.
The smallest suitable design
Start with the built-in HTTP remote cache, one shared node, CI-only push. In settings.gradle (Groovy syntax shown; Kotlin DSL is analogous):
buildCache {
local {
enabled = true
}
remote(HttpBuildCache) {
url = 'https://cache.example.com/cache/'
push = System.getenv('CI') == 'true'
credentials {
username = System.getenv('CACHE_USER')
password = System.getenv('CACHE_PASSWORD')
}
}
}Key decisions embedded here:
- Push only from CI. The
pushflag keyed on theCIenvironment variable means developer machines read the cache but never write to it. This shrinks the set of machines that can poison the shared cache to your controlled CI images. - Local cache stays on. It is free and accelerates branch switching on developer machines independently of the remote.
- Credentials from the environment, not the file. The settings file is committed; secrets are not.
Enable the cache with org.gradle.caching=true in gradle.properties, or per-invocation with --build-cache. Run this from the project root; no special permissions are needed beyond normal build rights, but the CI service account needs network access to the cache host and valid credentials.
Trust and data boundaries
The remote cache is a trusted artifact store, not a neutral blob service. Anything pulled from it ends up on your compile classpath or test runtime. Treat it accordingly:
- Writes are privileged. A poisoned entry (a task whose declared inputs do not fully capture what affects its output) propagates wrong outputs to every consumer silently. This is why push is CI-only and why task input declarations deserve review.
- Traffic should be TLS-protected and authenticated. The HTTP cache supports basic auth as shown; put it behind your existing identity proxy if you have one.
- Cached outputs may contain secrets-adjacent material. Test reports can include environment dumps. If that matters, restrict read access the same way you restrict CI logs.
Which tasks cache, and which never will
Built-in tasks like JavaCompile, Test, and Javadoc are cacheable out of the box on recent Gradle versions (this note assumes Gradle 7.x/8.x; verify against your version's release notes, since cacheability defaults have changed over time). Custom tasks need the @CacheableTask annotation plus correctly declared @Input/@Output properties. A task that reads a file Gradle does not know about will produce cache hits that ignore that file — the classic poisoning scenario.
Tasks that should stay non-cacheable: anything publishing artifacts, anything touching external systems, and anything whose output embeds absolute paths or timestamps until you fix that determinism issue first.
Operational checks
- Confirm hits: run
./gradlew clean build --infotwice on a CI agent (or on one machine simulating two agents by clearing only the build directory, not~/.gradle). Look for tasks markedFROM-CACHErather thanUP-TO-DATE— the latter is the local incremental check, not the cache. - Measure the benefit: compare a clean CI run with
--build-cacheagainst one with--no-build-cache. If the difference is under ~10%, investigate hit rates before investing further in cache infrastructure. - Watch the build scan or logs for misses on unchanged code. A task that never hits usually has an unstable input: a timestamp, a Git commit hash baked into a resource, or a file whose ordering varies between machines.
Failure modes
- Cache poisoning: under-declared inputs cause a hit where the output should have differed. Symptom: tests pass on CI but the artifact is stale or wrong for a configuration. Mitigation: review custom task input annotations; when in doubt, drop
@CacheableTask. - Latency inversion: for tasks that execute in under a second, downloading the output over a slow link costs more than running it. Co-locate the cache with CI agents (same region or network segment).
- Unbounded growth: the built-in HTTP cache does not evict on its own. Plan a retention policy (delete entries older than N days) or use a backend with lifecycle management.
- Silent disablement: if credentials expire, Gradle logs warnings and falls back to executing tasks — builds get slower, not broken. Alert on hit-rate drops, not just errors.
What would change this design
- Multiple CI fleets or high volume: move to the gRPC-based cache node or a commercial backend with replication and access control, rather than a single HTTP endpoint.
- Regulated environment: if cached outputs count as build artifacts under your compliance rules, you need signed provenance and audit logging — beyond what the built-in cache provides.
- Heavy cross-platform builds: path normalization and relocatability become the dominant concern; expect to audit tasks for absolute-path inputs before any hit rate is achievable.
Verify any version-specific behavior — annotation names, default cacheability, configuration cache interaction — against the Gradle user manual for the version you run before rolling this out.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.