How to Cache Cargo Builds in CI for Faster Pipelines

Speed up Rust CI pipelines by caching the target directory and Cargo registry between workflow runs.

The one-line fix that takes an hour

You push a typo fix to your Rust project. The CI pipeline starts. You watch the logs scroll by. It's been twelve minutes. Your code hasn't even compiled yet. The build is stuck downloading and compiling serde, tokio, and half the ecosystem. You close the laptop. You come back an hour later. The build failed on the typo you already fixed. The pain isn't just waiting. It's the wasted compute and the broken feedback loop.

Rust compiles dependencies from source. Unlike languages that download pre-compiled binaries, Rust fetches the source code of every crate you use, compiles it, and stores the result. This gives you control over optimization flags and ensures reproducibility. It also means compiling takes time. Caching works by saving the compiled artifacts and the downloaded source code between CI runs. When you run the pipeline again, you skip the heavy lifting and reuse what you already built.

Why Rust compiles dependencies

Rust's package manager, Cargo, downloads source code, not binaries. This differs from npm or pip. When you run cargo build, Cargo fetches the source of every crate in your dependency graph. It compiles each crate with the exact flags you specified. This ensures your dependencies match your target architecture and optimization level. It also means feature flags propagate correctly. If you enable serde with derive, the compiled crate includes the derive macros. A binary registry would struggle with this combinatorial explosion of configurations.

The trade-off is compilation time. Every dependency must be compiled for every target and feature set. Caching bridges that gap. You pay the compilation cost once. Every subsequent build reuses the artifacts. The cache turns a ten-minute build into a thirty-second check.

Minimal example

The standard approach uses actions/cache in GitHub Actions. You cache three directories: the registry, the git checkouts, and the build output. The cache key hashes your Cargo.lock file. This ensures the cache invalidates only when dependencies change.

- name: Cache Cargo artifacts
  uses: actions/cache@v4
  with:
    # Save the registry, git checkouts, and the build output.
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target
    # Invalidate the cache when dependencies change.
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

Add this step before your cargo build or cargo test commands. The path list tells GitHub what to save. The key tells GitHub when to use it. If the key matches a previous run, GitHub restores the directories instantly.

Convention aside: hashFiles('**/Cargo.lock') is the community standard for the cache key. It hashes the lock file, which contains the exact resolved versions of all dependencies. Hashing Cargo.toml is risky. Cargo.toml can specify version ranges. The resolved versions live in Cargo.lock. Hashing Cargo.toml might restore a cache built with an older resolved version. Trust the lock file. It's the source of truth for your dependency tree.

How the cache key works

The cache key is a fingerprint. hashFiles('**/Cargo.lock') generates a hash of your dependency tree. If Cargo.lock hasn't changed, the hash matches. GitHub finds the cached directory and restores it. cargo build sees the compiled artifacts in target and the sources in ~/.cargo. It skips downloading and recompiling. The build finishes in seconds.

If you add a dependency, Cargo.lock changes. The hash changes. The cache misses. You pay the compilation cost once, and the new cache saves the result for next time. This strategy is efficient. You only recompile when the dependency graph changes. Source code changes reuse the cached dependencies.

You can add restore-keys as a fallback. If the exact key misses, GitHub looks for keys that match the prefix. This allows you to reuse a cache from a previous commit if the lock file hasn't changed, even if the hash calculation differs slightly. It's a safety net for edge cases.

- name: Cache Cargo artifacts
  uses: actions/cache@v4
  with:
    path: |
      ~/.cargo/registry
      ~/.cargo/git
      target
    key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
    # Fallback to a partial match if the exact key is missing.
    restore-keys: |
      ${{ runner.os }}-cargo-

Realistic workflow

A complete CI workflow includes checking out the code, installing Rust, caching artifacts, and running tests. Use dtolnay/rust-toolchain to install Rust. It's the community-preferred action. It handles toolchain installation and caching of the Rustup metadata.

name: CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          # Pin the toolchain version explicitly.
          toolchain: stable

      - name: Cache Cargo
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-

      - name: Build
        run: cargo build --verbose

      - name: Test
        run: cargo test --verbose

Convention aside: dtolnay/rust-toolchain is preferred over actions-rs/rust-setup. The actions-rs organization is deprecated. dtolnay is maintained by a core Rust contributor and integrates better with modern Rust workflows. Switch to dtolnay if you're still using the old action.

Pitfalls and strategies

The cache key must be precise. If you cache too broadly, you might restore artifacts built with a different Rust version or different features. The build might succeed but behave incorrectly. If you cache too narrowly, you miss the cache every time. The target directory can grow large. CI runners have disk limits. You might hit storage quotas.

Incremental compilation helps locally but can cause issues in CI. Incremental compilation stores intermediate artifacts to speed up subsequent builds. These artifacts are sensitive to the build environment. A corrupted incremental cache can cause cryptic compiler errors. Some teams disable incremental compilation in CI to ensure clean builds.

- name: Build
  run: cargo build --verbose
  env:
    # Disable incremental compilation in CI for reproducibility.
    CARGO_INCREMENTAL: 0

Convention aside: CARGO_INCREMENTAL=0 is a common pattern in CI. It trades a small amount of build speed for reliability. If you hit weird compiler errors that only appear in CI, try disabling incremental compilation. It often reveals the real issue.

A stale cache is worse than no cache. It hides bugs and wastes time debugging phantom errors. Validate your cache key strategy. Ensure it invalidates when Rust versions or critical configuration changes. A cache miss is a tax you pay once. A cache hit is a dividend you collect forever.

When to use what

Use actions/cache with hashFiles('**/Cargo.lock') when you use GitHub Actions and want a simple, reliable cache that invalidates on dependency changes. Use Swatinem/rust-cache when you want a drop-in action that handles cache keys, paths, and restoration logic automatically. Use a remote cache server like cargo-remote-cache or sccache when you have a large team and need to share caches across repositories or persist caches longer than CI limits allow. Reach for CARGO_INCREMENTAL=0 in CI when you prioritize build reproducibility over speed and want to avoid incremental compilation bugs.

Where to go next