Faster Java Builds on GitHub Actions

Faster Java builds on GitHub Actions come from bigger warp- runners, a warm Maven repository cache, and parallel Surefire forks. Sizing and cost math inside.

Last verified:

Faster Java builds on GitHub Actions come from three levers: enough vCPUs to run Maven modules and test forks in parallel, a local repository that stays warm between jobs, and JVM heap settings sized to the machine instead of left to defaults. WarpBuild covers the first two directly, with warp- labeled runners from 2 to 32 vCPUs billed per minute and a cache that keeps ~/.m2/repository populated across runs.

Switching is a one line change. Point runs-on at a warp- label, add a cache step for the Maven local repository, and the same workflow runs on a machine with the cores and memory a multi-module reactor build can use. The sections below cover the exact configuration, which runner size fits which build phase, and the arithmetic behind the bill.

Overview

Java spends GitHub Actions minutes in five places: resolving and downloading dependencies, compiling modules with javac, running unit tests under the Maven Surefire Plugin, running integration tests under Failsafe, and packaging jars. Two of those phases parallelize well. Maven builds independent modules concurrently with -T 1C (one build thread per core), and Surefire forks multiple test JVMs with forkCount. Both scale with vCPU count, which is why the runner size shows up directly in wall-clock time for multi-module projects.

Memory matters as much as cores here, because every forked JVM owns a heap. A runner that forks eight test JVMs needs room for eight heaps plus the Maven reactor JVM plus the operating system's file cache, which javac and jar packaging use heavily. The sizing section below has the arithmetic.

For Java, the Linux runners do the work: the runner catalog lists Ubuntu 22.04, 24.04, and 26.04 images on x64 and Ubuntu 24.04 and 26.04 on ARM64, each in sizes from 2 to 32 vCPUs with 150GB SSDs. The images carry the same tooling as GitHub-hosted runners, so JDK distributions and Maven are already installed. Runners are ephemeral VMs, freshly allocated per job and destroyed afterward, which is exactly why the cache step matters: without it, every job starts with an empty ~/.m2.

Cache is enabled by default on all Linux runners. The WarpBuild cache documentation covers the general mechanics; the next section applies them to Maven.

One scope note. This page covers Maven and general JVM tuning, which also applies to sbt and plain javac builds. Gradle projects share the JVM behavior described here, and Gradle's own layers, the build cache and the configuration cache, are covered on the Gradle solutions page. Android builds add the SDK and emulator layers on top, covered on the Android solutions page.

Configuration

Here is a working Java pipeline on WarpBuild runners. The unit test job runs on 8 vCPUs and stops at the test phase, so Failsafe never runs there. The integration job runs on 16 vCPUs with a Postgres service container and activates a profile that binds Failsafe to the integration-test and verify phases. Both jobs restore the Maven local repository through WarpBuilds/cache.

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

env:
  MAVEN_OPTS: -Xmx3g

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

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

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

      - run: mvn -B -T 1C test

  integration-tests:
    runs-on: warp-ubuntu-latest-x64-16x
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: ci
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    steps:
      - uses: actions/checkout@v4

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

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

      - run: mvn -B -T 1C verify -Pintegration-tests

Four details matter here.

The cache key hashes every pom.xml. The key changes exactly when the dependency set can change, and restore-keys falls back to the newest previous cache, so a version bump starts from a mostly warm repository instead of an empty one. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4; the syntax is identical. Cache entries expire after 7 days without use.

Heap sizes are explicit. The JVM's default ergonomics cap the maximum heap at a quarter of physical memory. On a 32 GB runner that hands a single JVM up to 8 GB, which looks safe until Surefire forks eight JVMs and each fork applies the same default. MAVEN_OPTS: -Xmx3g bounds the reactor JVM, and the Surefire argLine below bounds each fork at 2 GB. On warp-ubuntu-latest-x64-8x that budgets 3 GB for the reactor plus eight forks at 2 GB each, 19 GB total against 32 GB of RAM, leaving headroom for the OS file cache. On warp-ubuntu-latest-x64-16x, sixteen forks plus the reactor stay under 36 GB of the 64 GB available.

