Caching the Cargo Registry and Target in a DevContainer

Rust's slow compiles come from re-downloading crates and recompiling from scratch on every rebuild. This page caches both the Cargo registry and the target directory on named volumes, so incremental compilation and crate sources persist and rebuilds become fast.

This matters because a devcontainer is disposable by design: the whole point of the setup is that you can throw the container away and rebuild it from the devcontainer.json at any time. But Rust makes that disposability expensive. A cold build of a moderately sized crate graph downloads hundreds of megabytes of .crate files into ~/.cargo/registry, unpacks and compiles every dependency, and writes the results into target. If both of those directories live inside the container's writable layer, they vanish the moment you rebuild, and every rebuild starts from zero. The compiler is not slow because your code changed; it is slow because the artifacts it produced last time were discarded along with the container. Caching separates the container's lifecycle from the build cache's lifecycle so that only one of them is disposable.

The mental model is to treat the registry and target as durable state that outlives any single container, mounted the same way you would mount a database volume. You reach for this the moment a rebuild becomes part of your routine — switching branches, editing the Dockerfile, updating a Feature, or onboarding a teammate whose first cargo build otherwise takes ten minutes. Cargo already knows how to reuse crate sources and incremental artifacts when they are present on disk; the only thing standing between you and that reuse is making sure the disk itself survives. Named volumes give you exactly that, and Cargo.lock guarantees that the artifacts you keep still match the dependency versions you resolved.

Prerequisites

You need a Rust devcontainer and volumes for the registry and target dir.

  • A Rust Feature pinned to a toolchain.
  • Named volumes for ~/.cargo/registry and the workspace target dir.
  • Cargo.lock committed.

The toolchain pin is the piece that ties the caching together. Because a cached target directory records artifacts compiled by a specific rustc, the cache is only valid for the compiler version that wrote it. If your Rust Feature floats to whatever is newest, a background toolchain bump silently invalidates the incremental cache, and the "fast" rebuild you were expecting turns into a full recompile with no obvious cause. Pinning the toolchain — through the Feature version or a checked-in rust-toolchain.toml — keeps the compiler stable so the artifacts on the volume stay usable across rebuilds. Committing Cargo.lock does the same job for the registry half: it fixes the exact set of crate versions the cache is allowed to contain.

The detail people most often get wrong is the target of the registry mount. Cargo's cache home depends on how the image sets CARGO_HOME; on the common devcontainer images it is /usr/local/cargo, not the ~/.cargo you would expect on a laptop. Mount the volume at the path Cargo actually uses, or the volume will sit empty at ~/.cargo/registry while Cargo happily re-downloads everything into /usr/local/cargo/registry on the writable layer. Check echo $CARGO_HOME inside the container before you decide the mount path.

Cache prerequisitesYou need registry and target volumes, a warm step, and a rebuild check.Registry volumecrate sourcestarget volumecompiled artifactscargo fetchwarm itVerifyfast rebuild

Step-by-Step Implementation

  1. Mount volumes for the registry and target dir.
{
  "mounts": [
    "source=devcontainer-cargo-registry,target=/usr/local/cargo/registry,type=volume",
    "source=devcontainer-cargo-target,target=/workspace/target,type=volume"
  ],
  "remoteUser": "vscode"
}

Each mount declares a named volume with type=volume, which is what makes it survive a Rebuild Container. The registry volume lands on /usr/local/cargo/registry because that is where CARGO_HOME points on these images, so downloaded crate sources and the index accumulate there instead of on the container's throwaway layer. The target volume is mounted at /workspace/target, matching the workspace path Cargo writes build output to, so compiled artifacts persist in the one place the compiler looks for them. Naming the volumes (devcontainer-cargo-registry and devcontainer-cargo-target) rather than using anonymous mounts is deliberate: named volumes are stable across rebuilds and easy to inspect or remove by name, whereas anonymous volumes accumulate as orphans and are hard to reason about. Setting remoteUser here also matters, because it establishes which user must own the files that land on those volumes.

  1. Warm the registry on create.
{ "postCreateCommand": "cargo fetch" }

