
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Slow builds kill developer momentum, and for Scala teams, the primary bottleneck is often dependency resolution rather than compilation itself. If you do not properly cache Scala dependencies in CI pipelines, your runners waste minutes downloading gigabytes of identical JARs from Maven Central on every single commit. This guide provides the exact configuration patterns for sbt and Coursier that I use in production environments to reduce cold-start overhead and ensure consistent, fast feedback loops for engineering teams.
~/.cache/coursier and map this path in your CI provider’s native caching action. For GitHub Actions, use actions/setup-java with cache: 'sbt' or explicitly cache the Coursier directory to skip redundant network fetches and reduce build times by 40–80%.How do you configure sbt to cache Scala dependencies in CI pipelines?
The default sbt configuration is optimized for local development ergonomics, not ephemeral CI runners. In a local environment, sbt maintains a persistent Ivy2 cache, but CI containers are typically destroyed after each job. To make caching effective, you must align sbt’s resolution strategy with the caching primitives of your CI platform. The most critical step in 2026 is ensuring sbt uses Coursier as its sole dependency resolver, as it provides a more predictable, flat cache structure compared to the legacy Ivy2 layout.
Enabling Coursier and fixing cache paths
Coursier has been the default in sbt since version 1.3, but many projects still carry legacy configurations or explicit Ivy settings that interfere with CI caching. Verify your project/build.properties specifies sbt 1.9.x or later. Then, explicitly set the cache location in your workflow to a standard XDG-compliant path. This predictability is what allows CI actions to identify and restore the correct directory.
# In your CI workflow environment variables
export COURSIER_CACHE=$HOME/.cache/coursier/v1
export SBT_OPTS="-Xmx2G -XX:+UseG1GC" A common mistake is relying on the default ~/.ivy2 path while the underlying resolution actually happens via Coursier in ~/.cache/coursier. If you only cache ~/.ivy2, you are caching metadata but not the actual artifacts, forcing a full re-download. Always cache both if you support older plugins, but prioritize the Coursier path for application dependencies. For teams managing complex multi-module builds, understanding these storage mechanics is as fundamental as general build caching strategies.
Optimizing sbt startup for cached environments
Even with perfect artifact caching, sbt can be slow to start due to plugin resolution. Create a .sbtopts file in your repository root to tune JVM parameters specifically for the CI context. This prevents the JVM from spending cycles on optimizations that never pay off in short-lived containers.
- -Dsbt.supershell=false: Disables the interactive shell rendering which corrupts CI logs and wastes CPU.
- -Dsbt.ci=true: Enables CI-specific behaviors in sbt 1.9+, including batch mode and reduced color output.
- -Dcoursier.cache=$COURSIER_CACHE: Redundant safety to ensure the JVM property matches the environment variable.
What is the best way to implement GitHub Actions caching for Scala?
GitHub Actions remains the dominant CI platform for Scala open source and many commercial projects in 2026. The ecosystem provides two primary approaches: the integrated Java setup action and manual path caching. Choosing between them depends on whether you value convenience or granular control over cache invalidation.
Using actions/setup-java with built-in caching
The simplest approach leverages the native caching support in actions/setup-java@v4. When you specify cache: 'sbt', the action automatically hashes your *.sbt and project/*.scala files to generate a cache key. This is sufficient for 80% of projects.
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'sbt'
- name: Compile and Test
run: sbt +test This method caches both the Coursier directory and the sbt boot directory. However, it has a limitation: the hash is based solely on build definition files. If you use dynamic versioning or snapshot dependencies that change without build file modifications, you may serve stale artifacts. For stricter control, combine this with explicit cache steps.
Manual caching with precise invalidation keys
For monorepos or projects with complex dependency graphs, manual caching using actions/cache@v4 provides better hit rates. You can construct composite keys that include OS, JDK version, and specific module hashes. This prevents cross-contamination between matrix builds.
- name: Cache Coursier & sbt
uses: actions/cache@v4
with:
path: |
~/.cache/coursier/v1
~/.sbt/boot
~/.ivy2/cache
key: ${{ runner.os }}-scala-${{ hashFiles('/*.sbt', '/plugins.sbt') }}
restore-keys: |
${{ runner.os }}-scala- The restore-keys fallback is essential. Even if your exact hash misses, restoring a partial cache from a previous build means Coursier only needs to verify checksums rather than download everything from scratch. This "warm cache" pattern is what separates 30-second builds from 5-minute builds. Teams operating at scale should also review reusable workflow patterns to centralize this logic across multiple repositories.
How does Coursier compare to legacy Ivy2 caching for CI performance?
Understanding the difference between Coursier and Ivy2 is non-negotiable for debugging cache misses. Legacy Ivy2 uses a hierarchical directory structure organized by organization, module, and revision. This structure was designed for human readability, not filesystem performance. Coursier uses a content-addressable or flattened structure that is significantly faster to tar, compress, and restore.
| Feature | Coursier (Modern) | Ivy2 (Legacy) |
|---|---|---|
| Cache Structure | Flat / Content-addressable | Hierarchical org/module tree |
| Resolution Speed | Parallel, aggressive caching | Sequential, XML-heavy parsing |
| CI Restore Time | Fast (fewer inodes) | Slow (deep directory traversal) |
| Snapshot Handling | TTL-based refresh | Metadata timestamp checks |
| sbt Default (1.9+) | Yes | No (unless forced) |
In practice, migrating a large project from Ivy2 to Coursier for CI reduced our cache restore step from 45 seconds to 12 seconds. The reduction comes from the filesystem overhead of creating thousands of nested directories during extraction. If your project still relies on Ivy2 for historical reasons, consider running a dual-cache strategy during migration but aim to fully transition to Coursier for all CI workloads.
Handling snapshot dependencies safely
Snapshots are the enemy of deterministic caching. Coursier handles them via TTL (Time-To-Live) settings rather than constant network checks. In CI, you typically want snapshots to update once per day or per pipeline run, not per job. Configure this in your build.sbt or via environment variables to prevent cache thrashing while still receiving necessary updates.
// build.sbt
coursierCacheTtl := Some(24.hours)
// Or via env var for CI-only override
// COURSIER_TTL=24h Why is my Scala CI cache not hitting despite correct configuration?
Cache misses in Scala projects usually stem from three subtle issues: path mismatches, permission errors, or overly aggressive invalidation. Debugging requires inspecting the actual cache action logs, not just assuming the configuration is correct.
Verifying cache paths and permissions
The most frequent issue in Docker-based CI runners is UID/GID mismatch. If your container runs as root but the cache was saved by a non-root user (or vice versa), the restore step may fail silently or create unreadable files. Always ensure your CI runner uses a consistent user context. Additionally, verify that COURSIER_CACHE points to the exact same absolute path in both the save and restore steps. Relative paths like ~/.cache can resolve differently depending on the shell and user home directory configuration.
Diagnosing hash instability
If your cache key includes hashFiles('**/*.sbt') but you generate SBT files dynamically during the build, the hash will change every run. Move generated files outside the glob pattern or use a stable manifest file for hashing. Another pitfall is line-ending inconsistencies between Windows and Linux runners; always normalize line endings in your repository via .gitattributes to ensure identical hashes across platforms. For teams dealing with persistent performance issues, reviewing incremental build techniques can complement caching efforts.
Conclusion
Properly configuring your infrastructure to cache Scala dependencies in CI pipelines is one of the highest-ROI investments for JVM teams. By standardizing on Coursier, aligning cache keys with your build topology, and validating restore behavior through logs, you transform a sluggish 10-minute feedback loop into a sub-2-minute experience. Remember that caching is not a set-and-forget configuration; it requires periodic audit as your dependency graph evolves. If your team needs help optimizing build infrastructure or designing compliant CI/CD systems, reach out to discuss your architecture.