Skip to content
Français
CI/CD & Automatisation medium

Java, Maven and Gradle caching in GitHub Actions

30 min de lecture

Read this page in French

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-java for Maven and Gradle
  • Configure actions/cache for 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 verify

The 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.

ValueManager
mavenMaven (detects pom.xml)
gradleGradle (detects build.gradle or build.gradle.kts)
sbtSBT (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 verify

Caching 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 build

Caching 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 }}-

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 build

That 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-cache

Tests 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 --parallel

Optionally 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
fi

The 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.

Fenêtre de terminal
# Symptom: checksum errors
Could not resolve dependencies for project
# Fix: delete the cache
gh cache delete maven-linux-* --repo owner/repo

Gradle 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-daemon

A 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/cache gives you control over the paths (~/.m2/repository, ~/.gradle/caches) when the built-in cache is not enough.
  • For Gradle, gradle/actions/setup-gradle handles 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

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn