How Do I Cache Maven Dependencies?

Cache ~/.m2/repository with a key from hashFiles over every pom.xml, then pin resolution with -nsu and -C so a partial restore cannot change versions.

Cache the Maven local repository at ~/.m2/repository, key the entry on hashFiles('**/pom.xml'), and add one restore-keys prefix so a dependency bump still starts from yesterday's artifacts. Then pin resolution behavior with -nsu and -C and run the build offline on an exact hit, so a partially restored repository cannot quietly resolve different versions than the run before it.

Answer

Maven writes every resolved artifact into a single local repository, which defaults to ${user.home}/.m2/repository and is configurable through localRepository in settings.xml (Maven settings reference). One cache entry covers it.

PathWhat it holdsCache it
~/.m2/repositoryDownloaded dependencies, plugins, and their POMsYes, exact key with one prefix fallback
~/.m2/wrapperMaven distributions unpacked by mvnwYes, in the same entry
~/.m2/settings.xmlRepository credentials and server definitionsNo
target/Compiled classes and packaged jarsNo, rebuild it

hashFiles('**/pom.xml') returns a single SHA-256 over every matching file in the workspace, so the key already covers the parent POM and every module POM (GitHub expressions reference).

name: verify
on:
  pull_request:
    branches: [main]

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

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"

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

      - name: Resolve what the restore missed
        if: steps.m2.outputs.cache-hit != 'true'
        run: ./mvnw -B -ntp -nsu -C dependency:go-offline

      - name: Build and test
        run: |
          offline=""
          if [ "${{ steps.m2.outputs.cache-hit }}" = "true" ]; then
            offline="-o"
          fi
          ./mvnw -B -ntp -nsu -C $offline verify

      - name: Trim resolver metadata before the entry is saved
        run: |
          find ~/.m2/repository -name '*.lastUpdated' -delete
          find ~/.m2/repository -name 'resolver-status.properties' -delete
          find ~/.m2/repository -type d -name '*-SNAPSHOT' -prune -exec rm -rf {} +

Four flags carry the resolution policy (Maven CLI reference). -B runs in batch mode and -ntp drops per-artifact transfer progress from the log. -nsu stops Maven from checking remote repositories for newer snapshot builds. -C turns a checksum mismatch into a build failure instead of a warning. -o runs the build with no network access at all, so an exact cache hit means zero downloads.

WarpBuilds/cache@v1 accepts the same path, key, and restore-keys inputs as actions/cache@v4 and exposes the same cache-hit output, so the swap above is the only change (WarpBuild caching documentation). For one less step, replace actions/setup-java with WarpBuilds/setup-java@v5 and set cache: maven, which routes the same directory through the WarpBuild cache without an explicit cache step (setup actions documentation).

Detail

Why an unpinned POM breaks the key

The key promises that identical POMs mean an identical dependency set. Two things break that promise. A version range such as [1.2,2.0) lets the resolver pick a newer release without any POM edit, and a SNAPSHOT dependency changes content under a fixed coordinate. In both cases hashFiles returns the same digest while the artifacts behind it move, and the job restores an entry whose contents no longer match what the build should resolve.

Pin plugin versions in pluginManagement and dependency versions in dependencyManagement, then enforce the rule for release builds with the Enforcer plugin's requireReleaseDeps (Maven Enforcer rule reference). Where snapshots are unavoidable, -nsu keeps a single run consistent by resolving against the copy already on disk instead of checking the remote repository mid-build.

What the offline flag buys, and where it bites

dependency:go-offline resolves declared dependencies and plugin artifacts ahead of the build (Maven Dependency Plugin reference), and it does not catch plugins that resolve their own dependencies lazily at execution time. That is why the workflow above runs offline only after an exact hit, when the previous run of the same POM set already wrote every artifact the build touched. On a prefix restore or a miss, the build stays online and repopulates the entry.

Offline mode is also the fastest way to prove the cache is doing its job. A build that passes with -o resolved everything from disk. A build that fails with -o names the missing coordinate in the error, which is a precise list of what the entry is missing.

Keep one entry for the whole repository

A reactor build resolves every module into the same local repository, and module dependency sets overlap heavily: Jackson, SLF4J, JUnit, and the plugin classpath land in almost every module. Per-module cache entries store those artifacts once per module, multiply the operation count, and still leave the reactor build resolving from one directory on disk.

Matrix jobs need the same care. Parallel legs that share a key each try to save at the end, and only the first save wins while the rest log that the entry already exists. Warm the entry from one push-triggered job on the default branch, and let pull request branches reach it through restore-keys, since a run can restore entries from its own branch and its base branch only (GitHub dependency caching reference).

What the entry costs

Cache storage on hosted runners is $0.20 per GB-month and each write or restore operation is $0.0001, both free on BYOC (caching documentation, checked on 2026-08-13). Take a Maven monorepo with a 4 GB local repository, five live entries across branches, and 900 jobs a month on warp-ubuntu-latest-x64-8x at $0.016 per minute (pricing page, checked on 2026-08-13):

  • Storage: 5 x 4 GB x $0.20 = $4.00 per month.
  • Operations: 900 jobs x 2 operations x $0.0001 = $0.18 per month.
  • Resolution time avoided at 2 minutes per job: 900 x 2 x $0.016 = $28.80 per month of runner time that is no longer spent downloading.

When the numbers above do not match your runs, CI observability reports per-step durations so the restore step and the resolve step can be read separately, and the Action Debugger opens a shell on a failed runner so you can inspect ~/.m2/repository in place. The tradeoff between a cache action and a snapshot runner for build state is covered in persistent caches for GitHub Actions runs.

Should I cache ~/.m2 or ~/.m2/repository?

Cache ~/.m2/repository, and add ~/.m2/wrapper when the project uses the Maven Wrapper, since mvnw unpacks its distribution there. The parent directory also holds settings.xml and settings-security.xml, which carry repository credentials and master passwords, so caching ~/.m2 wholesale writes secrets into an entry every job on the branch can restore. The rest of the pipeline shape is on the Maven builds on GitHub Actions page.

Do I still need restore-keys if the key already covers every pom.xml?

Yes, because one dependency bump changes the hash and turns an otherwise warm run cold. A single prefix such as ${{ runner.os }}-maven- restores the most recent entry and leaves Maven to download only what changed, the fallback behavior GitHub documents for restore-keys (GitHub dependency caching reference). Pair it with -nsu and -C so the partial repository cannot resolve a different snapshot build or accept a corrupted artifact.

Should each module in a multi-module build get its own cache entry?

No. Maven resolves the whole reactor into one local repository, and module dependency sets overlap heavily, so per-module entries store the same artifacts many times and multiply the operation count. Keep one entry keyed on hashFiles('**/pom.xml'). Sizing the runner for a large reactor is covered in faster Java builds on GitHub Actions.

Does the WarpBuild cache work for Maven jobs on Windows runners?

No. WarpBuild caching is not supported on Windows runners, so a Windows Maven job stays on actions/cache@v4 with the same key and path shape, while Linux jobs use WarpBuilds/cache@v1 as a drop-in replacement. The same repository can split its Maven matrix across platforms and use the cache backend each one supports. Gradle projects cache a different directory and answer to different flags, which is the subject of caching Gradle builds in GitHub Actions.

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.