Maven Build Cache on GitHub Actions

Cache Maven on GitHub Actions with WarpBuilds/setup-java, cache: maven and a pom.xml dependency path, then map mvn -T threads onto warp- runner sizes.

Last verified:

Overview

A Maven job on GitHub Actions begins with an empty ~/.m2/repository, because each runner is a fresh virtual machine, so the reactor downloads every dependency and every plugin again before javac produces a single class file. The shortest working setup is WarpBuilds/setup-java@v5 with cache: maven and cache-dependency-path pointed at your pom.xml files, running on a warp- runner sized to the width of the reactor.

Maven keeps one local repository per machine, shared by every module in the build and by every plugin the build invokes. Restoring that directory converts dependency resolution from a network operation into a disk read, which is why it is the first thing to cache and usually the only thing worth caching in a Maven pipeline.

PathWhat it holdsWhat changes it
~/.m2/repositoryResolved release artifacts and plugin jarsVersion bumps in any pom.xml, new plugins
~/.m2/repository/**/*-SNAPSHOT/Snapshot artifacts governed by the repository updatePolicyEvery upstream snapshot deploy
~/.m2/repository/**/resolver-status.propertiesLast-update timestamps and failed-resolution markersRepository outages, offline runs
~/.m2/settings.xmlMirrors, server entries, credentialsWritten from secrets per job, so keep it out of the cache
<module>/target/Compiled classes, Surefire reports, packaged jarsRebuilt on every run

Three scoping rules shape how keys behave. A cache entry is scoped to the key, the cache version, and the branch. The version is a hash over the compression tool and the list of cached paths, so an entry written on a macOS runner will fail to restore on a Linux runner even under an identical key. Entries expire 7 days after their last use, which retires abandoned branch entries on its own.

Maven work belongs on the Linux runners, where cache is enabled by default and where the runner catalog lists Ubuntu 22.04, 24.04, and 26.04 images in sizes from 2 to 32 vCPUs, each with a 150GB SSD and the same tooling as GitHub-hosted runners.

One scope note. This page stays on Maven: the local repository, reactor threading, and Surefire. General JVM heap tuning that applies across build tools sits on the faster Java builds page, the Gradle build cache and configuration cache sit on the Gradle build caches page, and sbt sits on the Scala and sbt page.

Configuration

WarpBuilds/setup-java@v5 installs the JDK and restores the dependency cache in one step. Caching is off by default in that action, so cache: maven has to be set explicitly, and cache-dependency-path decides which files feed the key. Point it at every pom.xml in the reactor rather than the root one alone, because a version bump in a leaf module changes what has to be downloaded.

name: maven
on:
  pull_request:
  push:
    branches: [main]

env:
  MAVEN_OPTS: -Xmx4g -Dmaven.artifact.threads=12

jobs:
  reactor:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v5

      - uses: WarpBuilds/setup-java@v5
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
          cache-dependency-path: |
            pom.xml
            **/pom.xml

      - name: Build and unit test the reactor
        run: mvn -B -ntp -T 4 verify -DskipITs

-B puts Maven in batch mode and -ntp drops the transfer progress lines, which together cut several thousand lines of noise out of the job log and make the reactor summary readable. -DskipITs keeps Failsafe out of the pull request job so the fast path stops at unit tests.

The full list of maintained forks is on the setup actions documentation. Each one accepts the same inputs as the action it replaces, so adopting it is a one line change per step.

When you need to see and control the key yourself, drop back to the cache action directly. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 and takes the same path, key, and restore-keys inputs.

      - uses: WarpBuilds/cache@v1
        with:
          path: ~/.m2/repository
          key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }}
          restore-keys: |
            maven-${{ runner.os }}-

The restore-keys prefix earns its place on Maven repositories. A single dependency bump changes the exact key, and without a prefix fallback the job downloads the entire dependency set again instead of the handful of artifacts that moved.