Surefire forks per core. Set forkCount to 1C in the plugin configuration so the fork count follows the runner size without workflow edits:

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

The integration profile keeps Failsafe out of PR unit runs. mvn test stops before the integration-test phase by definition, so the fast job never pays for the service stack. The -Pintegration-tests profile in the second job is the conventional place to bind the Failsafe plugin and point the JDBC URL at the service container on localhost:5432.

If you prefer one less step, replace actions/setup-java with WarpBuilds/setup-java@v5 and set cache: maven. It accepts the same inputs as the upstream action and routes the dependency cache to WarpBuild Cache automatically, so the explicit WarpBuilds/cache step can be dropped.

For ARM64 targets, swap the label to a size such as warp-ubuntu-latest-arm64-8x. Temurin and the other common JDK distributions publish aarch64 Linux builds, so the same workflow compiles and tests natively, with ARM64 rates starting at $0.003 per minute for the 2 vCPU size.

Sizing

The Linux x64 catalog, with per-minute rates from the WarpBuild pricing page:

Runner labelvCPUMemoryStoragePrice per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128 GB150GB SSD$0.064

Map the sizes to build phases rather than to repository size.

Compile phase. javac itself runs largely single-threaded per module, so compile parallelism comes from the width of the reactor. -T 1C on a reactor with 20 independent modules keeps 8 or 16 cores busy; a deep chain of dependent modules serializes no matter how many cores are present. A single-module project or a reactor under roughly ten modules compiles comfortably on warp-ubuntu-latest-x64-4x. Wide reactors with dozens of modules are where warp-ubuntu-latest-x64-16x shortens the compile phase, because the module scheduler finally has enough lanes.

Surefire phase. Unit tests are usually the largest share of the wall clock and the easiest to scale. With forkCount at 1C and independent, CPU-bound tests, the phase spreads across every core: four forks on the 4x, eight on the 8x, sixteen on the 16x, each fork within its 2 GB argLine budget. Suites with shared static fixtures or heavy I/O stop scaling earlier; if the CPU chart sags while the test phase drags, the constraint is the tests themselves rather than the machine.

Integration test phase. Failsafe is usually the serial tail. A bigger runner helps only when the integration tests actually run in parallel. warp-ubuntu-latest-x64-16x earns its rate when the job runs a service container stack plus parallel forks, since Postgres, the application under test, and eight or more forks compete for cores on anything smaller. When the integration suite is strictly serial, buy the smallest runner that holds the service stack, often the 4x, and get parallelism by sharding the suite across matrix jobs instead.

Static analysis jobs, Checkstyle, SpotBugs, and Error Prone javac passes, reuse the cached repository and rarely hold more than a few cores busy; keep them on warp-ubuntu-latest-x64-4x. The 32x size pays off only when a 16x run shows sustained full-core saturation through compile and test, which in Java usually means a very wide reactor with a large, well-parallelized suite.

Worked cost model

GitHub publishes list prices for its hosted runners: the standard Linux runner meters at $0.006 per minute on private repositories, and Linux larger runners meter at $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU. Rates are from the GitHub Actions minute multipliers reference and github.com/pricing, checked on 2026-08-13.

Take a Java monorepo whose pipeline consumes 25,000 runner-minutes per month on 8 vCPU machines, storing 12 GB of Maven cache and performing 6,000 cache operations:

Line itemRateVolumeMonthly cost
GitHub-hosted Linux 8 vCPU larger runner$0.022 per minute25,000 minutes$550.00
warp-ubuntu-latest-x64-8x$0.016 per minute25,000 minutes$400.00
WarpBuild cache storage$0.20 per GB-month12 GB$2.40
WarpBuild cache operations$0.0001 per operation6,000 operations$0.60

