How to Use Nix for Reproducible Rust Builds

Use Nix flake.nix to define a reproducible Rust build environment with specific toolchain versions and dependencies.

When "It Works on My Machine" Breaks

You finish a Rust project at 2 AM. The tests pass. You push the code. Your teammate pulls the branch the next morning, runs cargo build, and hits a dependency resolution error. Or worse, the binary compiles but segfaults because the system libssl version on their machine differs from yours. You both have the same code. The environment is the hidden variable.

Standard package managers install tools globally or per-user. They assume the host system provides the rest. Rust's Cargo.lock pins your crate dependencies, but it says nothing about the compiler version, the linker, or the system libraries. Nix treats the build environment as code. You describe exactly what tools and libraries exist, and Nix constructs that world from scratch. No more "it works on my machine."

The Nix Way: Hermetic Environments

Nix is a purely functional package manager. It doesn't install packages into /usr/bin. It builds everything into a unique path in the Nix store, usually /nix/store. Each path contains a hash of its contents. If the source code or dependencies change, the hash changes, and Nix builds a new path. The old path stays untouched.

Think of Nix as a molecular gastronomy lab for your tools. When you ask for Rust, you aren't getting "whatever Rust is installed." You are getting a specific derivation: a recipe that produces a specific binary with a specific hash. Nix executes that recipe in isolation. The result is a hermetic environment. If you list Rust 1.75, that environment has Rust 1.75. If you don't list curl, that environment has no curl.

This guarantees reproducibility. Anyone with Nix installed can run your flake and get the exact same tools, on any Linux distribution or macOS version. The host system becomes irrelevant.

Your First Flake

Nix uses a file format called a flake to define projects. A flake declares inputs (dependencies) and outputs (what the project provides). For Rust development, the most common output is a development shell.

Create a flake.nix in your project root. This minimal example sets up a shell with a stable Rust toolchain.

{
  description = "Reproducible Rust build";

  inputs = {
    # Pin nixpkgs to a channel for stability
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    # Helper library to generate outputs for multiple systems
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    # Generate outputs for x86_64-linux, aarch64-darwin, etc.
    flake-utils.lib.eachDefaultSystem (system:
      let
        # Load packages for the current system architecture
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        # Define the default development shell
        devShells.default = pkgs.mkShell {
          buildInputs = [
            # Fetch the latest stable Rust toolchain
            pkgs.rust-bin.stable.latest.default
            # Include cargo for building
            pkgs.cargo
          ];
        };
      }
    );
}

Run nix develop in the terminal. Nix evaluates the flake, fetches the inputs, builds the shell derivation, and drops you into a subshell. The PATH is modified so that rustc and cargo point to the Nix store versions. Run cargo build as usual. When you exit the shell, the environment returns to normal.

Trust the hash. If the path changes, the content changed.

How Nix Builds the Shell

When you run nix develop, Nix performs several steps. First, it parses flake.nix and fetches the inputs. The inputs block defines where Nix gets the package set and utilities. Nix checks for a flake.lock file. If it exists, Nix uses the locked versions. If not, Nix fetches the latest versions and creates the lock file.

Next, Nix evaluates the outputs function. The flake-utils library iterates over supported systems. For each system, it loads the package set (pkgs) and constructs the devShells.default attribute. The pkgs.mkShell function takes a set of buildInputs and returns a derivation that sets up a shell environment with those inputs on the PATH.

Nix then builds the derivation. If the inputs are already in the store, this is instant. If not, Nix downloads pre-built binaries from the cache or builds from source. Finally, Nix activates the shell. It sets environment variables, adjusts PATH, and starts your shell.

Convention: The community prefers explicit input names. Using nixpkgs and flake-utils as keys makes the flake easier to read and maintain. Avoid renaming inputs unless you have a specific reason.

The Lock File: Your Safety Net

The flake.lock file is the heart of reproducibility. It records the exact commit hashes of every input. Without it, nixos-unstable moves forward, and your flake might suddenly pull in a breaking change.

When you first run nix develop, Nix creates flake.lock. Commit this file to your repository. Every developer and CI runner will use the same inputs.

To update dependencies, run nix flake update. Nix fetches the latest versions and updates the lock file. Review the changes, then commit.

