Skip to Main Content
Optimizing Nix Builds for Cargo WorkspacesBack to Top

Optimizing Nix Builds for Cargo Workspaces

6 minutes

I’ve been spending a significant amount of time exploring techniques for ensuring the reproducibility of software builds. I view this as a critical component of software supply chain security that’s often overlooked. I’m far from the first person to think about this, and have spent most of my time wiring together the many incredible components (git, nix, OCI images, etc) developed by the open source community to make this possible.

If this concept feels new, check out https://reproducible-builds.org for a good intro.

For a particularly central new project of mine, I’ve chosen both Nix and Rust as core technologies. The communities surrounding these technologies value reproducibility, and Nix’s hermetic builds complement Cargo’s dependency management beautifully. Tools like crane make it easy to get started. As my project grew, I began encountering build-time performance bottlenecks with my monorepo setup that severely impacted my iteration loop.

Ultimately, I set up a simplified dummy repository to experiment with optimization strategies, and this post simply recounts what I learned.

In this post, I’ll walk through a couple of scenarios that required optimizing a Nix flake for a Rust monorepo. We’ll start with a naive implementation, identify its pain points, and iteratively improve it. The full source code is available at github.com/mike-marcacci/nix-cargo-workspace-optimizations.

The Setup

Our workspace has four crates with intentionally diverse relationships:

crates/
├── pkg-a/  (lib)   deps: once_cell, either
├── pkg-b/  (bin)   deps: pkg-a, once_cell
├── pkg-c/  (bin)   deps: pkg-a, itoa
└── pkg-d/  (bin)   deps: arrayvec

This structure gives us:

The initial commit sets up this workspace with a standard crane-based flake.

The Naive Implementation

Following crane’s documentation, our initial flake.nix looks something like this:

{
  src = craneLib.cleanCargoSource ./.;

  cargoArtifacts = craneLib.buildDepsOnly { inherit src; };

  mkPackage = pname: craneLib.buildPackage {
    inherit src cargoArtifacts pname;
    cargoExtraArgs = "-p ${pname}";
  };
}

This is clean and simple. Crane handles the complexity of separating dependency compilation from source compilation, giving us nice caching behavior. But there’s a problem lurking here.

Pain Point #1: Source Changes Invalidate Everything

Let’s say you modify pkg-c/src/main.rs. What gets rebuilt?

With the naive implementation: everything.

The issue is that src contains the entire workspace. When any file changes, the hash of src changes, and every derivation that depends on it gets invalidated. Even though pkg-b has no relationship to pkg-c, changing pkg-c forces pkg-b to rebuild.

To make this concrete, I wrote a test script that verifies cache behavior:

# Test: pkg-b after pkg-c change should use cache (no dependency)
run_test "Sibling change" "pkg-b" "pkg-c" "pkg-b" "hit"

With our naive implementation, this test fails. Modifying pkg-c invalidates pkg-b’s cache.

Solution #1: Source Isolation

The fix is to give each package a filtered view of the source tree that only includes relevant crates. If pkg-b depends on pkg-a, it should only see crates/pkg-a and crates/pkg-b — changes to pkg-c or pkg-d shouldn’t affect it.

The implementation involves two parts:

Part 1: Auto-discover workspace dependencies

Rather than manually maintaining a dependency graph that duplicates Cargo.toml, we parse it at Nix evaluation time:

{
  getWorkspaceDeps = pname:
    let
      cargoToml = builtins.fromTOML (builtins.readFile ./crates/${pname}/Cargo.toml);
      deps = cargoToml.dependencies or { };
      workspaceDeps = pkgs.lib.filterAttrs (
        name: spec: builtins.isAttrs spec && spec ? path
      ) deps;
    in
    builtins.attrNames workspaceDeps;

  getAllDeps = pname:
    let
      directDeps = getWorkspaceDeps pname;
      transitiveDeps = builtins.concatMap getAllDeps directDeps;
    in
    pkgs.lib.unique (directDeps ++ transitiveDeps);
}

This recursively resolves workspace dependencies by looking for path dependencies in each Cargo.toml.

Part 2: Filter source per-package

