Crane: Nix Builds for Rust Without Losing cargo build
7 min readHalf your team uses Nix. The other half just wants cargo build. Both are right.
The default way Nix builds Rust is painful. buildRustPackage hashes your dependency tree with cargoHash, which means every time you add a crate, you get a hash mismatch, go look up the new one, paste it in, and rebuild everything from scratch. It’s the kind of workflow that makes people mass-quit Nix.
Crane fixes this. It splits the build into two derivations — one for dependencies, one for your code — so changing a source file doesn’t re-download and re-compile 400 crates. And it doesn’t touch your Cargo.toml or project layout, so cargo build keeps working exactly as before.
The flake skeleton
Start with the inputs. Crane for the build, rust-overlay for toolchain control, pre-commit-hooks for formatting, flake-utils because you don’t want to write system four times.
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane.url = "github:ipetkov/crane";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
pre-commit-hooks = {
url = "github:cachix/pre-commit-hooks.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
}
Nothing unusual here. The follows declarations keep the dependency tree shallow.
Toolchain and Crane setup
rust-overlay gives you pinned toolchains without fighting nixpkgs versions. You pick stable, add the extensions you actually want, and hand it to Crane.
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "clippy" "rustfmt" ];
};
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
rust-src is there for rust-analyzer. Without it, go-to-definition into std just gives you a wall of “source not available.”
The two-stage build
This is the core pattern. Crane splits your build into a dependency layer and a source layer.
src = craneLib.cleanCargoSource ./.;
commonArgs = {
inherit src;
strictDeps = true;
nativeBuildInputs = with pkgs; [ protobuf ];
PROTOC = "${pkgs.protobuf}/bin/protoc";
};
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
my-service = craneLib.buildPackage (commonArgs // {
inherit cargoArtifacts;
});
buildDepsOnly compiles every dependency in Cargo.lock and caches the result. buildPackage then takes those cached artifacts and only compiles your code. Change a line in src/main.rs and you skip the entire dependency build.
cleanCargoSource strips non-Cargo files from the source — READMEs, CI configs, docs. This matters because Nix derivations rebuild when their inputs change. Without it, editing your README invalidates your build cache.
The PROTOC env var is there because protobuf codegen needs to find the compiler. If your project doesn’t use protobuf, drop both lines. The pattern is the same for any native dependency — declare it in nativeBuildInputs, set env vars if needed.
Private git dependencies
Things get interesting when your Cargo.toml pulls crates from private repos. Crane calls builtins.fetchGit under the hood, which works fine on single-user Nix installs — your SSH keys are right there. On multi-user installs, including Determinate Nix, the nix daemon runs as its own user and has no access to your keys.
The fix is to pre-fetch private deps as flake inputs — the flake machinery handles authentication — and then tell Crane’s vendoring to use those pre-fetched sources instead of trying to fetch them itself.
First, add the dep as a flake = false input:
{
inputs = {
my-private-dep-src = {
url = "github:myorg/my-private-dep";
flake = false;
};
};
}
Then override the vendoring:
cargoVendorDir = craneLib.vendorCargoDeps {
src = craneLib.cleanCargoSource ./.;
overrideVendorGitCheckout = lockMetadata: drv:
let
source = (builtins.head lockMetadata).source or "";
in
if builtins.match ".*my-private-dep.*" source != null then
drv.overrideAttrs (_: {
src = my-private-dep-src;
})
else
drv;
};
Then thread it into your build args:
commonArgs = {
inherit src cargoVendorDir;
strictDeps = true;
# ...
};
The gotchas
There are four things that will waste your afternoon if you don’t know them upfront.
lockMetadata is a list, not an attrset. The callback receives a list of lock entries. You need (builtins.head lockMetadata).source to get the git URL. Treating it as an attrset gives you an unhelpful type error deep in the Nix evaluator.
Match on source, not on the input name. If you have multiple private git deps and write a blanket override, you’ll apply the wrong source to the wrong dep. The source field contains the git URL from Cargo.lock — match against it.
The else drv fallback is essential. Without it, any git dependency that doesn’t match your condition returns null. Crane then silently produces a broken vendor directory. You’ll get a cargo error about missing crates with no indication that your Nix code is the problem.
Use drv.overrideAttrs, not a fresh derivation. Crane’s vendor checkout derivation carries metadata that the rest of the pipeline depends on. Replacing it with pkgs.runCommand or similar drops that metadata and breaks downstream.
The devShell — cargo build just works
Crane provides craneLib.devShell, which drops you into a shell with the Rust toolchain and any extra packages you specify. Inside it, cargo build, cargo test, cargo run — all work exactly as they would outside Nix.
devShells.default = craneLib.devShell {
checks = self.checks.${system};
packages = with pkgs; [ protobuf sqlite cargo-watch ];
PROTOC = "${pkgs.protobuf}/bin/protoc";
shellHook = ''
${self.checks.${system}.pre-commit.shellHook}
'';
};
The checks attribute wires up nix flake check outputs so they’re available in the shell. packages adds tools that aren’t Rust but that your project needs. The shellHook installs pre-commit hooks automatically — more on that below.
This is the key insight: the devShell gives Nix developers the standard Cargo workflow. They don’t run nix build during development. They run cargo build. Nix just sets up the environment.
direnv makes it transparent
The entire .envrc:
use flake
That’s it. Walk into the project directory and your shell has the right Rust version, protobuf, sqlite, whatever else is declared in the devShell. No nix develop, no manual activation. It just happens.
The non-Nix escape hatch
For developers who don’t have Nix installed, you need an alternative path. The approach that works: document the environment variables your devShell sets and provide a setup script that installs the same tools via rustup, brew, apt, whatever your team uses.
The shared interface is environment variables. PROTOC points at the protobuf compiler. DATABASE_URL points at the dev database. Nix sets these in the devShell; non-Nix developers set them manually or via a setup.sh. The Rust code doesn’t care where they came from.
This is what “dual workflow” actually means. Not two build systems — one codebase that works with or without Nix, with the build system being orthogonal to the development experience.
Local path overrides and the trap
When you’re iterating on a private dependency locally, you don’t want to push, update the flake input, and rebuild. Cargo has path overrides for this:
cargo build --config \
'patch."https://github.com/myorg/my-dep.git".my-dep.path="../my-dep"'
This tells Cargo to use your local checkout of my-dep instead of the git version. Works great for development.
The trap: never persist this in .cargo/config.toml. It works on your machine because ../my-dep exists. Inside the Nix sandbox, that path doesn’t exist. Your nix build will fail with a confusing “path not found” error that has nothing to do with Nix — it’s Cargo looking for a directory that isn’t there.
Keep it on the command line. Or use a .cargo/config.toml that’s in .gitignore. Either way, don’t commit it.
Pre-commit hooks
Formatting arguments are a waste of everyone’s time. Automate them.
pre-commit = pre-commit-hooks.lib.${system}.run {
src = ./.;
hooks = {
nixfmt.enable = true;
rustfmt.enable = true;
};
};
This runs nixfmt on .nix files and rustfmt on Rust files before every commit. The shellHook in the devShell installs the git hooks automatically, so Nix developers get them without thinking about it.
Non-Nix developers can install the same hooks via pre-commit install with a .pre-commit-config.yaml — same tools, different entry point.
Flake checks — everything in one command
Wire up your checks so nix flake check runs the build, Clippy, and format verification in one shot.
checks = {
inherit my-service;
inherit pre-commit;
my-service-clippy = craneLib.cargoClippy (commonArgs // {
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- -D warnings";
});
my-service-fmt = craneLib.cargoFmt { inherit src; };
};
inherit my-service means the build itself is a check — if it doesn’t compile, the check fails. cargoClippy runs lints with -D warnings so any warning is a hard failure. cargoFmt verifies formatting without modifying files.
In CI, your entire pipeline is nix flake check. One command, fully hermetic, same result on every machine.
Clippy pedantic
While you’re setting up lints, turn on Clippy’s pedantic preset. It catches things the default lints miss — redundant clones, needless borrows, missing docs on public items.
In your Cargo.toml:
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
The priority = -1 means pedantic lints are warnings, but you can still override individual lints at higher priority. Some pedantic lints are genuinely annoying — module_name_repetitions comes to mind — and you’ll want to #[allow] those. But the majority catch real issues.
The result
You end up with a Rust project that builds with cargo build for daily development and nix build for CI and deployment. The Cargo workflow is untouched — no wrapper scripts, no custom build commands, no “run this Nix thing instead.” Developers who don’t use Nix never need to know it’s there.
The Nix side gives you hermetic CI, binary caching, and NixOS deployment modules. Crane’s two-stage build means your CI isn’t re-compiling 400 crates on every push. And when something breaks, it breaks the same way on every machine, which — if you’ve spent enough time debugging “works on my laptop” — is actually a feature.