Hedronite · Ops Lesson · Nix + crane + Rust · Mon 2026-10-26

Packaging a Rust API with crane a cached dependency layer, source filtering, and checks that fail the build

Pay for the dependencies once per lockfile, not once per commit.

Lesson Class: Ops (Nix packaging · crane dependency layer · source filtering · format, lint and test checks)
Tested with: crane e675a36 · nixpkgs fe13242 (rustc 1.98.1) · Determinate Nix 3.22.3 (nix 2.35.2) · axum 0.8 · jsonwebtoken 11.1.0 · aarch64-darwin
Lab: lab/run.sh · notes-api crate + flake.nix · real transcript and per-step logs in lab/out/
Source: crane API reference and book · Nixpkgs manual · Nix reference manual
Two Derivations
Dependencies build once from a stripped copy; your crate builds on top in seconds.
Filter Before Hashing
A README edit rebuilds nothing; an embedded file must be added on purpose.
Gates Are Builds
Clippy, rustfmt and tests are derivations, so a failure fails the build.
One comment line. One hundred and five crates. Unless the dependencies live somewhere else.

The problem

A Rust service might have a few hundred lines of its own code and about a hundred dependency crates. A crate is Rust's unit of compiled code, and the file Cargo.lock pins the exact version of every crate the project uses. The example here is a small HTTP API (application programming interface) built on the axum web framework. Its login route issues a JWT (JSON Web Token, a signed string that a client sends back to prove who it is).

Nix packages software as derivations. A Nix derivation is a build recipe whose output path is a hash of all its inputs, so any input change means a new output. The stock nixpkgs helper, buildRustPackage, compiles the dependencies and the crate inside one derivation. A one-line comment added to the crate therefore compiles every dependency again. Lab step 2.2 measured it: the stock build took 20s after the edit, and the log line reads "plain: crates compiled after the edit: 105". The crane build of the same edit took 4s. Step numbers refer to the lab script and transcripts that accompany this lesson. The cost grows with the dependency tree, and continuous integration (CI), the automated build that runs on every push, pays it on every commit.

The team also wants formatting, lint and test failures to block a merge, and wants those gates to run the same way on a laptop and in CI. Otherwise a gate that exists only in a CI configuration drifts away from what developers run locally.

The idea, in plain words

Split the build in two. The first derivation compiles only the dependencies, starting from a stripped copy of the project in which Cargo.lock is kept and the crate's own source files are replaced by empty programs, so editing real code cannot change its inputs. The second derivation compiles only the crate, on top of the first derivation's cached output. Source filtering adds a further saving: only the files Cargo needs get hashed, so a README edit changes nothing. Each gate then becomes one more derivation that reuses the cached dependencies, and nix flake check builds all of them, so that a failing gate fails the build. Put shortly: pay for the dependencies once per lockfile, not once per commit.

Two derivations: buildDepsOnly and buildPackage

crane is a Nix library for building Cargo projects. Its documentation describes the split directly: buildDepsOnly is meant for "one which only builds dependencies and needs to be rebuilt when a Cargo.lock file changes, and another which inherits the cargo artifacts from the first and (quickly) builds just the application itself". The code lives in a flake, which is a filesystem tree with a file named flake.nix at its root that declares inputs (here nixpkgs and crane) and outputs (here packages and checks). This is the lab's flake.nix in full.

{
  description = "notes-api: a Rust HTTP API packaged with crane, with a cached dependency layer and checks";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/fe13242281a67641c0102cdb1087f0ebe586c7df";
    crane.url = "github:ipetkov/crane";
  };

  outputs = { self, nixpkgs, crane }:
    let
      systems = [ "aarch64-darwin" "x86_64-linux" "aarch64-linux" ];
      forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});

      build = pkgs:
        let
          craneLib = crane.mkLib pkgs;

          # Keep only Cargo files and *.rs / *.toml, so a README edit is not a rebuild.
          src = craneLib.cleanCargoSource ./.;

          commonArgs = {
            inherit src;
            strictDeps = true;
          };

          # Derivation 1: every dependency in Cargo.lock, compiled against stub sources.
          cargoArtifacts = craneLib.buildDepsOnly commonArgs;
        in
        {
          deps = cargoArtifacts;

          # Derivation 2: only our crate, starting from the cached target directory.
          notes-api = craneLib.buildPackage (commonArgs // {
            inherit cargoArtifacts;
            doCheck = false; # tests run once, in the tests check below
          });

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

          fmt = craneLib.cargoFmt { inherit src; };

          tests = craneLib.cargoTest (commonArgs // { inherit cargoArtifacts; });

          # For comparison: the nixpkgs builder, one derivation for deps and crate together.
          plain = pkgs.rustPlatform.buildRustPackage {
            pname = "notes-api-plain";
            version = "0.1.0";
            src = pkgs.lib.fileset.toSource {
              root = ./.;
              fileset = pkgs.lib.fileset.unions [ ./Cargo.toml ./Cargo.lock ./src ./tests ];
            };
            cargoLock.lockFile = ./Cargo.lock;
            doCheck = false;
          };
        };
    in
    {
      packages = forAll (pkgs:
        let b = build pkgs; in {
          default = b.notes-api;
          inherit (b) deps plain;
        });

      checks = forAll (pkgs:
        let b = build pkgs; in {
          inherit (b) notes-api clippy fmt tests;
        });
    };
}

