Scala and sbt Builds on GitHub Actions
Scala and sbt builds on GitHub Actions speed up with larger warp- runners, a warm coursier cache, and snapshot runners that carry zinc incremental state.
Last verified:
Scala and sbt builds are slow on GitHub Actions for four specific reasons: a cold coursier cache on every fresh VM, zinc incremental compilation state thrown away between runs, macro and implicit derivation work that extra cores cannot split, and a JVM that starts cold on each job. WarpBuild addresses the first two directly, with warp- labeled Linux runners from 2 to 32 vCPUs billed per minute, a cache that keeps ~/.cache/coursier and ~/.ivy2 warm, and snapshot runners that boot a job from a disk image where target/ is already populated.
Adoption is a label change plus a cache step. Point runs-on at a warp- label, swap actions/setup-java for WarpBuilds/setup-java@v5 with cache: sbt, and the same build runs on a machine with the memory a multi-subproject sbt reactor and a fleet of forked test JVMs actually need. The sections below cover the exact configuration, which size fits which job, the four bottlenecks in order, and the arithmetic behind the bill.
Overview
An sbt run spends GitHub Actions minutes in five places: resolving and downloading artifacts through coursier, compiling the build definition under project/, compiling sources with scalac, running tests under a forked JVM, and packaging or publishing.
Only two of those parallelize on vCPU count. sbt compiles independent subprojects concurrently, and it runs test tasks concurrently across subprojects. Inside a single subproject, scalac is largely single threaded on both Scala 2.13 and Scala 3, so a build laid out as one large subproject gets almost nothing from a 32 vCPU machine. Build layout decides how much of the runner you can use.
Memory is the constraint that surprises people. The sbt JVM holds the whole compiler state plus zinc's analysis, and every forked test JVM owns a separate heap on top of that. The JVM defaults the maximum heap to a quarter of physical memory, so on a 32 GB runner the sbt JVM will happily take 8 GB, and then each of eight forked test JVMs applies the same default independently. The sizing section sets explicit numbers instead.
Scala work belongs on the Linux runners: Ubuntu 22.04, 24.04, and 26.04 images on x64 and Ubuntu 24.04 and 26.04 on ARM64, each from 2 to 32 vCPUs with 150GB SSDs, carrying the same tooling as GitHub-hosted runners. Runners are ephemeral VMs, allocated per job and destroyed afterward, which is exactly why coursier arrives empty unless a cache step fills it. Cache is enabled by default on Linux runners.
Two tools matter here. Snapshot runners carry incremental state that a cache archive handles badly, and CI observability shows whether a slow job is pinned on one core in typer or spread across all of them in tests.
One scope note. This page covers sbt and the Scala compiler. Projects that build Scala through Maven or Gradle share the JVM and heap behavior described here, and the build-tool specifics live on the Java solutions page and the Maven solutions page.
Configuration
Here is a working sbt pipeline on WarpBuild runners. The setup action handles the toolchain and the sbt dependency cache, and the explicit cache step covers the coursier and ivy directories plus the sbt boot directory.
name: scala
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: warp-ubuntu-latest-x64-8x
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-java@v5
with:
distribution: temurin
java-version: '21'
cache: sbt
- uses: WarpBuilds/cache@v1
with:
path: |
~/.cache/coursier/v1
~/.ivy2/cache
~/.sbt
key: >-
${{ runner.os }}-sbt-${{ hashFiles('**/build.sbt',
'project/build.properties', 'project/plugins.sbt',
'project/**/*.scala') }}
restore-keys: |
${{ runner.os }}-sbt-
- run: sbt -batch "compile; Test/compile; test"Four details matter.
cache: sbt has to be set explicitly. Caching is off by default in the Java setup action, so an otherwise correct WarpBuilds/setup-java@v5 step caches nothing until the input is present. The accepted values are maven, gradle, and sbt. Full input reference is in the setup actions documentation.
The explicit cache step covers what the toolchain cache misses. sbt resolves through coursier, whose Linux cache lives under ~/.cache/coursier/v1, while older plugins and locally published artifacts still land in ~/.ivy2/cache. ~/.sbt holds the launcher boot jars and the global plugin set. Caching all three keeps the resolution phase off the network. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4 with identical syntax.
The cache key hashes build inputs, not sources. build.sbt, project/build.properties, and project/plugins.sbt are the files that change the dependency set, and project/**/*.scala catches multi-file build definitions. restore-keys falls back to the newest previous entry, so a single version bump starts from a mostly warm cache instead of an empty one.
One sbt invocation, several commands. sbt -batch "compile; Test/compile; test" starts the JVM once and lets the JIT warm across all three phases. Three separate sbt steps pay full startup and warmup three times.
For ARM64 targets, swap the label for a size such as warp-ubuntu-latest-arm64-8x. Temurin publishes aarch64 Linux builds, so the same build runs natively, with ARM64 rates starting at $0.003 per minute for the 2 vCPU size on the pricing page.
Sizing
The Linux x64 catalog, with per-minute rates from the WarpBuild 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 |
Pick the size by heap budget and test concurrency rather than by repository size.
8 vCPU, 32 GB, at $0.016 per minute. This is the default for a compile and test job. Put a .jvmopts file at the repository root so the sbt launcher picks it up:
-Xms2g
-Xmx6g
-Xss8m
-XX:MaxMetaspaceSize=1g
-XX:ReservedCodeCacheSize=512m-Xss8m is the one people miss. Implicit resolution and type inference recurse deeply, and derivation-heavy code overflows the default 1 MB stack with a StackOverflowError inside typer rather than a useful compile error. -XX:MaxMetaspaceSize matters because scalac loads a large class graph, and macro-heavy builds load more.
Then cap test concurrency in build.sbt so the forked heaps fit:
Global / concurrentRestrictions += Tags.limit(Tags.Test, 4)
Test / fork := true
Test / parallelExecution := true
Test / javaOptions ++= Seq("-Xmx2g")Four concurrent test tasks at 2 GB each plus the 6 GB sbt JVM is 14 GB against 32 GB of RAM, leaving the rest for the OS page cache that zinc reads and writes heavily.
16 vCPU, 64 GB, at $0.032 per minute. Move up when CI observability shows all 8 vCPUs saturated through both compile and test, which in practice means a reactor with enough independent subprojects to keep the scheduler busy. Raise -Xmx to 10g, lift Tags.limit(Tags.Test, 8), and give each forked JVM 3 GB: 10 plus 24 is 34 GB against 64 GB. If the CPU chart instead shows one core pinned while the rest idle, the build is in typer and the larger runner will not change the wall clock.
Keep scalafmt, scalafix, and doc jobs on warp-ubuntu-latest-x64-4x at $0.008 per minute; they reuse the same cache and rarely hold more than a couple of cores. Cross-building against several Scala versions is better sharded across matrix jobs than run as sbt +test on one large machine, because + walks the versions sequentially.
Worked cost model
GitHub publishes list prices for its hosted runners: Linux larger runners meter at $0.022 per minute for 8 vCPU and $0.042 per minute for 16 vCPU. Rates are from the GitHub Actions minute multipliers reference, checked on 2026-08-13.
Take a Scala monorepo consuming 18,000 runner-minutes per month on 8 vCPU machines, holding 6 GB of coursier and ivy cache, running 4,000 cache operations, keeping one snapshot alias alive for a 30-day month, and booting 900 jobs from that snapshot:
| Line item | Rate | Volume | Monthly cost |
|---|---|---|---|
| GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 18,000 minutes | $396.00 |
| warp-ubuntu-latest-x64-8x | $0.016 per minute | 18,000 minutes | $288.00 |
| WarpBuild cache storage | $0.20 per GB-month | 6 GB | $1.20 |
| WarpBuild cache operations | $0.0001 per operation | 4,000 operations | $0.40 |
| Snapshot storage | $0.025 per snapshot-hour | 720 hours | $18.00 |
| Snapshot restore | $0.04 per job | 900 jobs | $36.00 |
The WarpBuild total is $343.60 against $396.00 for the same minutes on GitHub-hosted larger runners, a difference of $52.40 per month, with the snapshot and cache line items already included.
The shapes match exactly, so the runner gap 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. One size up, 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.
Every rate above is on the pricing page.
Bottlenecks
Cold coursier cache. sbt resolves through coursier, and a fresh VM starts with nothing: the Scala compiler jars, the standard library, every plugin declared in project/plugins.sbt, and the whole transitive dependency graph download before scalac emits a single class file. On a mid-size service that is hundreds of artifacts on every push, and on a monorepo with several Scala versions it is that number multiplied. The fix is the configuration above: cache: sbt on the setup action plus the explicit cache step over ~/.cache/coursier/v1, ~/.ivy2/cache, and ~/.sbt.
Incremental compilation state lost between runs. zinc keeps an analysis file next to the class files under target/, recording source hashes, the class-to-source mapping, and the dependency graph it uses to decide what a changed file forces to recompile. Ephemeral runners delete all of it, so every GitHub Actions run is a clean build and a one-line change in a leaf file costs the same as a rewrite.
A cache archive is a poor container for that state. target/ and project/target/ churn on every commit, so the archive is written fresh each run, and the key can only be built from inputs known before compilation starts, which means the entry you restore rarely matches the tree you are about to build. Uploading and downloading tens of thousands of small class files per run also costs more time than the incremental compile saves.
A snapshot runner carries the whole disk instead. Boot the job from a saved image and target/, project/target/, the coursier cache, and the git working tree are already there, so zinc compares hashes and recompiles only what changed. The label syntax appends to the runner label:
jobs:
build:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-8x;snapshot.key=scala-monorepo' }}
steps:
- uses: actions/checkout@v4
- uses: WarpBuilds/setup-java@v5
with:
distribution: temurin
java-version: '21'
cache: sbt
- run: sbt -batch "compile; Test/compile; test"
- name: Cleanup credentials
if: github.ref == 'refs/heads/main'
run: |
rm -rf $HOME/.ssh $HOME/.aws
git clean -ffdx -e target -e project/target
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: "scala-monorepo"
fail-on-error: true
wait-timeout-minutes: 60snapshot.enabled=true always boots from the base image, which is what the main-branch job wants before it saves a clean snapshot. snapshot.key=<alias> boots from the existing snapshot for that alias and falls back to the base image when none exists. Three constraints apply: snapshots are supported on WarpBuild Cloud Ubuntu runners only, and the labels are silently ignored on BYOC, Windows, and macOS runners; every snapshot is deleted after 15 days; and /tmp is cleaned on boot, so forked test JVMs that write scratch data there start empty each run. Details are in the snapshot runners documentation and on the snapshot runners page. The same technique applied to other build tools is covered in the guide to incremental builds on GitHub Actions.
Macro-heavy modules. Automatic derivation with circe, shapeless, Magnolia, and refined, along with Scala 3 inline and given derivation, all run inside the typer phase. That phase is single threaded per compilation unit, so a module that spends most of its time expanding derivations gets nothing from a larger runner. Three changes work: switch automatic derivation to semi-automatic so each codec is materialized once at a named val rather than at every use site, move generated codecs into their own subproject so the rest of the reactor compiles alongside it, and keep derivation out of files that every other module depends on. Scala 2.13 accepts -Vstatistics to print per-phase timings when you want to confirm which phase owns the time.
JVM warmup. scalac runs on the JVM, so the first minutes of any job execute interpreted bytecode while the JIT compiles the hot paths. A job that invokes sbt three times pays that cost three times, which is why the workflow above uses a single sbt -batch invocation with a command list. Leave full tiered compilation on: -XX:TieredStopAtLevel=1 shortens startup but denies scalac the C2 output that long compiles depend on. Note that a snapshot boot does not preserve warmup. Snapshots restore the disk, and everything that lived only in memory, including JIT state and any running process, is gone.
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. The same runner labels, cache actions, and heap arithmetic apply to an sbt build on the same images.
FAQ
Which WarpBuild runner size should an sbt build start with?
Start the compile and test job on warp-ubuntu-latest-x64-8x at $0.016 per minute, with a .jvmopts file setting -Xmx6g for the sbt JVM and Tags.limit(Tags.Test, 4) capping concurrent test tasks. Move to warp-ubuntu-latest-x64-16x at $0.032 per minute when the CPU chart in WarpBuild's CI observability shows all 8 vCPUs saturated through compile and test. Keep scalafmt and scalafix check jobs on warp-ubuntu-latest-x64-4x at $0.008 per minute.
How do I cache coursier and ivy directories on WarpBuild runners?
Replace actions/setup-java with WarpBuilds/setup-java@v5 and set cache: sbt, because caching is off by default in the Java setup action. Add a WarpBuilds/cache@v1 step over ~/.cache/coursier/v1, ~/.ivy2/cache, and ~/.sbt, keyed on a hash of build.sbt, project/build.properties, and project/plugins.sbt.
Do snapshot runners work for sbt builds on macOS, Windows, or BYOC?
No. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only. A snapshot.enabled=true or snapshot.key=<alias> label on a BYOC, Windows, or macOS runner is silently ignored and the job runs normally without snapshot functionality.
Will a larger runner speed up a macro-heavy Scala module?
On its own, rarely. Macro expansion and implicit derivation run inside the typer phase, which is single threaded per compilation unit, so extra vCPUs help only when sbt has independent subprojects to compile or forked test JVMs to run alongside it. Split derivation-heavy code into its own subproject so the rest of the build compiles in parallel with it.
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.