Pitfall: Forgetting to commit flake.lock leads to drift. Two developers might have different lock files, causing subtle build differences. Always include flake.lock in version control.

Convention: Use nix flake lock --update-input nixpkgs to update only specific inputs. This gives you granular control over updates.

Realistic Setup with rust-overlay

The minimal example uses rust-bin, which works but has limitations. The Rust community standard for flakes is rust-overlay. It provides better integration with rustup, supports toolchain files, and handles components like rustfmt and clippy more cleanly.

A realistic flake uses rust-overlay and pins the toolchain via rust-toolchain.toml. This matches the workflow of developers who use rustup locally.

Create a rust-toolchain.toml in your project root:

[toolchain]
channel = "1.75.0"
components = ["rustfmt", "clippy"]

Update flake.nix to use rust-overlay:

{
  description = "Reproducible Rust build with rust-overlay";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
    # rust-overlay provides better rustup integration
    rust-overlay.url = "github:oxalica/rust-overlay";
  };

  outputs = { self, nixpkgs, flake-utils, rust-overlay }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        # Apply rust-overlay to the package set
        pkgs = import nixpkgs {
          inherit system;
          overlays = [ (import rust-overlay) ];
        };
        # Derive toolchain from rust-toolchain.toml
        rustToolchain = pkgs.rust-bin.fromRustupToolchainFile {
          src = ./rust-toolchain.toml;
        };
      in
      {
        devShells.default = pkgs.mkShell {
          buildInputs = [
            # Use the derived toolchain
            rustToolchain
            # Add common dev tools
            pkgs.cargo-clippy
            pkgs.cargo-fmt
          ];
          # Set RUSTUP_TOOLCHAIN to match the pinned version
          RUSTUP_TOOLCHAIN = rustToolchain.rust.version;
        };
      }
    );
}

This setup ensures that the Rust version in the Nix shell matches the version in rust-toolchain.toml. If you update the toolchain file, Nix automatically picks up the change.

Convention: Use fromRustupToolchainFile when your project already has a rust-toolchain.toml. It keeps the toolchain definition in one place. If you don't use rustup, you can specify the version directly in the flake.

Pitfalls and Errors

Nix introduces new concepts that can trip up developers. Here are common issues and how to resolve them.

If you run nix develop without a lock file, Nix creates one. If you later run nix develop --no-pure-eval, Nix might ignore the lock file and fetch fresh inputs. This breaks reproducibility. Stick to pure evaluation unless you have a specific reason.

Error: flake input 'nixpkgs' is not locked. This happens when flake.lock is missing or corrupted. Run nix flake lock to regenerate it.

Error: cannot build derivation '/nix/store/...-shell.nix.drv'. This usually means a package is missing or incompatible with your system. Check the buildInputs and ensure the package exists in nixpkgs.

Pitfall: Global rustup interference. If you have rustup installed globally, it might conflict with the Nix shell. The Nix shell overrides PATH, so cargo and rustc should point to Nix. However, rustup might still complain about toolchains. Set RUSTUP_TOOLCHAIN in the shell to silence warnings.

Convention: Use nix fmt or nixpkgs-fmt to format Nix files. Consistent formatting reduces noise in diffs. Add a pre-commit hook to run the formatter.

Counter-intuitive but true: Nix builds can be slow on the first run because it downloads or builds packages. Subsequent runs are instant if the inputs haven't changed. Trust the cache.

Decision: Nix vs Alternatives

Choose the right tool for your workflow. Nix excels at reproducibility but adds complexity.

Use Nix when you need a reproducible development environment that integrates with your host system's editors and terminals. Use Nix when your team works across different operating systems and you want to eliminate environment drift. Use Nix when you are building complex projects with many system dependencies beyond Rust crates.

Use Docker when you are deploying a container and need the build environment to match the runtime container exactly. Use Docker when you want to isolate the build completely from the host system, including filesystem and network access.

Use rustup and cargo alone when you are working on a single machine and don't care about reproducing the environment on another computer. Use rustup when you want the simplest possible setup and are willing to accept environment drift.

Use just or make for task automation, but pair them with Nix to ensure the tools those tasks rely on are available. Task runners don't manage dependencies; Nix does.

Where to go next