Essential Tips for Gradle on Ephemeral CI Environments - Part 2
Table of Contents
Introduction
In Part 1, we explored foundational strategies for improving Gradle build performance in ephemeral CI environments, focusing on the Startup and Initialization phase.
Now, in Part 2, we shift our focus to the Configuration phase: the work Gradle does after startup but before it executes any tasks. We’ll benchmark dependency and script caching, then look at where the setup-gradle GitHub Action and Develocity’s Universal Cache fit.
Benchmarking Performance in Ephemeral CI #
The methodology is the same as in Part 1: each scenario builds a fresh Docker image of the Spring Boot Project with --no-cache, base images are pre-pulled, and every figure is the average of ten runs against one consistent baseline. Individual runs vary by a second or two, so read small differences as approximate rather than exact.
These numbers are not intended as universal Gradle performance claims. They show the relative impact of configuration-phase optimizations for one small Spring Boot project, one Gradle version, one JDK image, and one local Docker environment. Larger builds, with more dependencies and more build logic, will generally see bigger gains from dependency and script caching than a small sample project does.
NOTE: All measurements in this post are pinned to Gradle 9.5.1. If you repeat the experiment with a newer Gradle version, rebuild the prime image and update any version-specific cache paths accordingly.
One difference from Part 1: for the Configuration phase, we prime the cache by building the complete Spring Boot Project, not the empty Prime Project. Caching configuration-phase work only means something when there are real dependencies and real build scripts to cache, so the prime image must reflect a realistic project. As in Part 1, that prime image is built once and only restored from, so the timed builds measure cache restore, not cache creation.
Building the full project populates the following directories in the Gradle User Home:
.gradle/ <-- Gradle User Home (default: ~/.gradle)
├── caches/ <-- Cached build data and dependencies
│ ├── modules-2/ <-- Downloaded project dependencies
│ ├── jars-9/ <-- Instrumented and processed JAR files
│ ├── transforms-3/ <-- Results of artifact transformations
│ ├── build-cache-1/ <-- Local build cache artifacts
│ └── 9.5.1/ <-- Version-specific caches (Gradle 9.5.1)
│ ├── generated-gradle-jars/ <-- Generated files for Gradle runtime
│ ├── kotlin-dsl/ <-- Compiled Kotlin DSL scripts
│ └── scripts/ <-- Compiled Groovy/other scripts
├── wrapper/ <-- Cached Gradle distributions (downloaded by Wrapper)
│ └── dists/ <-- Gradle distribution ZIP files and extracted archives
├── daemon/ <-- Gradle daemon runtime data
│ └── 9.5.1/ <-- Daemon logs and runtime data per Gradle version
└── jdks/ <-- JDK installations managed by Gradle Toolchains
The optimizations below load and reuse specific directories from this primed cache.
Establishing the Baseline #
As in Part 1, the baseline builds the Spring Boot Project with no optimizations applied. In this run it averaged 31.5s over ten runs, and every configuration-phase figure below is measured against it. That lines up closely with Part 1’s 31.7s baseline, which is reassuring given the two posts were benchmarked in separate runs.
Optimizations for the Configuration Phase #
During the Configuration phase, Gradle evaluates the project’s build scripts and establishes the build structure. Specifically, Gradle:
- Resolves plugin dependencies by downloading plugin JARs from the declared repositories.
- Compiles build logic by executing the build scripts, then creating and configuring tasks (without executing them).
- Constructs the task graph, determining the order in which tasks must run based on command-line input and task dependencies.