cargo fetch resolves Cargo.lock and downloads every dependency into the registry volume without compiling anything. Running it as postCreateCommand means the download happens once, right after the container is created, and populates the persistent registry so the first real cargo build finds its crate sources already on disk. This is the step that turns a teammate's first build from a network-bound wait into a compile-bound one. Because fetch only touches the registry and honours the lockfile, it is cheap to run on every create — if the volume is already warm from a previous container, cargo fetch sees the crates present and returns almost immediately rather than re-downloading them.

  1. Build with the lockfile so caching stays reproducible.
cargo build --locked

The --locked flag tells Cargo to fail rather than modify Cargo.lock. Without it, an out-of-date lockfile or a subtly different resolution can cause Cargo to rewrite the lock and pull different crate versions, which would populate your cached registry and target with artifacts that no longer match what your teammates build. With --locked, the build either uses the exact versions the committed lockfile pins or it stops and tells you the lockfile is stale — a far better failure than a silently divergent cache. This is what keeps the cache honest: everything the volumes hold is provably derived from the checked-in Cargo.lock, so a warm cache is never a source of drift.

  1. Verify a rebuild reuses compiled artifacts.
du -sh target   # populated after a rebuild, incremental compile reused

du -sh target is a quick sanity check that the cache is actually doing its job. After a container rebuild followed by a cargo build, the directory should already hold hundreds of megabytes of compiled artifacts rather than being freshly regenerated from nothing — a non-trivial size that appears immediately, plus a build that finishes in seconds, is the signature of a warm cache. The real confirmation, though, is timing: run cargo build twice with no source changes and watch the second run report Finished almost instantly, then rebuild the container and confirm the first post-rebuild build is still fast. If that first build is slow again, the target volume is not mounting where Cargo writes, and the size and timing here will tell you so before you waste an afternoon on it.

Rust cache anatomyCaching the registry and target dir persists crates and compiled artifacts across rebuilds.Registry cachecrate sources persisttarget cacheincremental artifacts persist--lockedcache stays reproducibleResultseconds, not minutes

Common Pitfalls

Slow Rust builds come from an uncached target dir or registry.

The pitfall that bites hardest is ownership. A freshly created named volume is owned by root, but your build runs as the remoteUser (vscode above). When Cargo tries to write into a target or registry volume it does not own, you get permission errors mid-build, or worse, a build that falls back to writing somewhere else and quietly caches nothing. The fix is to make the remote user own the mount point after creation — a chown -R vscode:vscode /workspace/target /usr/local/cargo/registry in postCreateCommand, run before the first build, so Cargo can write into the volumes it is supposed to fill. Do this once per volume; because named volumes persist, the ownership sticks across subsequent rebuilds and you will not need to repeat it.

The other common trap is caching target at the wrong granularity or across incompatible states. Sharing a single target volume between projects, or between a host build and a container build, can leave Cargo staring at artifacts compiled against a different rustc, different target triple, or different feature set, which forces a full recompile anyway and defeats the cache. Keep one target volume per project and never mount the host's target into the container. If a toolchain upgrade does land, expect the next build to be slow while the incremental cache repopulates for the new compiler — that one-time cost is normal, and removing and recreating the target volume is the clean way to reset it rather than fighting stale artifacts.

Rust cache triageA triage path from recompiling/refetching to warm registry and target caches.Full recompile every rebuild?YESCache the target dir on a volumeCrates re-download?YESCache the registry on a volumeFast Rust rebuilds

SymptomRoot CauseRemediation
Full recompile each rebuildtarget not on a volumeMount the target dir on a named volume
Crates re-downloadRegistry not cachedMount ~/.cargo/registry on a volume
target volume ownership errorsVolume owned by rootchown target to the remote user
Build ignores lockfileMissing --lockedBuild with cargo build --locked