{
  mkFilteredSrc = crates:
    let
      crateSet = builtins.listToAttrs (map (c: { name = c; value = true; }) crates);
    in
    pkgs.lib.cleanSourceWith {
      src = ./.;
      filter = path: type:
        let
          crateMatch = builtins.match "/crates/([^/]+).*" relPath;
          crateName = if crateMatch != null then builtins.head crateMatch else null;
          isIrrelevantCrate = crateName != null && !(crateSet ? ${crateName});
        in
        if isIrrelevantCrate then false
        else craneLib.filterCargoSources path type;
    };
}

Now each package only “sees” the crates it actually needs. Changing pkg-c no longer affects pkg-b’s source hash.

Pain Point #2: Unnecessary Dependency Compilation

With source isolation working, I turned to another inefficiency. Consider pkg-d — it only uses arrayvec. But when we build it, what external dependencies get compiled?

With the naive implementation: all of them.

The shared cargoArtifacts derivation builds dependencies for the entire workspace. Even though pkg-d doesn’t need once_cell, either, or itoa, they’re all compiled and included in pkg-d’s dependency closure.

The test script update verifies this:

# Test: pkg-d closure should NOT include once_cell
run_dep_test "pkg-d excludes once_cell" "pkg-d" "once_cell" "no"

This test extracts the compiled dependencies from pkg-d’s cargoArtifacts and checks what’s inside. With shared artifacts, it fails — once_cell is there even though pkg-d doesn’t use it.

Solution #2: Per-Package Dependencies

The fix is to create per-package cargoArtifacts:

{
  mkPackageDeps = pname:
    let
      relevantCrates = [ pname ] ++ (packageDeps.${pname} or []);
      filteredSrc = mkFilteredSrc relevantCrates;
    in
    craneLib.buildDepsOnly {
      src = filteredSrc;
      pname = "${pname}-deps";
      cargoExtraArgs = "-p ${pname}";
    };

  mkPackage = pname:
    let
      packageCargoArtifacts = mkPackageDeps pname;
    in
    craneLib.buildPackage {
      src = mkFilteredSrc relevantCrates;
      cargoArtifacts = packageCargoArtifacts;
      cargoExtraArgs = "-p ${pname}";
    };
}

The key is cargoExtraArgs = "-p ${pname}" in buildDepsOnly. This tells Cargo to only build dependencies for that specific package, not the entire workspace.

Now pkg-d’s dependencies only include arrayvec:

$ ./scripts/test-nix-cache.sh
...
Test: pkg-d excludes once_cell
  Package: pkg-d, Dependency: once_cell, Expect in deps: no
  Checking deps for pkg-d... dep once_cell in deps: no
  PASS

The Final Result

After both optimizations, our cache behavior matches expectations:

ScenarioRebuild?
Rebuild pkg-d without changesNo
Rebuild pkg-d after modifying pkg-aNo
Rebuild pkg-b after modifying pkg-aYes
Rebuild pkg-b after modifying pkg-cNo

And each package only compiles the external dependencies it actually needs.

Tradeoffs

These optimizations aren’t free:

  1. Complexity: The flake is more complex than the naive version. For small workspaces with overlapping dependencies, this may not be worth it.

  2. More derivations: Each package now has its own deps derivation. This means more entries in your Nix store and potentially more cache uploads if you’re using a binary cache.

  3. Evaluation-time file reads: Parsing Cargo.toml at evaluation time means Nix needs to read those files before it can evaluate the flake. This is generally fast but worth noting.

The optimizations become valuable as:

What About Workspace-Wide Operations?

You might notice we kept a shared cargoArtifacts for workspace-wide operations like clippy and tests:

{
  cargoArtifacts = craneLib.buildDepsOnly { src = fullSrc; };

  clippy = craneLib.cargoClippy {
    inherit cargoArtifacts;
    cargoClippyExtraArgs = "--all-targets -- --deny warnings";
  };
}

This is intentional. When running clippy or tests across the entire workspace, you need all dependencies anyway. The shared artifacts also benefit from crane’s smart handling of buildDepsOnly — it creates a “dummy source” that only includes Cargo.toml files, so source changes don’t invalidate it.

Conclusion

Nix + Rust is powerful, but getting optimal caching requires understanding how Nix derivations are hashed. The key insights:

  1. Source filtering: Each package should only see the source files it needs
  2. Dependency isolation: Each package should only compile the external dependencies it needs
  3. Auto-discovery: Parse Cargo.toml to avoid duplicating dependency information

The full implementation is available at github.com/mike-marcacci/nix-cargo-workspace-optimizations, including the test script that verifies these behaviors.