Gradle Build Caches on GitHub Actions Runners
Gradle build caching on GitHub Actions: configure a remote build cache, restore the Gradle User Home, and size warp- runners so cached tasks stay warm.
Last verified:
The Gradle build cache turns a build into a lookup: Gradle hashes each task's inputs, and when a matching output already exists in the local or the remote cache it copies that output in instead of running the task. On GitHub Actions the local cache dies with the runner, so a working setup pairs a remote build cache with a restored Gradle User Home, and WarpBuild runners give that restore a fast disk, a large memory ceiling, and an optional snapshot that brings back the whole Gradle User Home between runs.
This page covers the Gradle side (the buildCache block, org.gradle.caching, the configuration cache) and the runner side (which warp- label to pick, how to size --parallel and --max-workers, what a cached Gradle job costs per month).
Overview
Gradle keeps five separate pieces of state on a build machine, and teams lose time by treating them as one cache.
| Layer | Location on the runner | What it stores | What invalidates it |
|---|---|---|---|
| Dependency cache | ~/.gradle/caches/modules-2 | Artifacts resolved from Maven Central and other repositories | Version bumps, lockfile changes |
| Wrapper distributions | ~/.gradle/wrapper/dists | The Gradle distribution the wrapper pins | gradle-wrapper.properties changes |
| Local build cache | ~/.gradle/caches/build-cache-1 | Task outputs keyed by an input hash | Task input changes, removeUnusedEntriesAfterDays |
| Remote build cache | An HTTP endpoint you run | The same task outputs, shared across machines | Task input changes, server retention policy |
| Configuration cache | PROJECT/.gradle/configuration-cache | The serialized task graph | Build logic edits, system properties and environment variables read at configuration time |
The remote build cache is the only layer that crosses machines by itself. Everything else is runner-local, which is why a workflow that turns on org.gradle.caching=true and stops there still pays for a full dependency download on every job.
The build cache also has a stricter contract than a dependency cache. A task participates only when it declares its inputs and outputs and is marked cacheable, and an entry is reused only when the input hash matches exactly. Absolute paths, timestamps written into jars, and environment variables read inside a task all change that hash, so a build can be fully configured for caching and still miss on every run.
WarpBuild fits under this as a runner fleet you select with a label. Gradle work belongs on the Linux runners, where WarpBuild cache is enabled by default and where snapshot runners are supported. Two of those, snapshot runners and CI observability, do real work for a Gradle build.
Read the caching behavior in the WarpBuild caching documentation before wiring keys by hand, because the WarpBuilds/* setup actions already handle most of it. Maven repositories and general JVM runner setup are covered on the Java builds on GitHub Actions runners page; everything below stays on the Gradle build cache.
Configuration
Start in settings.gradle, where the buildCache block controls both layers. This version keeps the local cache at its default location inside the Gradle User Home so a single restore covers dependencies and task outputs together, and it writes to the remote cache only from the default branch.
// settings.gradle
def isCi = System.getenv("CI") == "true"
def onMain = System.getenv("GITHUB_REF") == "refs/heads/main"
buildCache {
local {
enabled = true
removeUnusedEntriesAfterDays = 7
}
remote(HttpBuildCache) {
url = "https://gradle-cache.internal.example.com/cache/"
push = isCi && onMain
useExpectContinue = true
credentials {
username = System.getenv("GRADLE_CACHE_USER")
password = System.getenv("GRADLE_CACHE_PASSWORD")
}
}
}In the Kotlin DSL the same properties are isEnabled and isPush, and the remote block is written remote<HttpBuildCache>. Gating push on the default branch keeps task outputs produced by unreviewed pull request code out of the cache every other build reads from.
Turn the features on in gradle.properties so local runs and GitHub Actions runs behave the same way.
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.workers.max=6
org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
kotlin.daemon.jvmargs=-Xmx4gThe workflow below runs on an 8 vCPU WarpBuild runner and restores ~/.gradle explicitly. Use this shape when you want to see and control the cache key.
name: build
on:
pull_request:
push:
branches: [main]
jobs:
gradle:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v5
- uses: WarpBuilds/setup-java@v5
with:
distribution: temurin
java-version: "21"
- name: Restore Gradle User Home
uses: WarpBuilds/cache@v1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle/libs.versions.toml', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Build
run: ./gradlew build --build-cache --parallel --max-workers=6
env:
GRADLE_CACHE_USER: ${{ secrets.GRADLE_CACHE_USER }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}WarpBuilds/cache@v1 is compatible with actions/cache@v4 and takes the same path, key, and restore-keys inputs, so an existing Gradle cache step converts by changing one line. The restore-keys prefix matters more for Gradle than for most ecosystems: a dependency bump changes the hash, and without a prefix fallback the runner downloads every artifact again rather than the handful that moved.
For a build with a large Gradle User Home, hand the whole job to the maintained Gradle action instead. WarpBuilds/gradle-actions/setup-gradle@v5 caches the Gradle User Home including wrapper distributions, downloaded dependencies, and build caches, and it takes cache-read-only, cache-write-only, cache-disabled, and the gradle-home-cache-includes and gradle-home-cache-excludes filters. Setting cache-read-only on non-default branches keeps one canonical cache entry per branch line instead of one per pull request.
jobs:
gradle:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-16x;snapshot.key=gradle-main' }}
steps:
- uses: actions/checkout@v5
- 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' }}
- run: ./gradlew build --build-cache --parallel --max-workers=12
- name: Remove credentials before the snapshot
if: github.ref == 'refs/heads/main'
run: rm -rf $HOME/.ssh $HOME/.aws $HOME/.gradle/gradle.properties
- uses: WarpBuilds/snapshot-save@v1
if: github.ref == 'refs/heads/main'
with:
alias: gradle-main
fail-on-error: true
wait-timeout-minutes: 60The runs-on expression is the pattern from the snapshot runners documentation: the default branch boots from the base image with snapshot.enabled=true and saves a fresh snapshot, and every other branch boots from the gradle-main alias. A restored snapshot arrives with ~/.gradle already populated, so the first ./gradlew invocation resolves dependencies from disk and reads local build cache entries produced by the last main build.
Two constraints shape how far this goes. Snapshots are supported on WarpBuild cloud Ubuntu runners, and snapshot labels on macOS, Windows, or BYOC runners are ignored without an error. The /tmp directory is not preserved, so leave GRADLE_USER_HOME at its default under $HOME and expect anything written to java.io.tmpdir to be gone. Snapshots are deleted after 15 days. The full label syntax and lifecycle sit on the snapshot runners page.
Sizing
Gradle's default --max-workers equals the processor count, and org.gradle.parallel=true lets independent projects execute at the same time. On a small runner those defaults produce more concurrent JVMs than the memory ceiling supports, and the build slows down or gets killed by the out of memory killer.
Budget memory before you raise worker counts. Three consumers share the box: the Gradle daemon heap set by org.gradle.jvmargs, the Kotlin or Java compile daemons, and the forked test JVMs governed by maxParallelForks on the Test task. On warp-ubuntu-latest-x64-8x with 32GB, an 8GB daemon heap, a 4GB Kotlin daemon, and 4 test forks at 2GB each reserve 20GB and leave 12GB for the page cache that makes the local build cache reads fast. On warp-ubuntu-latest-x64-16x with 64GB, a 12GB daemon heap, a 6GB Kotlin daemon, and 6 test forks at 2GB each reserve 30GB with the same headroom pattern.
Sizes, memory, storage, and rates below come from the WarpBuild runner catalog. The worker and heap columns are starting points to tune against your own build scan.
| Label | vCPU | Memory | Storage | Suggested --max-workers | Suggested daemon heap | Per minute |
|---|---|---|---|---|---|---|
warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | 4 | -Xmx4g | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | 6 | -Xmx8g | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | 12 | -Xmx12g | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | 24 | -Xmx24g | $0.064 |
warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | 6 | -Xmx8g | $0.012 |
warp-ubuntu-latest-arm64-16x | 16 | 64GB | 150GB SSD | 12 | -Xmx12g | $0.024 |
Leaving two workers unused on the 8 vCPU and 16 vCPU sizes is deliberate. Gradle spawns compile daemons and test forks outside the worker count, and a build configured to use every core spends its last minutes in context switches while the daemon waits on garbage collection.
Size against the critical path rather than the total task count. A build whose slowest module chain takes 6 minutes finishes no faster on 32 vCPU than on 16 vCPU, and the larger label doubles the per minute rate. The signal that a bigger runner will pay off is a build scan timeline where every worker stays busy for most of the run. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Splitting one long Gradle invocation into several matrix jobs on smaller labels is available as a strategy whenever the module graph allows it.
ARM64 is worth testing for pure JVM builds. The Linux ARM64 runners carry the same memory per vCPU at a lower rate, and a Gradle build with no native toolchain dependency usually moves across by changing the label. Confirm any annotation processor, native library, or Docker step in the same job before switching.
Android modules change the sizing picture, because an emulator leg needs nested virtualization on the x64 runners and holds memory for the duration of the instrumentation tests. That setup is covered on the Android builds on GitHub Actions runners page.
Bottlenecks
Cold Gradle User Home. A first-run job downloads the wrapper distribution and every dependency before compilation starts. This is the largest fixed cost in a Gradle job on GitHub Actions and the one the cache restore removes.
Remote build cache latency. Every cacheable task performs at least one round trip to the remote cache. When the cache server sits far from the runners, a build with thousands of small tasks spends longer on lookups than it saves on hits. Place the cache server close to the runners, and where the server has to live inside your own network, run the runners there: BYOC runs on AWS, GCP, and Azure.
Cache misses that look like hits. Non-relocatable tasks, absolute paths in compiler arguments, and timestamps embedded in archives change the input hash on every run. Run ./gradlew build --build-cache --scan twice on the same commit and compare which tasks report FROM-CACHE against which report SUCCESS.
Configuration cache invalidation. The configuration cache is stored per project and per set of build parameters. Reading a system property or environment variable during configuration ties the entry to that value, so a workflow that passes a run number or a timestamp into the build invalidates the entry on every run. Pass volatile values to task inputs instead of to configuration logic.
Daemon cold start. A fresh JVM interprets bytecode before the JIT compiler warms up, which shows up as a slower first Gradle invocation. Snapshot runners cut the disk part of that cost by restoring a populated Gradle User Home. The daemon process starts fresh after the boot, and a snapshot runner boots in 45 to 60 seconds, so treat the snapshot as a disk warmer.
Snapshot hygiene. A snapshot captures the filesystem, so credentials written during the job are captured too. Remove them before snapshot-save runs. A full git clean -ffdx also deletes the project's .gradle/configuration-cache directory and the build outputs you wanted to preserve, so on Gradle repositories prefer targeted removals of credential paths. Snapshots are addressed by alias, and WarpBuild provisions runners at the organization level, so avoid the pattern entirely on public repositories where a pull request workflow could boot from your alias.
Windows Gradle jobs. WarpBuild caching is not supported on Windows runners, so a Windows Gradle matrix leg has to rely on the remote build cache for task outputs and pay dependency download costs each run.
Cache spend. Gradle User Home archives are large, and Gradle repositories often store tens of gigabytes across branches. WarpBuild cache entries expire 7 days after last use, which keeps abandoned branch caches from accruing. Key design across branches and jobs is covered in the guide on persistent caches for GitHub Actions workflows. Add-on rates come from the pricing evidence.
| Add-on | Rate |
|---|---|
| Cache storage | $0.20 per GB-month |
| Cache write or restore | $0.0001 per operation |
| Snapshot restore | $0.04 per job |
| Snapshot storage | $0.025 per snapshot-hour |
| Networking (Tailscale) | $0 per minute |
Proof
Work the numbers on a concrete build. Take a Gradle monorepo that runs 1,000 jobs per month, each 12 minutes long on 8 vCPU after the cache work above, holding roughly 25GB of Gradle User Home archives and performing 8 cache operations per job.
| Line item | Arithmetic | Monthly |
|---|---|---|
| Runner minutes | 12,000 minutes at $0.016 | $192.00 |
| Cache storage | 25GB at $0.20 per GB-month | $5.00 |
| Cache operations | 8,000 at $0.0001 | $0.80 |
| Total | $197.80 |
The same 12,000 minutes on the comparable GitHub-hosted Linux 8-core runner bill at $0.022 per minute, which is $264.00. GitHub publishes those rates on its Actions minute multipliers reference and on the GitHub pricing page, both checked on 2026-08-13.
| Size | WarpBuild label | WarpBuild per minute | GitHub-hosted comparison | GitHub per minute |
|---|---|---|---|---|
| 8 vCPU Linux x64 | warp-ubuntu-latest-x64-8x | $0.016 | Linux 8-core | $0.022 |
| 16 vCPU Linux x64 | warp-ubuntu-latest-x64-16x | $0.032 | Linux 16-core | $0.042 |
| 8 vCPU Linux ARM64 | warp-ubuntu-latest-arm64-8x | $0.012 | Linux 8-core arm64 | $0.014 |
The same rows stated as list-price arithmetic, with rates for every label on the pricing page. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. GitHub list prices checked on 2026-08-13.
Snapshots carry their own arithmetic. One alias held for a 30 day month is 720 snapshot-hours at $0.025, or $18.00, plus 1,000 restores at $0.04, or $40.00. That $58.00 buys 3,625 minutes at the 8 vCPU rate, so the snapshot pays for itself when it removes more than 3.6 minutes from the average job. A build that spends 5 minutes resolving dependencies clears that bar; a build that already restores a warm Gradle User Home in 40 seconds does not.
The model above is the whole bill for these runners. Current rates for every label sit on the WarpBuild pricing page.
Verify wall clock time on your own repository: run the same commit on your current label and on a warp- label, then compare the build scan timelines and the FROM-CACHE task counts. CI observability reports system metrics from the runner agent alongside the job logs, which is where CPU saturation and memory pressure during a Gradle run become visible. The same numbers can be pulled into your own reporting from there. Teams working through a migration can use them directly.
FAQ
Does a remote Gradle build cache replace caching the Gradle User Home?
No. The remote build cache serves task outputs only. Resolved dependencies, wrapper distributions, and the configuration cache still have to be restored onto the runner, either with a cache action or with a snapshot runner.
Should pull request builds push to the remote build cache?
Usually not. Let the default branch write entries and let pull request builds read them, which keeps unreviewed task outputs out of the shared cache. Set push to true only for the branch you trust.
Which WarpBuild runner size fits a Gradle build?
Start at warp-ubuntu-latest-x64-8x with 8 vCPU and 32GB of memory at $0.016 per minute, then move to warp-ubuntu-latest-x64-16x if the build saturates every worker for most of the run.
Do snapshot runners keep the Gradle daemon warm?
A snapshot preserves the disk, so the Gradle User Home and the local build cache come back populated. The daemon process itself starts fresh after the boot, which takes 45 to 60 seconds on a snapshot runner.
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.