Surefire needs a fixed fork count once -T is in play, because Maven multiplies build threads by forks. -T 1C with forkCount at 1C on a 16 vCPU runner asks for up to 256 test JVMs, each one sizing its default heap at a quarter of machine memory.

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <forkCount>2</forkCount>
    <reuseForks>true</reuseForks>
    <argLine>-Xmx2g</argLine>
  </configuration>
</plugin>

One naming collision is worth flagging. Maven SNAPSHOT artifacts and WarpBuild snapshot runners share a word and share nothing else: the first is a version suffix with an update policy, the second is a runner that boots from a saved disk image so ~/.m2 arrives populated. Snapshot runners are covered on the Gradle build caches page, and the same label syntax applies to a Maven reactor.

Sizing

Reactor width sets the ceiling on -T, and machine memory sets the ceiling on forks. Start from the dependency graph: -T 8 on a reactor whose widest independent layer holds four modules keeps four threads idle, and no runner size fixes that. The reactor summary printed at the end of each build gives the per-module durations you need to find the chain that actually determines wall clock time.

Rates below come from the WarpBuild runner catalog and pricing page. The thread and fork columns are starting points to tune against your own reactor summary.

LabelvCPUMemorySuggested -TSurefire forkCountPeak test JVMsPer minute
warp-ubuntu-latest-x64-2x28 GB-T 111$0.004
warp-ubuntu-latest-x64-4x416 GB-T 224$0.008
warp-ubuntu-latest-x64-8x832 GB-T 428$0.016
warp-ubuntu-latest-x64-16x1664 GB-T 8216$0.032
warp-ubuntu-latest-x64-32x32128 GB-T 16232$0.064

Check the memory budget before raising either number. On warp-ubuntu-latest-x64-8x, a 4 GB reactor JVM plus eight forks at 2 GB reserves 20 GB of the 32 GB available and leaves 12 GB for the page cache that makes local repository reads fast. On warp-ubuntu-latest-x64-16x, a 6 GB reactor JVM plus sixteen forks at 2 GB reserves 38 GB of 64 GB. On warp-ubuntu-latest-x64-32x, an 8 GB reactor plus thirty two forks at 2 GB reserves 72 GB of 128 GB, which is the point where a wide suite starts saturating the disk instead of the cores.

Peak test JVMs exceed the vCPU count on purpose. A -T build thread spends most of the test phase waiting on its forks, so a small amount of oversubscription keeps cores busy while forks start and stop. Push past a factor of two and the run degrades into context switching.

Map sizes to phases. A reactor under roughly ten modules compiles comfortably on warp-ubuntu-latest-x64-4x, and static analysis jobs running Checkstyle or SpotBugs against a restored repository belong there too. Wide reactors with dozens of independent modules are where warp-ubuntu-latest-x64-16x shortens the compile phase, because the module scheduler finally has lanes to fill. Integration jobs that run Failsafe against service containers need room for the containers plus the forks, which is the other case that justifies 16 vCPUs.

For pure JVM reactors, test the ARM64 catalog. warp-ubuntu-latest-arm64-8x carries the same 8 vCPU and 32 GB shape at $0.012 per minute, and Temurin publishes aarch64 Linux builds, so most reactors move across by changing the label. Confirm every annotation processor and native library in the job before switching.

Bottlenecks

Cold ~/.m2. An uncached job resolves and downloads the full dependency set and the full plugin set before compilation starts. A mid-size Spring Boot reactor pulls hundreds of artifacts this way on every push, and the cost recurs identically on every pull request. The cache step above removes it.

Dependency resolution against remote repositories. A warm repository still pays for metadata. Maven checks maven-metadata.xml for every SNAPSHOT dependency according to the repository updatePolicy, and version ranges force a metadata fetch per resolution. Set updatePolicy to daily or interval:60 on internal snapshot repositories rather than leaving it at always, raise -Dmaven.artifact.threads so downloads run in parallel, and declare a single mirror in settings.xml so the resolver stops walking a list of repositories per artifact. When the internal repository sits inside your own network, run the runners there: BYOC runs on AWS, GCP, and Azure.

