The first time someone pitched Nix to me, I laughed. That was 2018. In 2024 I run a production infrastructure on Nix flakes, and I write this post from the other side of a three-year migration. Nix is not a casual tool, and its supply chain properties are often misrepresented in both directions -- evangelists oversell them, skeptics underestimate them. This is the honest version.
What Nix Actually Guarantees
Nix builds are content-addressed derivations. Every input -- source code, compiler, build script, environment variable -- is hashed, and the hash becomes part of the output path. The output path looks like /nix/store/8pkjm9r...-glibc-2.38. Two builds with identical inputs land on the same path. That sameness is what lets Nix share outputs across machines safely.
The supply chain implications are significant. An attacker who swaps a dependency cannot silently substitute it because the store path would change, which would invalidate every downstream consumer. An attacker who tampers with a build script produces a different output hash. The content-addressed store is a Merkle tree, and tampering anywhere breaks the chain upward.
But "content-addressed" does not mean "reproducible." Reproducibility is the separate claim that rebuilding the same derivation produces the same bytes. Nix goes further than most build systems here, but it is not perfect. The Reproducible Builds project tracks the delta -- as of early 2024, roughly 97% of nixpkgs is bit-for-bit reproducible across builds, which is the highest of any major Linux distribution.
Flakes, and Why They Matter for Supply Chain
Flakes were experimental from Nix 2.4 (November 2021) and remain "experimental" in name even though large production deployments run on them. Flakes solve a problem classical Nix never really addressed: pinned, verifiable, transitive dependencies at the project level.
A basic flake.nix looks like this:
{
description = "payments-api";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-23.11";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
in {
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "payments-api";
version = "1.4.2";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
};
});
}
The critical part is flake.lock, which is auto-generated and committed alongside flake.nix. It pins every input to a specific git revision and narHash. For our payments-api project, flake.lock is 1,247 lines, and every input -- including transitive ones, because flakes resolve transitively -- has a narHash field. Modify the upstream without changing the hash, and Nix refuses the build.
I have seen engineers treat flake.lock as an inconvenience and update it casually. Don't. Treat it the same way you treat package-lock.json or go.sum: review every change, and use nix flake update --commit-lock-file so the change lands in a discrete commit.
Binary Caches and the Trust Boundary
Nix avoids rebuilding everything from scratch by substituting outputs from a binary cache. The default cache is cache.nixos.org, signed by NixOS's release key. Custom caches like Cachix are trusted by adding their public key to nix.settings.trusted-public-keys.
This is where the supply chain story gets interesting. A binary cache is effectively a distribution channel. If you trust a cache, you trust anything served from it with a valid signature. The security model depends on:
- The private key remaining private.
- The substituter URL being authenticated (HTTPS or IPFS).
- Your set of trusted keys not being tampered with.
For our production systems, we run a self-hosted nix-serve on an isolated VM, signed by a key held in an HSM. The CI pipeline builds, uploads to this cache, and production nodes substitute from it. We explicitly do not trust cache.nixos.org for production artifacts -- only for the initial Nix installation bootstrap, after which the system is locked to our internal cache.
The classical Nix substitution flow has a known weakness: cache poisoning via key compromise. If an attacker gets the signing key, they can push malicious derivations that match legitimate output paths. Content-addressed derivations (Nix 2.6+ via __contentAddressed = true) partially mitigate this by making the output path a function of the actual output content rather than the derivation hash, but they are not yet the default in nixpkgs.
The Bootstrapping Problem
Nix has a bootstrapping problem that every reproducible-builds practitioner eventually wrestles with. To build GCC, you need a C compiler. To build that compiler, you need another compiler. The chain goes back to a binary seed -- the bootstrap-tools tarball, which is checked into nixpkgs.
The bootstrap-tools are not reproducible in the strict sense. They were built once, hashed, and enshrined in nixpkgs. If you want full reproducibility from source, you need to follow the Guix-style chain ("Reduced Binary Seed Bootstrap") or rebuild the bootstrap tools from a different starting point and hope they match.
For most supply chain threat models, this is acceptable. The bootstrap tools are reviewed, their hashes are in source control, and any tampering would require writing to the nixpkgs repo. But if your threat model includes nation-state compromise of NixOS release infrastructure, you have further work to do.
Pure Evaluation Mode
Enable --pure-eval in CI. This flag, available since Nix 2.4, forbids impure operations during evaluation: no reading $HOME, no fetching without hashes, no reading arbitrary paths. It catches supply chain surprises before they become build artifacts.
Our CI uses:
nix build .#default \
--pure-eval \
--no-allow-import-from-derivation \
--fallback \
--option sandbox true \
--option require-sigs true
--no-allow-import-from-derivation is the subtle one. Import-from-derivation lets a flake evaluate code produced by an earlier build step. It is powerful and dangerous -- it is the Nix analog of a postinstall script. Forbidding it in CI means every evaluation is a pure function of committed files.
SBOMs from Nix
Nix does not natively produce SPDX or CycloneDX SBOMs, but the derivation graph is the source of truth. Tools like nix-sbom and the built-in nix why-depends can walk the graph and emit SBOMs. We use a wrapper that emits CycloneDX 1.5 with the narHash as the component hash field and the flake input as the supplier. The resulting SBOM is richer than most language-ecosystem SBOMs because Nix sees the entire native closure -- glibc, openssl, and so on -- not just the Rust or Go dependencies.
How Safeguard Helps
Safeguard ingests Nix-generated CycloneDX SBOMs and correlates components against OSV, GHSA, and NVD for vulnerability visibility across the full native closure. Policy gates can block flake updates that introduce vulnerable transitive dependencies or change narHash values without a signed commit. For teams running private binary caches, Safeguard's attestation tooling verifies that substituted artifacts match their expected signatures and flags drift when a cache serves an unexpected output path. Combined with Nix's content-addressed model, this gives a reproducible, auditable, and policy-enforced supply chain from source to production binary.