How do I cache Rust builds in CI without rebuilding everything?
A Rust CI cache has to keep Cargo’s downloaded crates and the compiled dependencies in target/ between runs. It has to follow changes to Cargo.lock and the toolchain, and every machine that builds the project has to reach it. On GitHub Actions, Swatinem/rust-cache meets most of these requirements. On other CI platforms, a platform cache, sccache, or cargo-chef each meets some of them. Dagger meets the same requirements in one place. Its cache volumes keep both directories between runs, and the same checks run on a laptop and in Dagger Cloud.
What a Rust CI cache has to do
A Rust CI cache has four requirements. Each GitHub-hosted runner is a new virtual machine (GitHub-hosted runners). A job on a new machine starts without Cargo’s files, so Cargo downloads every crate and compiles every dependency again. A cache that meets these requirements lets Cargo skip that work:
- Keep the downloaded crates. Cargo keeps them in its home directory,
~/.cargo, or/usr/local/cargoin the officialrustimage. Itsregistrydirectory holds the crates from crates.io, and itsgitdirectory holds the Git dependencies (Cargo home). - Keep the compiled dependencies. Cargo writes the output of a build to the
target/directory (build cache). When the previoustarget/directory is present, Cargo recompiles only the crates that changed (Cargo source). - Follow changes to Cargo.lock and the toolchain. Cargo’s fingerprint of each crate includes the
rustcversion and the fingerprints of its dependencies (Cargo source). After a dependency update or a new toolchain, Cargo recompiles the affected crates. The cache then has to store the new output, and an older cache still saves the work for the crates that did not change. - Share the cache across CI machines and laptops. A cache helps only the builds that can read it. When jobs run on many ephemeral runners, or on more than one CI platform, each of them needs the same cache.
The sections below meet these requirements by hand, then with Dagger.
Keep Cargo’s directories on a long-lived runner
On a self-hosted runner that lives a long time, keep both directories on the runner’s disk. Set CARGO_HOME and CARGO_TARGET_DIR to directories on that disk. Nothing moves between runs, and Cargo’s fingerprints follow the changes to Cargo.lock and the toolchain. The cache serves only that runner. Cargo cleans its global cache in Cargo’s home directory automatically, but not build artifacts (Cargo configuration). Old artifacts stay in the target directory until you remove them.
Cache Cargo’s home and target/ with Swatinem/rust-cache
On GitHub Actions, the Swatinem/rust-cache action meets the first three requirements, and the fourth for jobs in GitHub Actions. Install the toolchain before the cache step, because the action puts the rustc version in its cache key. In a workflow file such as .github/workflows/ci.yml, the steps are:
- uses: actions/checkout@v6
- run: rustup toolchain install stable --profile minimal
- uses: Swatinem/rust-cache@v2
with:
shared-key: "ci"
save-if: ${{ github.ref == 'refs/heads/main' }}
- run: cargo test --locked
The README says how the action meets each requirement:
- Keep the downloaded crates. It caches
~/.cargo. - Keep the compiled dependencies. It caches
./target. Before it saves the cache, it removes the workspace crates and the incremental build artifacts. The cache keeps only compiled dependencies. The action also setsCARGO_INCREMENTAL=0. - Follow changes to Cargo.lock and the toolchain. Its cache key includes the
rustcversion, a hash of everyCargo.lockandCargo.toml, the toolchain files, and compiler variables such asRUSTFLAGS. When the lockfile changes, it restores the cache of the previous lockfile. Then only the changed dependencies rebuild. - Share the cache.
shared-keygives several jobs one cache.save-iflimits saves tomain.
The action stores its cache in the GitHub Actions cache, so GitHub’s cache limits apply to it.
Cache Cargo with a platform cache keyed on Cargo.lock
On a CI platform without a Rust-specific action, use the platform’s own cache with a key from Cargo.lock. List Cargo’s home and target/ as the cache paths to keep the downloaded crates and the compiled dependencies. The key from Cargo.lock makes a new cache when the dependencies change. GitHub Actions cannot change a cache after it saves it, so a new key is the only way to store new output (GitHub docs).
On GitHub Actions, the platform cache is actions/cache with hashFiles('**/Cargo.lock') in the key (GitHub docs). On GitLab CI/CD, it is cache:key:files, which makes a new key when the listed files change. GitLab cache paths are relative to the project directory, so set CARGO_HOME to a directory in the project. In .gitlab-ci.yml:
variables:
CARGO_HOME: $CI_PROJECT_DIR/.cargo
cache:
key:
files:
- Cargo.lock
paths:
- .cargo/
- target/
If a rust-toolchain.toml file pins the toolchain, add it to the key files. Then a new toolchain also makes a new cache.
Share compiled crates across machines with sccache
sccache shares compiled output across machines. Cargo’s own docs name it as a way to “share built dependencies across different workspaces” (build cache). It caches the output of each rustc call, not a whole directory, so it does not keep the downloaded crates. It stores results on local disk or in a remote backend: S3, R2, Google Cloud Storage, Azure, Redis, or the GitHub Actions cache.
To turn it on, set RUSTC_WRAPPER=sccache in the build environment. For an S3 bucket, also set SCCACHE_BUCKET and SCCACHE_REGION (S3 setup). Many machines can read and write one bucket, so sccache fits a fleet of ephemeral runners, such as CI agents on Kubernetes.
The Rust notes of sccache list its limits for Rust:
- Incremental compilation in
rustcmust be off. - sccache does not cache crates that call the system linker:
bin,dylib,cdylib, andproc-macrocrates. These crates compile on every run. - Procedural macros that read files from the file system may not cache correctly.
Cache Rust dependencies in Docker builds with cargo-chef
When CI builds a container image, cargo-chef keeps the compiled dependencies in a Docker layer of its own. cargo chef prepare writes a recipe, recipe.json, from the manifests and the lockfile. cargo chef cook builds only the dependencies from that recipe. Docker keeps that layer in its cache until the dependencies change. The README warns: “You must use the same Rust version in all stages”. The cache reaches the machines that can read the Docker layer cache.
Rust cache options against the requirements
Each option meets a different part of the requirements:
| Option | Keep the downloaded crates | Keep the compiled dependencies | Follow changes to Cargo.lock and the toolchain | Share the cache |
|---|---|---|---|---|
| Long-lived runner | Yes, in CARGO_HOME on its disk |
Yes, in CARGO_TARGET_DIR on its disk |
Cargo’s fingerprints | Only that runner |
| Swatinem/rust-cache | Yes, ~/.cargo |
Yes, dependencies only | A key with rustc, the lockfiles, and RUSTFLAGS, and a fallback to the previous lockfile |
GitHub Actions jobs, with shared-key |
Platform cache keyed on Cargo.lock |
The paths you list | The paths you list | A key from the files you list | Jobs on that CI platform |
| sccache | No | The output of each rustc call, except linked crates |
Each rustc call caches on its own |
Every machine that reads the backend |
| cargo-chef | In the dependency layer | Yes, as a Docker layer | A recipe from the manifests and the lockfile | Machines that read the Docker layer cache |
Limits of these caches as a project grows
The hand-built caches strain on the same requirements as the project grows:
- Keep the compiled dependencies. The target directory is large. An archive takes longer to upload and download as the workspace grows. GitHub gives each repository 10 GB of cache by default. It removes entries that no run accessed in over 7 days. Above the limit, it removes the entries with the oldest last access. Pull request runs can restore caches from the default branch, and their own caches use the same 10 GB.
- Follow changes to Cargo.lock and the toolchain. Keys miss. A key from
Cargo.lockmisses after each dependency update, unless a fallback key restores an older cache. A new toolchain or a newRUSTFLAGSvalue starts a new cache. - Share the cache across CI machines and laptops. Each setup belongs to one platform. rust-cache is a GitHub Action. GitLab, Buildkite, and Jenkins pipelines each need their own cache configuration. The CI platform caches do not apply when a developer runs the same steps on a laptop. sccache reaches every machine, but it does not cache linked crates. Binaries and proc-macro crates compile on every run.
Cache Rust builds with Dagger cache volumes
Dagger runs each Cargo command in a container and keeps Cargo’s home and target/ in cache volumes. A cache volume is a directory whose contents persist across Dagger sessions. It lives on the Dagger Engine, the program that runs the containers. No archive moves between steps. On the next run, Cargo finds its compiled dependencies in the same place and rebuilds only what changed. Dagger also caches each step by its inputs, so a rerun skips the steps whose inputs did not change.
The official Dagger modules cover tools such as Go, pytest, and ESLint, but not Rust. A search for a Rust module finds nothing:
dagger module search rust
No matching modules found.
So the Cargo steps go in the project’s own Dagger module: a package of functions and checks that build and test the project. Write it in Dang, Dagger’s native language for modules. Run these commands from the root of the Git repository:
dagger init
dagger module install dagger.io/sdk/dang
dagger module init dang
dagger init creates dagger.toml, the workspace configuration, or uses the file that is there (Quickstart). dagger module init without a name creates the module at .dagger/modules/<project>-dev. It also makes the module the workspace main module. The command returns a changeset, a diff of the proposed files, and Dagger shows it for review before it writes any file. Pass -y to apply the changeset without the prompt. A headless run, such as CI or an AI agent, needs -y.
The commands also write dagger.lock, which pins the images and Git refs that the workspace uses. After the first dagger check, it pins the rust image too. Commit dagger.lock with dagger.toml and .dagger/.
Write the Cargo checks in Dang
The module runs cargo fmt, cargo clippy, and cargo test as checks. A check is a function that validates the project, such as a test run or a linter. dagger check runs every check. Replace the starter code in .dagger/modules/<project>-dev/main.dang with the code below. ProjectDev stands for the type name that dagger module init generated, so keep your own type name.
"""
Build, lint and test the Rust workspace with Cargo's caches kept between runs.
"""
type ProjectDev {
let source: Directory!
"""
Rust version, used for the image tag and the target cache name.
"""
pub rustVersion: String!
new(ws: Workspace!, rustVersion: String! = "1.99") {
self.source = ws.directory("/", exclude: ["target", ".git", ".dagger"])
self.rustVersion = rustVersion
self
}
"""
A Rust toolchain with the workspace mounted and Cargo's caches attached.
"""
pub rustEnv: Container! {
container
.from(`rust:${rustVersion}`)
.withExec(["rustup", "component", "add", "clippy", "rustfmt"])
.withMountedCache("/cargo", cacheVolume("cargo-home"))
.withEnvVariable("CARGO_HOME", "/cargo")
.withMountedDirectory("/src", source)
.withMountedCache("/src/target", cacheVolume(`cargo-target-${rustVersion}`))
.withWorkdir("/src")
}
"""
Check formatting with rustfmt.
"""
pub fmt: Void @check {
rustEnv.withExec(["cargo", "fmt", "--all", "--check"]).sync
null
}
"""
Lint every crate and target with Clippy, denying warnings.
"""
pub clippy: Void @check {
rustEnv
.withExec(["cargo", "clippy", "--workspace", "--all-targets", "--locked", "--", "-D", "warnings"])
.sync
null
}
"""
Build and run the workspace's tests.
"""
pub test: Void @check {
rustEnv.withExec(["cargo", "test", "--workspace", "--locked"]).sync
null
}
}
rustEnv mounts two cache volumes, and all checks share them. The sections below show how each volume meets the requirements. The rustup component add step installs Clippy and rustfmt, because the official rust image has only cargo, rustc, and the standard library. Dagger caches this step like any other step.
Keep the downloaded crates in a Dagger cache volume
The cargo-home volume keeps the downloaded crates. All checks and all Rust versions share it. CARGO_HOME points to the volume, not to /usr/local/cargo in the image. This is the directory that rust-cache keeps on GitHub Actions.
Cargo’s docs advise CI to cache only parts of Cargo’s home. An archive of all of it holds the sources of each crate twice (Cargo home). A cache volume is not an archive and does not move between runs, so the module mounts all of Cargo’s home. Cargo keeps the lock for its package cache in Cargo’s home directory (Cargo source), so that lock is in the volume too.
Keep the compiled dependencies in a Dagger cache volume
The target volume, mounted at /src/target, keeps the compiled dependencies. It works like CARGO_TARGET_DIR on a long-lived runner. The directory stays where Cargo left it, and Cargo recompiles only the crates that changed. Unlike rust-cache, the module does not remove the workspace crates, so the volume also keeps the project’s own compiled crates.
The source excludes target, .git, and .dagger. Dagger does not upload your local build output, and changes to it do not invalidate Dagger’s cache.
The checks share one target directory and one Cargo home. dagger check runs all checks in parallel, and Cargo’s file locks in the two volumes make the checks take turns. When one check waits for another, Cargo prints Blocking waiting for file lock on package cache or Blocking waiting for file lock on build directory.
A target volume that lives a long time grows, as a local target/ directory does. Cargo cleans its global cache in Cargo’s home directory automatically, but not build artifacts (Cargo configuration). To start a target directory again on purpose, change the name of the volume.
Follow changes to Cargo.lock and the toolchain with Dagger
Cargo writes to the cache volumes on every run, so no cache key has to change when Cargo.lock changes. After a dependency update, Cargo finds the old artifacts in the target volume. It recompiles only the changed dependencies and the crates that depend on them (Cargo source). rust-cache gets the same result when it restores the cache of the previous lockfile.
The name of the target volume includes the Rust version. Cargo puts a hash that includes the compiler version in the file names of its artifacts (Cargo source). Artifacts from two compiler versions thus stay side by side in target/. A new name gives a new toolchain an empty target directory, so old artifacts do not collect in one volume. rust-cache also starts a new cache when rustc changes.
rustVersion is a constructor argument, so it is also a module setting. To change it, run this command:
dagger module settings <project>-dev rustVersion 1.98
The command adds the setting to dagger.toml. In a project named greeter, it adds these lines:
[modules.greeter-dev.settings]
rustVersion = "1.98"
Every check then uses the new toolchain and a new target volume.
Share the cache across CI machines and laptops with Dagger
Cache volumes live on the Dagger Engine that runs the checks. On a laptop, the engine keeps them between runs, until its garbage collector removes cache to free disk space. By default, the engine prunes its cache when free disk space falls below 20% (engine.json). The gc settings in engine.json change these limits.
In CI, Cloud Checks run the checks on Cloud Engines, the remote engines that Dagger Cloud manages for you. Cloud Engines have scale-out and a distributed cache. Developers, agents, and CI pool one cache, and the --engine=cloud flag runs a pipeline from a laptop on Cloud Engines (Cloud Engines). Dagger Cloud also removes stale cache volumes on Cloud Engines automatically (changelog).
An engine on a laptop keeps its own cache volumes. The module is the same on every engine, and the cache volumes have the same names. One module thus meets the requirements on a laptop and in Dagger Cloud, with no cache configuration for each CI platform.
Run the same cached checks locally and with Cloud Checks
Run dagger check on your machine and in CI, and the same checks run in both places. List the checks, then run them:
dagger check -l
dagger check
In a project named greeter, dagger check -l lists the module’s three checks and a staleness check from the Dang SDK:
MODULE CHECK DESCRIPTION
greeter-dev clippy Lint every crate and target with Clippy, denying warnings.
dang-sdk stale staleness check: re-generate modules and clients managed by the Dang SDK
greeter-dev fmt Check formatting with rustfmt.
greeter-dev test Build and run the workspace's tests.
This guide’s greeter project has two crates, with serde, serde_json, and anyhow as dependencies. On an engine with an empty cache, the runs took these times:
- The first
dagger checktook 28.7 seconds. It pulled therustimage, installed Clippy and rustfmt, downloaded the crates, and compiled them. - A rerun with no changes took 1.9 seconds.
- After an edit to one file in the
greetcrate,dagger checktook 1.8 seconds. Cargo compiled onlygreetandcli, becauseclidepends ongreet.
These times come from one run on one machine. They are an example, not a benchmark.
In CI, Cloud Checks run the checks. Cloud Checks run your checks on Cloud Engines, Dagger’s managed engines, after each push and pull request, with no CI workflow file. The repository must be on GitHub, with its origin remote set to it. Run this command from the project root:
dagger cloud checks on
To see the runs, use dagger workspace activity.
Bottom line: A Rust CI cache has to keep Cargo’s downloaded crates and the compiled dependencies in target/. It has to follow changes to Cargo.lock and the toolchain, and reach every machine that builds. On GitHub Actions, Swatinem/rust-cache meets most of them in a few lines of YAML. sccache shares compiled crates across a fleet of machines, and cargo-chef fits Docker builds. Dagger meets the same requirements with cache volumes, and the same dagger check runs on a laptop and through Cloud Checks.
Related guides
- Can Dagger lower CI costs?: how cached work and usage-based bills change what CI costs.
- How do I run Playwright tests in CI and locally in the same container, with cached browsers?: the same cache approach for Playwright’s browsers.
- What are the alternatives to GitHub Actions, from faster runners to portable pipelines?: runner vendors, other CI platforms, and Dagger.