Serialized module order. The reactor is a dependency graph, and -T extracts parallelism only where the graph is wide. A chain of ten modules that each depend on the previous one runs at the same speed on 4 vCPUs and 32. Read the per-module times in the reactor summary, find the longest chain, and fix the graph before buying a larger label: split the shared common module that everything depends on, or move a slow module with no dependents to the end.

Surefire forks. Fork startup is pure overhead. A JVM loads classes, verifies bytecode, and interprets until the JIT compiler warms up, so a fork that lives 30 seconds spends most of its life warming. Keep reuseForks at true so warmup amortizes across many test classes. Fork isolation also breaks against shared state: suites that write to one database schema serialize themselves, and the fix is to template ${surefire.forkNumber} into the JDBC URL or give each fork its own container.

Telling these apart is a measurement problem. CI observability streams system metrics from the runner agent and correlates them with GitHub Actions job logs, so a job stalled on network resolution looks visibly different from one saturating every core through Surefire.

Proof

Work the arithmetic on a concrete pipeline. Take a reactor of 34 modules with 2,000 jobs per month: 1,600 pull request jobs at 9 minutes on 8 vCPU, and 400 verify jobs at 14 minutes on 16 vCPU, holding 9 GB of local repository archives and performing 4 cache operations per job.

Line itemArithmeticMonthly
Unit jobs, warp-ubuntu-latest-x64-8x14,400 minutes at $0.016$230.40
Verify jobs, warp-ubuntu-latest-x64-16x5,600 minutes at $0.032$179.20
Cache storage9 GB at $0.20 per GB-month$1.80
Cache operations8,000 at $0.0001$0.80
WarpBuild total$412.20

The same minutes on GitHub-hosted larger runners bill at $0.022 for the 8 vCPU shape and $0.042 for the 16 vCPU shape, which is $316.80 plus $235.20, or $552.00. GitHub publishes those rates on its Actions minute multipliers reference and on the GitHub pricing page, both checked on 2026-08-13. The gap is $139.80 per month, or $1,677.60 per year, on identical machine shapes.

Stated as list-price arithmetic per size, with every rate on the pricing page: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. 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. GitHub list prices checked on 2026-08-13.

Those rows hold minutes constant.

Public repositories run Maven on warp- labels in the open. kintsugi-tax/killbill-kintsugi-plugin builds a plugin for the Kill Bill billing platform with mvn -B clean verify on warp-ubuntu-latest-x64-2x in its release workflow, which is the shape most single-module Maven projects land on.

FAQ

How do I cache the Maven local repository on GitHub Actions?

Use WarpBuilds/setup-java@v5 with cache: maven and cache-dependency-path pointed at your pom.xml files. Java caching is off by default in that action, so the cache input has to be set explicitly. On WarpBuild runners the dependency cache is then served by WarpBuild Cache with no other workflow change.

Which warp- runner size fits a multi-module Maven reactor?

Start on warp-ubuntu-latest-x64-8x at $0.016 per minute with mvn -T 4 and a fixed Surefire forkCount of 2. Move the job to warp-ubuntu-latest-x64-16x at $0.032 per minute once the reactor has enough independent modules to keep 8 build threads busy through the compile phase.

Why does mvn -T 1C with forkCount 1C exhaust memory on a runner?

Maven multiplies the two. Eight build threads each forking eight test JVMs is up to 64 JVMs, and each one sizes its default heap from total machine memory. Set forkCount to a fixed small number and bound both heaps with MAVEN_OPTS and the Surefire argLine.

How long does a Maven cache entry survive?

WarpBuild cache entries expire 7 days after their last use. Each entry is scoped to the key, the cache version, and the branch, and the version hash covers the compression tool and the cached paths, so an entry saved on a macOS runner cannot restore on a Linux 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.