The cleanCargoSource call produces src, a copy of the project holding only Rust and Cargo files. commonArgs bundles that src with strictDeps = true and is passed to every crane function, so they all see identical inputs; the lab did not test what strictDeps changes. cargoArtifacts = craneLib.buildDepsOnly commonArgs is the first derivation. Per the crane reference it replaces src with a dummy copy, "This ensures that we do not need to rebuild the cargo artifacts derivation whenever the application source changes." The notes-api attribute is the second derivation: inherit cargoArtifacts starts the compile from the cached target directory, and doCheck = false skips tests inside the package build because the tests check runs them once. The plain attribute is the comparison, using cargoLock.lockFile, which the Nixpkgs manual says retrieves dependencies "using fixed-output derivations from the specified lockfile".

On a cold store, step 2.1 reported deps: exit 0 in 34s, package: exit 0 in 5s and plain-cold: exit 0 in 20s. The dependency build is slower than the whole stock build because, by default, "all artifacts from running cargo {check,build,test} will be cached". It therefore compiled check, release and test artifacts, which shows up as 119 "Compiling" lines in its log against 105 for the stock build. The result is "deps output: /nix/store/d8b0q8lw1whdyy0jg8akskcfis81q16a-notes-api-deps-0.1.0 73M". That is a store path (a directory under /nix/store named with a hash of the build's inputs) holding one file, target.tar.zst, the compressed Cargo build directory. The extra up-front cost pays off on the first edit, in step 2.2:

deps drv unchanged: yes
package drv unchanged: NO
package-after-src-edit: exit 0 in 4s
   Compiling notes-api v0.1.0 (/nix/var/nix/builds/nix-11510-1067353955/source)
plain-after-src-edit: exit 0 in 20s

The dependency derivation kept its path, so Nix reused it. The package derivation got a new path, because its source changed, and rebuilt with exactly one "Compiling" line, while the stock build recompiled all 105 crates. These timings come from one machine (Apple silicon, aarch64-darwin) and one run, so read them as ratios, not benchmarks.

Symptom: the package build recompiles every dependency on every commit even though crane is in use.

Cause: buildPackage was not given cargoArtifacts, or was given different source or arguments than buildDepsOnly, so Nix saw a different derivation.

Fix: build cargoArtifacts once from commonArgs and pass inherit cargoArtifacts to every derivation that compiles code (package, clippy, tests). Confirm with nix eval --raw .#packages.<system>.deps.drvPath before and after an edit, as the lab does.

What the dependency layer is keyed on

mkDummySrc is the crane function that builds the stripped copy. Its documentation says it converts the source to "the smallest, most trivial form needed to build all dependencies", with the project's own source files "ignored/replaced with empty programs". The dependency derivation hashes only that copy, so only what survives into the copy can invalidate the cache. Cargo.lock is "kept as-is. Any changes to it will invalidate the build cache." Cargo.toml is reduced, and by default only these changes invalidate the cache:

The lab tested the fourth item in step 2.4 by bumping version from 0.1.0 to 0.1.1:

deps drv: /nix/store/dffvv02ix86h6c2dv0aw23pi9a130cyr-notes-api-deps-0.1.1.drv
deps drv unchanged by version bump: NO
deps-after-version-bump: exit 0 in 28s
deps drv back to original after revert: yes

No dependency changed, yet the derivation path changed and Nix rebuilt all of it in 28s. Reverting the version restored the original path exactly. A derivation path is a pure function of its inputs, so identical inputs give the identical path, and the old build output is still in the store. That is also why a revert costs nothing.

Symptom: release builds always pay the full dependency compile even though Cargo.lock did not change.

Cause: the release process bumps version in Cargo.toml, which is part of the [package] definition that the dependency layer is keyed on.

Fix: expect one dependency rebuild per version bump and keep bumps to release commits. Do not chase it as a cache bug.

Source filtering and the file the build could not see

Nix hashes every file passed into a build. The crane book spells out the consequence: "changes to any 'irrelevant' files (such as the project README) would end up rebuilding the project even if the final outputs don't actually care about their contents!" Source filtering fixes this by "programmatically filtering out files which are known to not apply to the build before the inputs are hashed." The cleanCargoSource helper keeps only Rust and Cargo-related files, which is useful to avoid rebuilding when unrelated files change. In step 2.3 the lab added a README.md and got package drv unchanged by README: yes and package-after-readme: exit 0 in 0s. The transcript also prints plain drv unchanged by README: yes, but that line compared the stock package's path with itself, so it proves nothing. The plain package lists its files explicitly, so a README should be excluded there as well; the lab did not measure it.

Filtering has a cost: a file the code needs but the filter drops disappears from the build. Step 2.5 added banner.txt and embedded it with include_str!("../banner.txt"), a macro that reads a file at compile time and pastes its contents into the program. Plain Cargo was fine with it, and Nix was not:

cargo build:     Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.29s
package-with-banner: exit 1 in 2s
error: couldn't read `src/../banner.txt`: No such file or directory (os error 2)
   --> src/lib.rs:156:26
error: Cannot build '/nix/store/68nmq3c2ia1jm2jlad49n2z04pnrh9lj-notes-api-0.1.0.drv'.

The first line shows the laptop build succeeding. The Nix build then failed in 2s because the filtered source tree contained no banner.txt, so the compiler's file read hit "No such file or directory". The crane documentation notes that a custom filter can be written when files must be kept. Step 2.6 replaced the src line with a fileset (a Nix value describing exactly which files to include):

src = pkgs.lib.fileset.toSource {
  root = ./.;
  fileset = pkgs.lib.fileset.unions [
    (craneLib.fileset.commonCargoSources ./.)
    ./banner.txt
  ];
};

commonCargoSources is crane's helper for the files Cargo projects commonly use: Cargo.toml and Cargo.lock, Rust files, other TOML files and .cargo configuration. unions adds banner.txt to that set, and toSource turns the combined set into a source tree rooted at the project directory. Everything else is still excluded, which is the point. The rebuild exited 0 in 5s, and the lab reported deps drv after source change: unchanged. The dependency layer hashes only the stripped copy, so a non-Rust file added to the package source does not disturb it.

One more rule explains why the lab ran git add -A after every change. A flake in a local Git repository sees only files that have been added to Git. Uncommitted changes are tolerated but flagged, which is why every Nix command printed a line like warning: Git tree '~/lab/notes-api' has uncommitted changes.

Symptom: cargo build works on the laptop, but the Nix build fails with "couldn't read" for a template, SQL (Structured Query Language) migration or banner file.

Cause: the source filter dropped a non-Rust file that the code embeds, or the file was never git added.

Fix: add it explicitly with a fileset union on top of commonCargoSources, and git add it.

Gates as derivations

A gate is a check that must pass before code merges. In crane each gate is a derivation, so a failure is a failed build. According to the crane reference, cargoClippy runs cargo clippy --profile release for the workspace, cargoFmt runs cargo fmt in check mode, and cargoTest runs cargo test --profile release. Clippy is Rust's lint tool, which flags likely bugs and unidiomatic code. rustfmt, run as cargo fmt, is the formatter. The nix flake check command, per the Nix manual, "verifies that the flake specified by flake reference flake-url can be evaluated successfully (as detailed below), and that the derivations specified by the flake's checks output can be built successfully."

Step 2.1 built each gate once: clippy in 4s, fmt in 2s and tests in 5s. The tests log ends with test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s. Step 2.7 then added a function with a needless return to see a gate fail:

clippy-needless-return: exit 1 in 3s
+++ command cargo clippy --release --locked --all-targets -- --deny warnings
error: unneeded `return` statement
   --> src/lib.rs:159:5
    = note: `-D clippy::needless-return` implied by `-D warnings`
error: could not compile `notes-api` (lib test) due to 1 previous error

The flake's --deny warnings argument turns every lint warning into an error, as the note line shows. Clippy therefore exits non-zero, the derivation fails, and no output path is produced. This lab built the clippy check directly and did not run nix flake check against the broken tree.

Formatting deserves a plain admission. Step 1.2 found that the source as copied failed cargo fmt --check with exit 1 and 16 diff hunks. The lab ran cargo fmt before building with Nix, so it never watched the Nix fmt check fail. The crane reference says cargoFmt runs cargo fmt in check mode and that "cargoArtifacts is disabled/cleared", which is why the flake passes it only src.

Step 2.9 ran the full command on the restored tree:

✅ packages.aarch64-darwin.default (build skipped)
✅ packages.aarch64-darwin.deps (build skipped)
✅ checks.aarch64-darwin.fmt (previously built)
✅ checks.aarch64-darwin.clippy (previously built)
✅ checks.aarch64-darwin.tests (previously built)
✅ checks.aarch64-darwin.notes-api (previously built)
running 0 flake checks...
warning: The check omitted these incompatible systems: aarch64-linux, x86_64-linux
Use '--all-systems' to check all.
[nix flake check exit 0 in 0s]

"Previously built" means the check outputs already existed in the local store from earlier steps, so there was nothing to rebuild. On a clean CI runner with an empty store, the same command would build them. The packages lines say "build skipped" because the manual only requires package outputs to be derivations, while the derivations under checks are the ones it must build.

Symptom: nix flake check passes on a Mac, then fails on a Linux CI runner, or the reverse.

Cause: by default the command checks only the current system. The output says so: "The check omitted these incompatible systems: aarch64-linux, x86_64-linux".

Fix: run the checks on each target system in CI, or pass --all-systems where a builder for that system exists. The lab did not test Linux.

Putting it together

The daily loop follows from the pieces above. Edit code, and nix build recompiles only the crate. Run nix flake check before pushing, which gates formatting, lint and tests. Dependency changes and version bumps pay the full compile once. Non-Rust inputs enter the fileset deliberately. The measurements from this lab:

Change madeWhat rebuiltMeasured
One comment line in src/lib.rscrate only; dependency derivation unchanged (stock builder: 105 crates)4s (stock: 20s)
README.md addednothing; package derivation unchanged0s
version 0.1.0 to 0.1.1whole dependency layer28s
banner.txt added to the source filesetcrate only; dependency derivation unchanged5s

Step 2.8 ran the packaged binary with nix run .. It exercises the router in-process, with no network listener, and printed six lines:

POST /login (bad password) -> 401 {"error":"bad credentials"}
POST /login -> 200 {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZGEiLCJpc3
token has 3 dot-separated parts; header segment = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
GET /me (no header) -> 401 {"error":"missing bearer token"}
GET /me (Bearer) -> 200 {"sub":"ada","iss":"https://auth.example.test","aud":"notes-api","iat":1793036201,"exp":1793037101}
GET /me (last char of signature changed) -> 401 {"error":"invalid token: Base64(InvalidLastSymbol(42, 66))"}

Everything ran locally on aarch64-darwin with Determinate Nix 3.22.3 (nix 2.35.2), nixpkgs fe13242 (rustc 1.98.1) and crane e675a36, one run each. The lab did not test Linux builds, a remote binary cache or a CI runner, the Nix fmt check failing, or timing variance across runs.

Check yourself

Question 1
Why did the cold dependency build (34s) take longer than the whole stock build (20s), and why is that still a good trade?
tap to reveal
By default crane caches check, build and test artifacts, so the dependency derivation compiled more (119 "Compiling" lines against 105). The cost is paid once, and later crate edits rebuilt in 4s instead of 20s.
Question 2
Cargo.lock has not changed, but the dependency layer rebuilt. What is the most likely cause?
tap to reveal
A change to the [package] definition in Cargo.toml, such as a version bump, because the dependency layer is keyed on the reduced Cargo.toml. Reverting the version gives back the original derivation path and a cache hit.
Question 3
cargo build succeeds but the Nix build reports "couldn't read" on an embedded file. What do you check?
tap to reveal
Whether the source filter dropped the file and whether it was git added. Add it with lib.fileset.unions alongside craneLib.fileset.commonCargoSources.
Question 4
What does "previously built" mean in the nix flake check output?
tap to reveal
The check derivation's output already existed in the local Nix store, so nothing needed rebuilding. A clean CI runner would build it.
Question 5
Why can nix flake check pass on one machine and fail on another?
tap to reveal
By default it checks only the current system, and the output lists the omitted systems. Run it on each target system or use --all-systems where a builder exists.

Sources

  1. crane API reference: buildDepsOnly, mkDummySrc, buildPackage, cargoClippy, cargoFmt, cargoTest, cleanCargoSource, fileset helpers
  2. crane book: Source filtering and filesets
  3. Nixpkgs manual: Compiling Rust applications with Cargo
  4. Nix reference manual: nix flake check
  5. Nix reference manual: nix flake