Gradle Configuration Cache on GitHub Actions
Enable the Gradle configuration cache and the build cache on GitHub Actions, persist both directories across jobs, and key entries by Gradle version.
The Gradle configuration cache stores the task graph that the configuration phase produces and replays it on the next run, while the build cache stores the outputs of cacheable tasks keyed by a hash of their inputs. On GitHub Actions both are opt-in, and neither one survives the job unless the directory holding it is restored, because every job starts on a clean machine with an empty project checkout.
This guide separates configuration time from execution time, enables both caches, persists the two directory sets that hold them, and prices the result at real runner rates. It sits under the persistent caches on GitHub Actions hub, which covers the same problem for other ecosystems.
Diagnosis
Split the number before you change anything. Run the build with the profile report and read where the time actually goes:
./gradlew build --profileGradle writes an HTML report under build/reports/profile that separates configuration from task execution. A repository with 100 or more subprojects often spends 30 to 60 seconds in configuration alone, and that time is invisible in the GitHub Actions job log, which shows one duration for the whole step.
Next, read what Gradle says about the configuration cache on each run. With the feature on, a run that reuses an entry prints Reusing configuration cache. A run that cannot reuse one prints a line beginning Calculating task graph as no cached configuration is available for tasks: followed by the reason, such as a changed build script or a system property that was not present when the entry was written. That reason line is the diagnosis. Collect it from ten consecutive runs before deciding whether the problem is persistence or invalidation.
Two other suspects live nearby. The Gradle daemon starts cold on every job because the machine is new, which is a separate cost from configuration and is not fixed by any cache. Task execution that stays slow after a configuration cache hit points at the build cache instead.
Fix
The two caches remove different phases, and enabling one does nothing for the other.
| Configuration cache | Build cache | |
|---|---|---|
| What it stores | The serialized task graph and the configuration state needed to run it | The outputs of cacheable tasks, keyed by a hash of their inputs |
| Where it lives | .gradle/configuration-cache in the project directory | ~/.gradle/caches/build-cache-1, or a remote build cache node |
| Command line flag | --configuration-cache | --build-cache |
| Property | org.gradle.configuration-cache=true | org.gradle.caching=true |
| Invalidated by | Build script and buildSrc changes, environment variables, system properties, files read at configuration time, a different Gradle version | A change to any declared input of the task |
| Phase it removes | Configuration | Execution |
Both are documented in the Gradle user guide, on the configuration cache and build cache pages.
1. Turn both on in the repository, not in the workflow. Put them in gradle.properties so local builds and GitHub Actions runs behave the same way:
org.gradle.configuration-cache=true
org.gradle.caching=true2. Persist both directory sets. WarpBuilds/gradle-actions/setup-gradle@v5 restores Gradle User Home, which holds wrapper distributions, downloaded dependencies, and the local build cache. It does not carry the configuration cache, which lives inside the project directory, so that path needs its own entry.
3. Stop invalidating the entry. Anything read at configuration time becomes an input. A build script that reads System.getenv("GITHUB_RUN_NUMBER") at configuration time invalidates the entry on every single run. Read volatile values through Gradle's provider API so they are tracked at execution time, and keep timestamps and run identifiers out of configuration logic.
Configuration
The full workflow, with both caches persisted and the Gradle version in the key:
name: build
on:
pull_request:
push:
branches: [main]
jobs:
build:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
- name: Resolve Gradle version
id: gradle
run: |
version=$(sed -n 's/.*gradle-\(.*\)-\(bin\|all\)\.zip/\1/p' gradle/wrapper/gradle-wrapper.properties)
echo "version=$version" >> "$GITHUB_OUTPUT"
- uses: WarpBuilds/setup-java@v5
with:
distribution: temurin
java-version: "21"
- uses: WarpBuilds/gradle-actions/setup-gradle@v5
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Cache the configuration cache
uses: WarpBuilds/cache@v1
with:
path: .gradle/configuration-cache
key: gradle-cc-${{ steps.gradle.outputs.version }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle/libs.versions.toml', 'buildSrc/**') }}
restore-keys: |
gradle-cc-${{ steps.gradle.outputs.version }}-
- run: ./gradlew buildThree details in that key carry the whole design. The Gradle version comes first after the prefix because an entry written by one Gradle version is unusable by another, so a wrapper bump should start a fresh entry line rather than download an entry Gradle will reject. The hash covers build logic and version catalogs, which is the set that invalidates configuration, and leaves application source out, since a source change does not change the task graph. The restore-keys prefix keeps the same Gradle version, so a build script edit falls back to yesterday's entry for the unchanged parts.
cache-read-only on the setup action does the matching job for Gradle User Home: pull request builds restore the entry the default branch published and write nothing back, which keeps one authoritative entry instead of one per open branch.
Two constraints are worth stating before you copy this into a matrix. WarpBuild Cache is documented as unsupported on Windows runners, so a Gradle job that depends on these restores belongs on a Linux or macOS label. Cache entries are also scoped by a version hash over the compression tool and cached paths, so a macOS leg and a Linux leg keep separate entries by design. The caching documentation covers that scoping, and the setup actions documentation lists the cache inputs on each fork. General JVM heap and runner sizing that applies across Maven, Gradle, and sbt builds sits on the Java builds on GitHub Actions runners page.
One security note: a configuration cache entry contains values the build read at configuration time, so keep credentials out of configuration logic rather than storing them into an entry that is restored on every branch.
Cost or Time Model
Per-minute rates come from the pricing page, checked on 2026-08-13.
| Runner label | vCPU | RAM | Price per minute |
|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 |
Assumptions for the model, taken from a 120-subproject build. Substitute the numbers your profile report produced.
- 900 workflow runs per month on
warp-ubuntu-latest-x64-8x. - Configuration takes 0.75 minutes cold and 0.10 minutes when the entry is reused.
- Execution takes 6.50 minutes with no build cache and 2.00 minutes when task outputs are restored.
- Stored entries total 3 GB: Gradle User Home, the local build cache, and the configuration cache entry.
- Two restores per run, plus a save on the roughly 10 percent of runs that land on the default branch, so 1,980 cache operations per month.
| Phase | No caches | Build cache only | Both caches |
|---|---|---|---|
| Configuration | 0.75 min | 0.75 min | 0.10 min |
| Execution | 6.50 min | 2.00 min | 2.00 min |
| Total per run | 7.25 min | 2.75 min | 2.10 min |
| Monthly minutes | 6,525 | 2,475 | 1,890 |
| Monthly runner cost | $104.40 | $39.60 | $30.24 |
Cache fees land on top of the last column: 3 GB of storage at $0.20 per GB-month is $0.60, and 1,980 operations at $0.0001 each is $0.20, so the both-caches path bills $31.04 per month against $39.60 for the build cache alone. On BYOC runners, cache storage and operations are included.
Read the break-even the same way. That $0.80 monthly cache bill buys back 50 minutes at $0.016 per minute, which is 3.3 seconds per run across 900 runs. A configuration phase that clears more than 3.3 seconds per run pays for the whole cache line. The threshold falls on larger labels because a minute costs more there, and sizing runners for Java builds covers picking that label against heap and worker counts.
Once both caches are on, WarpBuild CI observability, alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger, is where you confirm the configuration minutes stayed gone across weeks rather than one lucky run. For the surrounding pipeline, see Gradle builds on WarpBuild runners.
FAQ
What is the difference between the Gradle configuration cache and the build cache?
The configuration cache stores the task graph produced by the configuration phase and replays it, so a hit removes configuration time. The build cache stores the outputs of cacheable tasks keyed by their inputs, so a hit removes execution time. They are separate features with separate flags, --configuration-cache and --build-cache, and separate directories.
Why does the configuration cache miss on every GitHub Actions run?
Two reasons cover most cases. The entry lives in .gradle/configuration-cache inside the project directory, which is not part of Gradle User Home and is discarded with the runner unless you cache that path yourself. The second reason is invalidation: a build script change, a new environment variable, a system property, or a different Gradle version all force Gradle to calculate the task graph again. The reason line Gradle prints names which one applies.
Should the cache key include the Gradle version?
Yes. A configuration cache entry is tied to the Gradle version that wrote it, so a wrapper bump makes every stored entry unusable. Parse the version out of gradle/wrapper/gradle-wrapper.properties and put it in the key, so a wrapper bump starts a new entry line instead of leaving a stale one to be downloaded and rejected.
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.