Java projects often carry large dependencies. A Maven or Gradle build without a cache can download hundreds of megabytes of JARs. The cache brings that time down from several minutes to a few seconds.
What you will learn
- Enable the built-in cache of
setup-javafor Maven and Gradle - Configure
actions/cachefor fine-grained control of the local repositories - Use
gradle/actions/setup-gradle, the official Gradle action - Build a complete workflow for Maven or Gradle, with a matrix
- Handle multi-module projects and debug a corrupted cache
The built-in cache of setup-java
The setup-java action can handle the cache on its own: the cache: option
triggers the save of the local dependency repository at the end of the job and
its restoration on the next one. The cache key is computed from the project
description files, so you have neither a path nor a fingerprint to write. It is
the method to prefer as long as you have no particular need, because it follows
the evolutions of the tool without any intervention on your side.
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '21' cache: 'maven' # or 'gradle'
- run: mvn verifyThe cache options
Three values are accepted, one per build manager. The file named in brackets is the one used to compute the key: changing it invalidates the cache and triggers a complete download of the dependencies again.
| Value | Manager |
|---|---|
maven | Maven (detects pom.xml) |
gradle | Gradle (detects build.gradle or build.gradle.kts) |
sbt | SBT (detects build.sbt) |
Manual Maven caching
Going through actions/cache becomes useful when you have to control exactly
which paths are saved, add a directory setup-java ignores, or share a cache
between several workflows. Two parameters carry the whole behaviour: key, the
exact fingerprint being looked for, and restore-keys, the list of fallback
prefixes used when that fingerprint does not exist yet. Without
restore-keys, the slightest change to a pom.xml starts from scratch.
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '21'
- name: Cache Maven packages uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.m2/repository key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }} restore-keys: | maven-${{ runner.os }}-
- run: mvn verifyCaching with the Maven wrapper
The Maven wrapper downloads its own Maven distribution into ~/.m2/wrapper,
on top of the project dependencies. Add that directory to the cache and
maven-wrapper.properties to the key computation, otherwise every run fetches
the Maven archive again.
- name: Cache Maven uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.m2/repository ~/.m2/wrapper key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml', '**/maven-wrapper.properties') }} restore-keys: | maven-${{ runner.os }}-Manual Gradle caching
If you do not use the official Gradle action, cache the Gradle dependencies
and wrapper by hand. The two directories are distinct: ~/.gradle/caches
holds the downloaded artefacts, ~/.gradle/wrapper the Gradle distribution
itself, several hundred megabytes for every version. The **/*.gradle* pattern
covers both the Groovy and the Kotlin syntax of the build files.
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '21'
- name: Cache Gradle uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | gradle-${{ runner.os }}-
- run: ./gradlew buildCaching the Gradle build
Caching the .gradle and build directories also keeps the compilation
output, which speeds up successive runs on the same branch. The key includes
github.sha here, so one entry per commit: that is deliberate, a cache of
compiled artefacts must not be shared between two states of the code. Keep in
mind that a cache entry is immutable, a key that has already been written
will never be updated, and that the 10 GB quota per repository fills up all the
faster as the keys are volatile.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper .gradle build key: gradle-build-${{ runner.os }}-${{ github.sha }} restore-keys: | gradle-build-${{ runner.os }}- gradle-${{ runner.os }}-The gradle/actions action (recommended)
Maintained by the Gradle team, gradle/actions/setup-gradle replaces writing the
cache blocks by hand. It saves the useful directories, restores the state between
runs and adds a build report to the job summary. Place it after setup-java,
because it needs a JDK already installed to start Gradle.
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '21'
- name: Setup Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- run: ./gradlew buildThat action:
- Caches the Gradle dependencies automatically
- Produces a build report
- Supports build scans
A complete Maven workflow
Here is the complete assembly, ready to be copied into .github/workflows/. It
combines the built-in cache, a matrix replaying the build on two JDK
versions, and the publication of the test reports even on failure thanks to if: always(). The matrix starts the two jobs in parallel, which barely lengthens the
total duration but doubles the minutes consumed.
name: Java CI with Maven
on: push: branches: [main] pull_request: branches: [main]
permissions: {}
jobs: build: runs-on: ubuntu-24.04 permissions: contents: read
strategy: matrix: java: [17, 21]
steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- name: Setup Java ${{ matrix.java }} uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: ${{ matrix.java }} cache: 'maven'
- name: Build and test run: mvn -B verify
- name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results-java${{ matrix.java }} path: target/surefire-reports/A complete Gradle workflow
The Gradle equivalent relies on the official action rather than on a cache block
written by hand. The build and test steps are kept separate to get two distinct
durations in the interface, which makes diagnosis easier when a job slows down.
As with Maven, the workflow starts from permissions: {} and grants the job only
contents: read, the strict minimum to clone the repository.
name: Java CI with Gradle
on: push: branches: [main] pull_request: branches: [main]
permissions: {}
jobs: build: runs-on: ubuntu-24.04 permissions: contents: read
steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- name: Setup Java uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '21'
- name: Setup Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Build run: ./gradlew build
- name: Test run: ./gradlew test
- name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results path: build/reports/tests/The specific optimisations
Beyond the cache, a few options speed Java builds up further: incremental builds, parallelism, conditional tests.
Incremental Gradle build
The --build-cache option lets Gradle reuse the result of the tasks whose inputs
have not changed, instead of running them again. The gain only appears from the
second run onwards, once the build cache has been restored by the action.
- name: Build with incremental run: ./gradlew build --build-cacheTests in parallel
GitHub-hosted runners have several cores, which neither tool uses by default. First check that your tests support concurrent execution: tests sharing a temporary file or a fixed port become flaky as soon as they run in parallel.
# Maven- run: mvn -B verify -T 4 # 4 threads
# Gradle- run: ./gradlew test --parallelOptionally skipping the tests
This expression adds -DskipTests outside of push events, to reserve the full
suite for integrated branches. Use it sparingly: an artefact built without tests
must never be published, at the risk of losing the guarantee the pipeline
provides.
- name: Build run: mvn -B package ${{ github.event_name == 'push' && '' || '-DskipTests' }}Multi-module Maven projects
On a multi-module project, only rebuild the modules that actually changed: the dependency cache stays shared, and the build targets the strict minimum.
- name: Cache Maven modules uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.m2/repository key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }} restore-keys: | maven-${{ runner.os }}-
# Build only the changed modules- name: Build changed modules run: | MODULES=$(git diff --name-only HEAD~1 | grep pom.xml | xargs dirname | tr '\n' ',') if [ -n "$MODULES" ]; then mvn -B -pl "$MODULES" -am verify fiThe common mistakes
Three typical problems hit Java builds in CI. Here is the symptom and the fix for each.
A corrupted cache
A partially downloaded artefact stays in the local repository and breaks every
subsequent run, since the cache is restored as it is. As a cache entry cannot be
overwritten, the only way out is to delete it with the gh cache delete command,
which accepts a pattern.
# Symptom: checksum errorsCould not resolve dependencies for project
# Fix: delete the cachegh cache delete maven-linux-* --repo owner/repoGradle daemon issues
The daemon is a Gradle process surviving between two invocations to keep the
JVM warm. On a runner destroyed at the end of the job, it is useless and
sometimes causes hangs at the end of a run; --no-daemon removes the problem.
# Disable the daemon in CI- run: ./gradlew build --no-daemonA settings.xml carrying credentials
Publishing to a private artefact repository requires a settings.xml holding
credentials, which setup-java generates for you from the server-* options.
The important point is that those options expect environment variable names,
not the values themselves: the secret stays in the env: block and never appears
in the committed file.
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'temurin' java-version: '21' cache: 'maven' server-id: github server-username: MAVEN_USERNAME server-password: MAVEN_TOKEN
- run: mvn deploy env: MAVEN_USERNAME: ${{ github.actor }} MAVEN_TOKEN: ${{ secrets.GITHUB_TOKEN }}Key points
- The built-in cache of
setup-java(cache: 'maven'/'gradle'/'sbt') is the recommended method. - A manual
actions/cachegives you control over the paths (~/.m2/repository,~/.gradle/caches) when the built-in cache is not enough. - For Gradle,
gradle/actions/setup-gradlehandles the cache automatically and adds a build report. - Disable the Gradle daemon in CI (
--no-daemon): it brings nothing on an ephemeral runner. - A corrupted cache is purged with
gh cache delete, worth remembering when you face checksum errors.
Next steps
- Introduction to runners: what runs your Maven and Gradle builds, and why the cache starts from scratch on every run on a GitHub runner.
- GitHub-hosted vs self-hosted runners: the option that keeps the local
~/.m2repository on disk between two builds, with the security trade-offs that implies. - ACT: testing your workflows locally: replaying a Java build job on your own machine to validate a cache key without consuming GitHub minutes.