In this benchmark, the dominant cost is obtaining and preparing dependencies, so that’s where the biggest wins show up.
Dependency Cache #
Gradle stores downloaded dependencies (libraries, plugin JARs) fetched from repositories such as Maven Central in <GRADLE_USER_HOME>/caches/modules-2, and stores processed and instrumented copies in caches/jars-9. In ephemeral CI, both are lost between builds unless you cache them.
#1 Save and Restore the modules-2 Directory #
Without caching, Gradle re-downloads every dependency on each build, adding network time and load. We cache the modules-2 directory from the primed Spring Boot Project build and restore it:
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
# Restore the downloaded dependency cache from the prime image
COPY --from=gradle-cache /root/.gradle/caches/modules-2 /root/.gradle/caches/modules-2
# Run build using the Gradle Wrapper
RUN ./gradlew build --no-daemon
# Extract the built JAR
RUN mkdir -p target && cp build/libs/demo-0.0.1-SNAPSHOT.jar target/app.jar
# Run Spring Boot app
CMD ["java", "-jar", "target/app.jar"]
Resulting Build Time:
[+] Building 22.7s (12/12) FINISHED
| Scenario | Build Time |
|---|---|
| Baseline (No caching) | 31.5s |
| Optimized (Dependency caching) | 22.7s |
| Total Time Saved | 8.8s |
Restoring modules-2 eliminates redundant dependency downloads and cuts build time by 28%. This is the single biggest configuration-phase win, and it grows with the size of your dependency graph, so it’s worth doing on almost any project.
#2 Use the Shared Read-Only Dependency Cache #
Gradle also supports a shared read-only dependency cache, configured through the GRADLE_RO_DEP_CACHE environment variable. It’s built for exactly this situation: many ephemeral runners that shouldn’t each download into and mutate their own full copy of the dependency cache.
It’s easy to misuse, so it’s worth being precise. GRADLE_RO_DEP_CACHE is a path, not a boolean, and there are three steps:
- Populate a
modules-2directory with all the dependencies any build on the agent might need, ideally the union of dependencies across your builds rather than a single project. - Copy that
modules-2directory to a location outside the Gradle User Home on the agent image (omit any*.lockandgc.propertiesfiles). - Run each build with
GRADLE_RO_DEP_CACHEpointing at the directory that containsmodules-2. The build starts with a clean Gradle User Home and reads dependencies from the shared cache instead of downloading them.
In our benchmark we seed that cache from the primed build and point Gradle at it:
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
# Seed a read-only dependency cache and point Gradle at it
COPY --from=gradle-cache /root/.gradle/caches/modules-2 /ro-cache/modules-2
ENV GRADLE_RO_DEP_CACHE=/ro-cache
# Run build using the Gradle Wrapper
RUN ./gradlew build --no-daemon
# Extract the built JAR
RUN mkdir -p target && cp build/libs/demo-0.0.1-SNAPSHOT.jar target/app.jar
# Run Spring Boot app
CMD ["java", "-jar", "target/app.jar"]
Resulting Build Time:
[+] Building 22.7s (12/12) FINISHED
| Scenario | Build Time |
|---|---|
| Baseline (No caching) | 31.5s |
| Optimized (Shared read-only dependency cache) | 22.7s |
| Total Time Saved | 8.8s |
At 28%, it landed identical to the writable modules-2 cache; both averaged 22.7s. That’s exactly what we’d expect, since both approaches simply avoid re-downloading dependencies, so they deliver the same build-time impact. The reason to reach for the shared read-only cache isn’t a bigger number, it’s operational: one pre-populated cache can be mounted read-only and shared across many ephemeral runners at once, without lock contention and without each runner keeping its own full, writable copy.
#3 Save and Restore the jars-9 Directory #
Gradle also stores processed and instrumented JARs in caches/jars-9. In theory, caching them avoids re-processing on each build:
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
# Restore the processed-JAR cache from the prime image
COPY --from=gradle-cache /root/.gradle/caches/jars-9 /root/.gradle/caches/jars-9
# Run build using the Gradle Wrapper
RUN ./gradlew build --no-daemon
# Extract the built JAR
RUN mkdir -p target && cp build/libs/demo-0.0.1-SNAPSHOT.jar target/app.jar
# Run Spring Boot app
CMD ["java", "-jar", "target/app.jar"]
Resulting Build Time:
[+] Building 31.2s (12/12) FINISHED
| Scenario | Build Time |
|---|---|
| Baseline (No caching) | 31.5s |
| Optimized (Processed-JAR caching) | 31.2s |
| Difference | 0.3s faster (within noise) |
On its own, caching jars-9 produced no measurable improvement for this project. A small Spring Boot app has little to instrument, and the processed JARs are effectively covered once you restore the dependency cache above. Treat jars-9 as something you get “for free” when you cache the broader Gradle User Home, not as a standalone optimization worth wiring up by hand.
Script Compilation Cache #
Compiling build scripts is part of the Configuration phase. Gradle caches compiled Kotlin DSL scripts in <GRADLE_USER_HOME>/caches/<version>/kotlin-dsl so it doesn’t re-parse and re-compile them on every run. In ephemeral CI this cache is lost between builds.
#4 Save and Restore the kotlin-dsl Directory #
We cache the kotlin-dsl directory from the primed build and restore it:
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
# Restore the compiled-script cache from the prime image
COPY --from=gradle-cache /root/.gradle/caches/9.5.1/kotlin-dsl /root/.gradle/caches/9.5.1/kotlin-dsl
# Run build using the Gradle Wrapper
RUN ./gradlew build --no-daemon
# Extract the built JAR
RUN mkdir -p target && cp build/libs/demo-0.0.1-SNAPSHOT.jar target/app.jar
# Run Spring Boot app
CMD ["java", "-jar", "target/app.jar"]
Resulting Build Time:
[+] Building 28.3s (12/12) FINISHED
| Scenario | Build Time |
|---|---|
| Baseline (No caching) | 31.5s |
| Optimized (Script caching) | 28.3s |
| Total Time Saved | 3.2s |
Caching compiled scripts reduces build time by 10% here by skipping redundant script compilation. The gain is modest for a project with a single small build script, but it grows with the amount of build logic: large multi-module builds and heavy buildSrc/convention plugins compile far more, so this optimization matters more the more build logic you have.
The Problem with Manual Caching #
As in Part 1, these optimizations work, but wiring them up by hand is where it gets expensive.
Each scenario above means another COPY --from line, another version-specific path to keep aligned, and another cache to reason about. Multiply that across every project, Gradle version, and CI system you run, and the maintenance burden becomes the problem. And when a build slows back down, working out which cache stopped being restored is a frustrating hunt.
There are two ways to get these configuration-phase wins without maintaining the Docker plumbing yourself.
If your builds run on GitHub Actions, the official setup-gradle action already handles it. It caches the Gradle User Home between jobs, including the dependency cache (modules-2) and compiled scripts (kotlin-dsl), from a single workflow step. It’s free, maintained by Gradle, and manages the save/restore keys and cleanup for you, so most GitHub Actions users can capture the dependency and script wins from this post without any custom Dockerfiles.
For caching that spans any CI system and multiple build tools, the Develocity Universal Cache takes it further. In Part 1 we saw its Setup Cache automate the startup-phase priming. The dependency-heavy part of the Configuration phase maps to its Artifact Cache, which caches dependency downloads (the modules-2 work that drove the biggest wins above) automatically, as a shared, observable service rather than per-project directory copies. Where setup-gradle is specific to GitHub Actions and Gradle, the Universal Cache works across any CI system and multiple build tools (Gradle, Maven, and npm), and the same cache can serve both CI and developer machines.
Because the Artifact Cache is a managed service, cache hit rates and dependency-fetch activity are visible per build through Build Scans, so a slow build tells you which cache is missing instead of leaving you to guess. In larger, dependency-heavy builds, teams adopting the Universal Cache can see build-time reductions well beyond what a single sample project shows, depending on project structure, network distance, dependency volume, and cache hit rates.
Summary #
The Configuration phase is dominated by dependencies. In this benchmark, restoring the dependency cache was the biggest lever, whether copied into the Gradle User Home or shared as a read-only cache across runners:
| Optimization | Time reduction vs. baseline | Recommendation |
|---|---|---|
Cache dependencies (modules-2) |
28% | Recommended |
Shared read-only dependency cache (GRADLE_RO_DEP_CACHE) |
28% (identical to modules-2) |
Recommended for sharing one cache across runners |
Cache compiled scripts (kotlin-dsl) |
10% | Situational; grows with build logic |
Cache processed JARs (jars-9) |
~1% (neutral) | Skip on its own |
A few takeaways:
- Cache your dependencies. It’s the highest-impact configuration-phase optimization. A shared read-only cache gives the same speedup and lets many ephemeral runners share one pre-populated cache.
- Script caching helps in proportion to your build logic. Small for a sample app, meaningful for large multi-module builds.
- Don’t bother caching
jars-9on its own. You get it for free when you cache the broader Gradle User Home. - Let tooling do the plumbing.
setup-gradleon GitHub Actions, or Develocity’s Artifact Cache for any CI and any build tool, delivers these wins without hand-maintained Dockerfiles.
In Part 3, we’ll turn to the Execution phase, where the Build Cache and task-output reuse take center stage.