Cache Scala Dependencies in CI Pipelines

Khimananda Oli 8 min read Programming and Languages
Cache Scala Dependencies in CI Pipelines

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.

CI RunnerEphemeral VMsbt / MillLocal Cache~/.cache/coursierJARs & MetadataRemote ReposMaven / SonatypeNetwork FetchHITMISS
Dependency resolution flow when you cache Scala dependencies in CI pipelines: hits stay local, misses traverse the network.

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.

Runner InitCache Actionsbt BuildRestore RequestKey Match?YESInject CacheResolve DepsPost-Build SaveUpload Tarball
Lifecycle of cache restoration and persistence in GitHub Actions during a Scala build job.

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.

FeatureCoursier (Modern)Ivy2 (Legacy)
Cache StructureFlat / Content-addressableHierarchical org/module tree
Resolution SpeedParallel, aggressive cachingSequential, XML-heavy parsing
CI Restore TimeFast (fewer inodes)Slow (deep directory traversal)
Snapshot HandlingTTL-based refreshMetadata timestamp checks
sbt Default (1.9+)YesNo (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.

0m5m10mNo Cache9m 30sCold StartCached2m 15sWarm HitIncremental0m 45sCode Change Only
Build time comparison demonstrating the impact of caching Scala dependencies in CI pipelines versus uncached and incremental runs.

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.

Frequently Asked Questions

Use the actions/cache action targeting ~/.cache/coursier and ~/.ivy2/cache directories. Configure the key using hashFiles('**/build.sbt') to ensure cache invalidation triggers whenever your dependency definitions change in 2026 workflows.

Combine runner.os with a hash of build.sbt and project/plugins.sbt files. This ensures caches update only when dependencies actually change, preventing stale artifact issues while maximizing hit rates across different branch builds.

Yes. Coursier stores artifacts in ~/.cache/coursier/v1 with content-addressable storage, making it more cache-friendly. Legacy Ivy uses ~/.ivy2/cache with metadata-heavy structures that are harder to restore incrementally in modern 2026 pipelines.

No. Caches are scoped to individual workflow runs. Use artifacts or external storage like S3 for cross-job sharing, though this adds complexity and latency compared to native per-job caching mechanisms.

Check path accuracy and key consistency. Common failures include missing tilde expansion, incorrect hash file patterns, or OS mismatches between save and restore steps. Enable verbose logging in actions/cache to diagnose misses.

Typically two to five minutes per build by skipping redundant downloads. Large multi-module projects see greater savings, especially when resolving transitive dependencies from slow mirrors or during peak registry congestion periods.

Cache only dependency directories like ~/.cache/coursier. Avoid caching .sbt itself as it contains build state and lock files that should remain fresh to prevent compilation errors or version conflicts in 2026 environments.

Yes. Dependency jars contain no secrets. However, never cache credentials, tokens, or private repository configs. Ensure cache keys do not leak sensitive environment variables or internal naming conventions in logs.

Change the cache key prefix manually or add a version suffix to force regeneration. Alternatively, delete specific entries via the GitHub Actions cache API using gh-actions-cache CLI tool in 2026.

Most CI platforms enforce 5GB to 10GB cache limits. Older entries evict automatically via LRU policies. Monitor usage via platform dashboards and prune unused keys proactively to avoid silent cache misses during critical builds.

Yes. Specify restore-keys with partial matches like runner.os-sbt- to restore older compatible caches when exact keys miss. This provides faster warmup than cold resolution while still updating to current dependencies post-restore.

Yes. Plugins resolve independently into ~/.sbt/boot or Coursier cache. Include these paths in your cache configuration to avoid re-downloading compiler plugins, scalafmt, or sbt-native-packager on every run.

Snapshots bypass caching effectively since they change frequently. Exclude them from cache keys or use shorter TTLs. Rely on release versions for stable caching; snapshots should always resolve fresh to avoid stale development artifacts.

Run sbt update after cache restore to validate resolved artifacts match expectations. Add checksum verification steps or use coursier fetch --check to detect corruption before compilation begins in production 2026 pipelines.

Absolutely. Even small projects download dozens of transitive jars. Caching eliminates network variability and speeds up feedback loops, making it essential regardless of project scale in modern CI environments.