Spring Boot Builds on GitHub Actions
Build and test Spring Boot on GitHub Actions with a warm Maven cache, reused container images, and warp- runners sized to heap per fork. Rates and YAML inside.
Last verified:
A Spring Boot build on GitHub Actions spends its minutes in four places: resolving dependencies onto a fresh machine, compiling and packaging the jar, starting application contexts under @SpringBootTest, and pulling and booting database containers for the integration suite. Point runs-on at a warp- label, cache the Maven or Gradle dependency directory, carry the container images between runs, and size the runner against heap per test fork rather than repository size.
This page gives the workflow that does all four, the cache and image reuse configuration behind it, the heap arithmetic that decides which runner size to buy, and the monthly bill against GitHub-hosted list prices.
Overview
Spring Boot adds two cost centers on top of a plain Java build. The first is the application context. Every distinct test configuration starts its own context, which runs component scanning, auto-configuration, bean creation, and usually a Flyway or Liquibase migration against a database. The Spring TestContext framework caches contexts and reuses them across test classes, with a default cache size of 32 contexts. A suite that produces more configurations than that evicts and rebuilds contexts inside a single run.
The second is the container fleet. Integration tests reach for Testcontainers or the GitHub Actions services block to get Postgres, Kafka, Redis, or LocalStack, and every job starts on a fresh virtual machine with an empty image store. The pull happens again on every run, before the first test executes.
Neither cost is a CPU problem alone. Memory decides how many test JVMs and containers fit at once, and the image store decides how much of the job is spent waiting on the registry.
Spring Boot work belongs on the Linux runners: the cloud runners documentation lists Ubuntu 22.04, 24.04, and 26.04 images from 2 to 32 vCPUs with 150GB SSDs, carrying the same tooling as GitHub-hosted runners, so ./mvnw and ./gradlew run unchanged. Two other parts of the product surface apply here: snapshot runners capture a runner VM mid-workflow so later jobs boot with the image store already populated, and CI observability streams system metrics from the runner alongside the job logs. The surface also includes remote Docker builders, an MCP server, and the Action Debugger.
Configuration
The workflow below splits unit tests from integration tests. Unit tests stop at the test phase on 8 vCPUs. Integration tests run verify on 16 vCPUs, on a runner that boots from a snapshot holding the Testcontainers images.
name: spring-boot
on:
push:
branches: [main]
pull_request:
env:
MAVEN_OPTS: -Xmx2g
jobs:
unit-tests:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-java@v5
with:
distribution: temurin
java-version: '21'
cache: maven
- run: ./mvnw -B -T 1C test
integration-tests:
runs-on: ${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-16x;snapshot.key=spring-boot-it' }}
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-java@v5
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Warm the image store
run: |
docker pull postgres:16
docker pull redis:7
- run: ./mvnw -B verify -Pintegration-tests
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: spring-boot-itFour details carry the weight.
The dependency cache rides on the setup step. WarpBuilds/setup-java@v5 accepts the same inputs as the upstream setup action and routes the Maven, Gradle, or sbt dependency cache to WarpBuild Cache, so ~/.m2/repository arrives populated without a separate cache step. For Gradle projects, set cache: gradle, or use WarpBuilds/gradle-actions when the build cache and configuration cache also need to survive between runs. Cache keys and the fallback behavior are covered in how to cache Maven dependencies in GitHub Actions.
Container images ride in the snapshot. The docker pull step is separate so the job log times it. On main, snapshot.enabled=true boots the base image and WarpBuilds/snapshot-save captures the VM after the images are resident. Pull requests boot from that alias with snapshot.key, so the image store is already warm. Snapshot boot takes 45 to 60 seconds, and the snapshot labels are silently ignored on BYOC, Windows, and macOS runners. Where a single database is enough, the plain services block is simpler; the tradeoffs are in service containers in GitHub Actions.
Fork count and heap are set together. Leaving both to defaults is what turns a 16 vCPU runner into a swap test. The JVM caps its default maximum heap near a quarter of physical memory, and each Surefire or Failsafe fork applies that default independently:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkCount>1C</forkCount>
<reuseForks>true</reuseForks>
<argLine>-Xmx1500m -XX:MaxMetaspaceSize=512m</argLine>
</configuration>
</plugin>reuseForks keeps the Spring context cache alive across test classes inside a fork. Setting it to false throws that cache away between classes and pays context startup again.
Integration tests stay out of the fast job. mvn test stops before the integration-test phase, so the unit job never boots a container. The -Pintegration-tests profile in the second job is the conventional place to bind the Failsafe plugin.
Sizing
Linux x64 rates from the pricing page:
| Runner label | vCPU | Memory | Storage | Price per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
| warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 |
Size the machine with one sum: forks times the per-fork budget, plus container memory, plus 2 GB for the operating system and its file cache. Budget roughly 1.4 times the configured heap per fork, because metaspace, thread stacks, and the code cache sit outside -Xmx, and a Spring context holds a large class graph in metaspace.
| Runner label | Forks at 1C | Heap per fork | JVM memory total | Left for containers and OS |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-4x | 4 | 1.5 GB | 8.4 GB | 7.6 GB |
| warp-ubuntu-latest-x64-8x | 8 | 1.5 GB | 16.8 GB | 15.2 GB |
| warp-ubuntu-latest-x64-16x | 16 | 2 GB | 44.8 GB | 19.2 GB |
Read the table against the workload. A unit suite with mocked collaborators and no containers fits the 4x size. A suite that starts a full context per fork lands on the 8x size, where 15.2 GB still covers a Postgres container and the file cache. The integration job is where the 16x size earns its rate, because Postgres, Redis, and sixteen Failsafe forks all want memory at once; when that job runs at 16 forks, drop forkCount to 0.5C rather than pushing per-fork heap down to 1 GB, since eight healthy forks finish sooner than sixteen forks that spend their time in garbage collection.
The 32x size pays off only when a 16x run shows every core saturated through the whole test phase. Spring suites usually stop scaling before that, held by context startup and container readiness rather than by cores.
Worked cost model
A service running 12,000 minutes per month of unit jobs on 8 vCPUs and 6,000 minutes of integration jobs on 16 vCPUs, with 600 integration jobs restoring one snapshot that is kept for the month, 8 GB of dependency cache, and 5,000 cache operations:
| Line item | Rate | Volume | Monthly cost |
|---|---|---|---|
| GitHub-hosted 8-core Linux larger runner | $0.022 per minute | 12,000 minutes | $264.00 |
| GitHub-hosted 16-core Linux larger runner | $0.042 per minute | 6,000 minutes | $252.00 |
| warp-ubuntu-latest-x64-8x | $0.016 per minute | 12,000 minutes | $192.00 |
| warp-ubuntu-latest-x64-16x | $0.032 per minute | 6,000 minutes | $192.00 |
| WarpBuild snapshot restore | $0.04 per job | 600 jobs | $24.00 |
| WarpBuild snapshot storage | $0.025 per snapshot-hour | 730 hours | $18.25 |
| WarpBuild cache storage | $0.20 per GB-month | 8 GB | $1.60 |
| WarpBuild cache operations | $0.0001 per operation | 5,000 operations | $0.50 |
The WarpBuild side totals $428.35 against $516.00 for the same minutes on GitHub-hosted larger runners, a difference of $87.65 per month. The shapes match on both rows, so the runner gap is rate alone: 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, and 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 are from the GitHub Actions minute multipliers reference, checked on 2026-08-13.
Every rate above is on the pricing page.
Bottlenecks
Context churn. The Spring TestContext framework keys its cache on the full configuration of a test class. Adding one @MockBean, one @TestPropertySource value, or one extra @ActiveProfiles combination produces a new key and a new context. @DirtiesContext goes further and evicts, forcing the next class to rebuild. Count the distinct keys in the suite before buying a bigger runner: a suite with 60 configurations against a default cache of 32 rebuilds contexts inside one run no matter how many cores are present. Collapse the variants onto a handful of shared base test classes, and reach for @DirtiesContext only where a test genuinely mutates singleton state.
Migrations per context. Flyway and Liquibase run at context startup, so every rebuilt context replays the whole migration history against the container database. On a schema with hundreds of migrations that dominates the suite. Keep one database container per fork rather than per class, and run the migration set once at fork startup rather than under @DirtiesContext.
Image pulls. A three container Testcontainers stack downloads its layers on every uncached run, before any test code executes. The snapshot pattern above removes the repeat pull. Where a snapshot does not apply, pin digests so the pull is deterministic and authenticate to the registry so anonymous pull limits stop applying.
Packaging on every pull request. spring-boot:build-image runs a buildpack build, which is a container build wearing a Maven goal's name. Running it on each pull request adds a full image build to a job that only needed a jar. Keep bootJar or package on pull requests and build the image on main, or move the image build to a remote Docker builder where the layer cache persists between runs.
Telling these apart is a measurement problem. A job idling while contexts rebuild and a job saturating every core through Failsafe look identical in the GitHub Actions timing summary and different in the runner metrics that CI observability streams next to the job logs. Sizing arithmetic for the JVM more broadly is in the guide to sizing runners for Java builds, and the Maven and Gradle layers underneath Spring Boot are covered in faster Java builds on GitHub Actions.
Proof
Public JVM projects run production workloads on warp- labels with readable workflow files.
- 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 verifyonwarp-ubuntu-latest-x64-2xin its release workflow.
The runner labels, the cache action, and the heap arithmetic in those workflows are the same ones a Spring Boot service uses; the Spring specific work is the context cache and the container stack described above.
FAQ
Which runner size fits a Spring Boot build?
Run the unit test job on warp-ubuntu-latest-x64-8x at $0.016 per minute, which holds eight Surefire forks at a 1.5 GB heap each inside 32 GB. Move the integration job to warp-ubuntu-latest-x64-16x at $0.032 per minute when Testcontainers containers and test JVMs compete for the same 32 GB.
How do I keep Testcontainers images from being pulled on every run?
Pull the images in their own step, then capture the runner VM with WarpBuilds/snapshot-save and boot later jobs from that alias with snapshot.key on the runs-on label. Snapshot boot takes 45 to 60 seconds, and the labels are silently ignored on BYOC, Windows, and macOS runners.
Why is the Spring test suite slower than the number of tests suggests?
Each distinct test configuration builds its own application context, and the Spring TestContext framework caches at most 32 of them by default. @MockBean, @TestPropertySource, and @DirtiesContext each create or evict contexts, so a suite with dozens of variants pays repeated context startup instead of reusing one.
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.