Conclusion

Cache both halves of Rust's build cost: the crate registry (downloads) and the target directory (compiled artifacts). On named volumes with --locked builds, incremental compilation survives rebuilds and Rust's compile times stop being a devcontainer tax.

The strategic payoff is that rebuilding the container stops being something you avoid. Once the registry and target live on volumes, you are free to change the devcontainer.json, bump a Feature, or blow away a broken container without dreading the recompile that used to follow, because the expensive state is decoupled from the container you are discarding. That is the same pin-and-cache discipline that runs through every reproducible-environment story: pin the inputs so results are deterministic, then cache the outputs so you never pay to recompute what has not changed. Here the pins are the toolchain and Cargo.lock, and the cached outputs are the crate sources and the compiled artifacts.

Seen that way, this page is one instance of a general pattern rather than a Rust-specific trick. The registry volume plays the same role as a package cache in any language, and the target volume is the compiled-artifact cache that makes incremental builds meaningful. What makes it safe rather than reckless is the lockfile: because --locked guarantees the cache can only ever hold artifacts your committed Cargo.lock already sanctions, speed and reproducibility stop being a trade-off and become the same setting. Warm the cache once, keep the pins honest, and Rust's compile times become a property of your first build rather than a cost you pay on every rebuild.

Cache and safetyCaching the registry and target is safe because Cargo.lock pins every crate.CacheRegistry cratestarget artifactsOn volumesSafe viaCargo.lock pins--locked buildsDeterministic

FAQ

Which cache matters more, registry or target? The target directory, usually — it holds compiled artifacts, and caching it lets incremental compilation reuse work across rebuilds, which is Rust's biggest cost. Cache both, but if you cache only one, cache target. The registry download is a one-time network cost that a warm cargo fetch amortizes, whereas recompiling target from scratch is a CPU cost you would otherwise pay on every single rebuild. In practice the registry volume saves minutes on the first build and the target volume saves minutes on every build after it, which is why the compiled-artifact cache is the one you protect first.

Is caching safe for reproducibility? Yes. Cargo.lock pins every crate to an exact version and checksum, and building with --locked enforces it. A cached crate can only be one your lockfile already allows, so caching adds speed without weakening determinism. The target cache is equally safe as long as the compiler that wrote it is the compiler you build with, which is why pinning the toolchain sits alongside committing the lockfile. If either pin drifts, Cargo detects the mismatch and recompiles rather than trusting a stale artifact, so the worst case of an invalidated cache is a slow build, never a wrong one.

Should I use sccache too? It helps for shared compiler caching across projects or CI, layering on top of the registry/target volumes. For a single project, the target-dir volume already captures incremental compilation; add sccache when you want cross-project or cross-run compile caching. Think of sccache and the target volume as complementary rather than competing: the volume gives one project fast incremental rebuilds, while sccache shares compiled crate outputs across many projects or CI runs. If you work in a single repository day to day, the volume alone is usually enough, and sccache earns its keep once you are rebuilding several crates that share dependencies.

Do I need to warm the cache in CI as well? CI is a different lifecycle from a local devcontainer, so the named-volume trick does not transfer directly — ephemeral CI runners have no persistent local volume to reuse. There you cache the same two directories through your CI provider's cache mechanism, keyed on Cargo.lock for the registry and on a combination of the lockfile and toolchain for target. The principle is identical: persist the registry and target between runs and gate reuse on the lockfile, only the storage backend changes.

Why is my first rebuild still slow after adding the volumes? The most common cause is a mount path that does not match where Cargo actually reads and writes, so the volume stays empty while Cargo works on the throwaway layer. Confirm CARGO_HOME inside the container and that the registry mount targets $CARGO_HOME/registry, and that the target mount matches your workspace's build directory. The second most common cause is ownership: if the volume is owned by root and your build runs as vscode, Cargo cannot write into it and silently caches nothing until you chown the mount to the remote user.