The WarpBuild total is $403.00 against $550.00 on GitHub-hosted larger runners for the same minutes, a difference of $147.00 per month. The shapes match exactly, so the gap in the table is pure rate difference: 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. GitHub list price checked on 2026-08-13. Every rate above is on the pricing page.

Full rates for every size and platform are on the pricing page.

Bottlenecks

Four failure modes account for most slow Java pipelines on GitHub Actions.

Cold ~/.m2. Every runner starts as a fresh VM with an empty local repository, so an uncached job resolves and downloads every dependency and every Maven plugin before the first line of javac output. A mid-size Spring Boot service pulls hundreds of artifacts this way on every push. The fix is the cache step above: ~/.m2/repository keyed on the pom.xml hash, with restore-keys for partial reuse. Maven repositories grow past what teams expect once every plugin version and transitive dependency lands in them; if cache eviction is undoing the work, the guide to GitHub Actions cache size limits covers where limits come from and what to do about them.

JVM warmup on short-lived jobs. Every forked JVM starts cold: class loading, bytecode verification, then interpreted execution until the JIT compiler warms the hot paths. A fork that lives 30 seconds spends much of its life warming up rather than testing. Keep reuseForks set to true so each fork amortizes warmup across many test classes, and avoid configurations that fork per class. For build-tool JVMs and short smoke-test jobs, -XX:TieredStopAtLevel=1 trades peak JIT output for fast startup, a good trade for a JVM that runs under a minute. Leave full tiered compilation on for long suites, where peak throughput wins the trade back.

Serialized integration tests behind service containers. The common pattern is one Postgres in the services block, one shared schema, and a suite forced to run serially because every test writes to the same tables. The runner then idles fifteen cores while one fork walks the suite. Three fixes stack: give each fork its own schema or database by templating ${surefire.forkNumber} into the JDBC URL, move to Testcontainers so each fork owns an isolated container, or shard the integration suite across matrix jobs so the serial sections at least run side by side on separate runners.

Annotation processing. Lombok, MapStruct, Dagger, and similar processors run inside javac, and on a fresh VM every build is effectively a clean build, so their full cost recurs on each push. Declare processors explicitly in the maven-compiler-plugin's annotationProcessorPaths so javac skips scanning the whole compile classpath for them, keep processor-heavy generated-code modules separate so the rest of the reactor compiles without waiting on them, and remove processor declarations from modules that generate nothing.

Telling these four apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a job idling through a serial integration suite looks visibly different from one saturating every core through Surefire. Workflow-level tactics that apply beyond Java, including job parallelization and cancellation of superseded runs, are in the guide to speeding up GitHub Actions.

Proof

Public Java projects run production workloads on warp- labels, and the workflow files are open to read.

  • restatedev/sdk-java builds and tests the Restate Java SDK with Gradle on warp-ubuntu-latest-x64-4x, compiling on a Temurin JDK and uploading JUnit test results on every pull request and push to main.
  • 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.

FAQ

Which WarpBuild runner size should a Java project start with?

Start on warp-ubuntu-latest-x64-8x at $0.016 per minute with Maven's -T 1C and Surefire forkCount at 1C. Move the build and test job to warp-ubuntu-latest-x64-16x when the CPU chart in WarpBuild's CI observability shows all 8 vCPUs saturated through compile and test, and keep Checkstyle and SpotBugs jobs on warp-ubuntu-latest-x64-4x.

How do I cache the Maven local repository on WarpBuild runners?

Add WarpBuilds/cache@v1 with ~/.m2/repository as the path and a key built from hashFiles('**/pom.xml'). Or replace actions/setup-java with WarpBuilds/setup-java@v5 and set cache: maven. Both are drop-in compatible with the upstream actions they replace.

Do WarpBuild runners work with Gradle projects too?

Yes. The runner images carry the same tooling as GitHub-hosted runners, so Gradle wrappers run unchanged on warp- labels. Gradle-specific caching, including the build cache and configuration cache, is covered on the Gradle solutions page.

Is WarpBuild SOC 2 compliant?

The audit evidence is published at trust.warpbuild.com.

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.