Exactly One Version Everywhere
This is the goal anyway. For Rust, I proposed remote dependency definition to the Rust Internals group and continue working on re-exports to get closer to the ideal.
Capabilities Achieved
- Every repo can update to exactly one version of all dependencies.
- Updating one central file sets that version, including the Rust toolchain.
- Upgrades are lazy and opt-in for each repo.
- Overrides can be done independently per-repo or shared for several repo via overlay.
- Dev toolchains are all using the same versions.
- Nix command line programs also use the same nixpkgs.
In other words, utopia. Everything has been won. The last holdouts will be in Cargo.toml files.
The Beauty
These are the flake inputs to one of our repos:
inputs = {
# pins is defined in the flake registry. No need to remember.
pins.url = "pins";
# and every input just follows the leader
nixpkgs.follows = "pins/nixpkgs";
fenix.follows = "pins/fenix";
crane.follows = "pins/crane";
};
- Nixpkgs provides all non-Rust dependencies
- Fenix is used to ship a Rust compiler and toolchain
- Crane builds Rust with Nix so that we can shove it into containers
Note the lack of any kinds of URLs anywhere. What is not defined here cannot rot here.
What Was Avoided
If you have been to some organizations that have polyrepo and also adopted one flake per-repo, the builds are reproducible, but the versions in use can be rotten and mismatched like crazy. Everywhere that we write full URLs is a place that can bit-rot. This ends up having a lot of costs and taking a lot of unnecessary risks.
How to Obtain This Power?
- All inputs to each repo's flake use
follows
to delegate their versions to a single central flake - The central definition uses
follows
in inputs so that they cover a minimum set (full example):
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs"; ❄️ re-using nixpkgs above!
};
};
- The local flake registry has the central definition flake included, so we can reference its inputs simply as
"pins/crane"
etc. - The nixpkgs in the local flake registry is pinned to the same rev used in the central flake (recursive pins into the registry doesn't seem to be supported).
Minimizing Input Sets
Run nix flake metadata
on the pins flake to verify that all duplicate transitive dependencies are made into explicit dependencies referenced by follows
(as long as nothing needed their own version to work):
└───fenix: github:nix-community/fenix/acdb...
├───nixpkgs follows input 'nixpkgs'
└───rust-analyzer-src: github:rust-lang/rust-analyzer/68e7...
Here, you can see that fenix is re-using the nixpkgs but is bringing in its own Rust-Analyzer source. This isn't the end of the world because the concrete version is in the lockfile and because Rust-Analyzer is not shared among any other inputs. Duplication of superficially different versions at best wastes disk space
Using the Registry for Shorter Names
nix registry add pin git+ssh://git@github.com/positron-solutions/pins
Overriding / Avoiding the Registry
The lockfile should propagate the concrete version to CI/CD, but you can also set it manually, which is also how to override the lockfile, such as when testing an available upgrade:
nix build --override-input pins \
git+ssh://git@github.com/positron-solutions/pins?rev=COMMIT_HASH
Share Overrides in Overlays
The pins flake doesn't do a lot. But it does ship an overlay. When some dependency needs a tweak, we can put the fix in the overlay, making it easily obtainable to all other repos:
pkgs = import nixpkgs {
inherit system;
overlays = [
fenix.overlays.default
pins.overlays.default # ❄️
];
};
We already needed fenix, so including the overlay just one extra line.
Home Manager
- Our shared tooling is described in a Home Manager module
- Even the version of home manager comes from pins
- Since my home manager flake uses pins to select nixpkgs, even my Emacs is built by the same dependency set.
Pinning Nixpkgs for Nix CLI
This detail is a quality of life improvement because of improved caching but also creates less pressure on the supply chain from dev machines downloading ephemeral binaries:
nix registry add nixpkgs \
github:NixOS/nixpkgs/1f08a4df998e21f4e8be8fb6fbf61d11a1a5076a
The hash is taken straight from pins's flake.lock (nix flake metadata
). As a result, even when I was testing fonttools
with nix shell nixpkgs#fonttools
I was using the same dependencies. When I decided fonttools would suit us and included it in our home manager, the result still used the same version of nixpkgs and so was already cached on my system.
⚠️ When you do this, you have to make bumping the registry on every machine part of your upgrade process. This is why I would prefer to re-reference the registry so that updating pins is enough.
Dispersion is The Enemy 😠
Different people running different updates at different times in different repos can obtain different versions. Different definitions that guide those updates can obtain different versions. Over time, you wind up with all sorts of version combinations in use. This is dispersion.
When you find out about a new feature you need or vulnerability to fix, if the upgrade or patch involves creating a unique combination of versions that will only be used by that project, you have created dispersion.
Dispersion is Risk
Sometimes different versions of different dependencies behave differently. Sometimes they refuse to build. Sometimes it depends on the combination of dependencies.
The frequency of problems that will be encountered and that can only exist due to versions or their interactions goes up every time we play remix version DJ. This includes:
- every supply chain vector
- every version mismatch that won't build for infuriating reasons
- every subtle behavior that is difficult to track down because it's not in our code or worse, only happens because of two different versions talking to each other
They all become more frequent when different repos wind up with different versions. Many of these problems show up at the worst times and do make it into production.
Don't Scale Up Your Problems
The pace of upgrading should not super-linearly increase the number version combinations that wind up in use somewhere. If I update once a year, I should have exactly two version to worry about, the new one and the old one. If I patch 10 CVEs in 10 repos, I should not wind up with 73,774 more unique dependencies in use. If the 10 CVEs are patched in the same batch, you should get two unique sets.
Dispersion is Slow! 🐢
Nix has caching. If you have 999 versions, you will slowly fill your cache with 999 things that were not cached! Defining concrete inputs per-project and then allowing them to rot means that many more cache misses, that many times waiting on rebuild, that much more disk space.
Reprodicibility is Not What We're Talking About
You can have dispersion with Nix! One of the biggest problems of switching to pure dependency management is suddenly realizing that you have 18 versions of Rustc cached because they don't just pick the one on $PATH
If you just have flakes in every project, you can still be subject to a lot of dispersion. It is well-tracked, reproducible dispersion, but until it is reconciled, it is still more version roulette than you need.
At all times all of the projects are reproducible, but they depend on none of the same things, meaning all of the negative consequences.
What Does Updating Look Like?
Nix is a tool with up-front switching cost. It is not until the first upgrade cycle that the dividends suddenly come roaring in. Upgrading anything maintained in Nix is the best advertising for Nix.
Nixpkgs is a rolling release with periodic stable branching that follows a naming scheme such as 24.11 or 25.05. 25.05 is the most recent. In the pins flake, edit the branch for nixpkgs (if necessary, only changes every six months):
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05"; # was 24.11
After that, run a simple nix flake update
to fetch new versions based on the definitions. You can of course use tags and revs for all inputs if desired.
Push the changes, then in a leaf repo, update the pins input:
nix flake update pins
We only updated one dependency for the repo, but since the one we upgraded is the central set of all of our dependencies, we upgraded all of them, each to an exact version stored in the pins repo's flake.lock file.
Updating Rust
The way we obtain Rust would lazily track stable, but because we only ever see updates to Fenix when we update our pins and because Fenix will encode specific versions of the Rust toolchain, all of our repos build with the exact same version of Rust.
rustToolchain = with pkgs.fenix; combine [
targets.wasm32-unknown-unknown.stable.rust-std
stable.rust-src
stable.rustc
stable.cargo
stable.rustfmt
stable.clippy
];
No work is required. Update pins and then say hello to your new Rustc.
So How Did It Go?
After shipping our first prototype, it was time to do regular upgrades.
First I updated pins, changing the branch name and running nix flake update
to grab new revs of all the inputs.
Second, in a leaf repo, I built and ran server to check that I had a good build. I then updated the lockfile's view of pins by running:
nix flake update pins
Immediately after the command, the Nix direnv integration fired, fetched and built everything, and dropped me into a new shell with the new variables. Because of the new Rust compiler version, the next build was a full rebuild.
Given that only the underlying toolchains and not Rust dependency versions (described in Cargo.toml) changed, it was unsurprising that everything just worked.
That's not always the whole story. Sometimes there are C dependencies for sys-crates. The cargo leptos tool needs to match the wasm bindgen used by the project. If cargo leptos in nixpkgs gets updated while the bindgen in Cargo.toml does not, it will break.
To publish this article, I ended up using a newer cargo leptos from nixpkgs-unstable. The production build uses wasm-opt, so it wasn't until publish time that I identified an issue with the binaryen version, but now the fix is in and will be propagated by flakes to the other repos. Since our "nixpkgs-unstable" is pinned everywhere, it's newer and less tested, but not in constant flux.
One by one, the I have been updating while writing this. All repos were fine. I updated my home packages. By the time you read this, I have deployed our containers on new nixpkgs, Rust, and some updates to our Rust dependencies that were put off until shipping the prototype.
Lazy Upgrades are Golden
Across all of our repos, our versions are limited almost entirely to package set N
or N - 1
. Since the flake lockfiles preserve the old choices, without making an effort, each repo continues using the old version of pins and thus all of the old versions overall.
Operationally, this means while everything is very tightly controlled, we will encounter problems when we are looking for them, such as right now, after a major release milestone, not when the factory is burning down and some automatic trigger runs us into a build error.
This is working out great. Updating lots of software that you have to maintain yourself can be a chore. Being able to go repo by repo, making one change, and then checking for a manageable amount of problems is bliss. I can still build every container at all times except the one I'm upgrading. Upgrades are very predictable because all repos will mostly have the same problems and fixes propagate through overlays.
Remember to Clean Up
After a big update, one of the benefits of such consistent dependency versioning is that all the old versions can be given back to the disk. Disk usage is one of the biggest complaints about using Nix. And Cargo. Make nix-garbage-collect
more effective by not having ten versions of everything. Tools like cargo sweep
(and machete!) are really helpful for keeping the SSD fresh. Recent Reddit thread on deleting roots.
Synchronizing Flakes
The example flake, doesn't follow our internal pins (actually named pinning). To update it exactly, override the input:
nix flake update --override-input nixpkgs \
github:nixos/nixpkgs/1f08a4df998e21f4e8be8fb6fbf61d11a1a5076a
Particulars of My System
- I use per-user home-manager installations and absolutely recommend that everyone does it this way. Wanting to install a program should not accidentally run you into time spent on bootloaders and graphics drivers. While I do coordinate updates when I can, if the OS has some disruptive issue, I'll roll it back and get back to work until I can focus.
- My NixOS on my laptop is still using the old non-flake channels. I re-pin nixpkgs when updating, and so it is an inconsequential and minor debt.
The bigger problem I really need to deal with is that Grub stopped being able to boot my encrypted disk a while ago and so I've been mixing in a bootloader from nixpkgs…23? To move to systemd boot, I need to expand my boot partition enough to fit a kernel in, which likely means completely migrating all of my data and switching to flakes in one step. Good times ahead.It was not fun. I used external boot disks to get systemd on a new partition in some trailer space I had forgotten about. - Some programs, like Emacs, I deliberately limit my Nix use for. While I get my 3rd party binaries like language servers, tree-sitter, and Emacs itself with Nix, I use and recommend an Elisp package manager for managing Elisp dependencies. Emacs is a live system that we are meant to program during use. Inserting a Nix rebuild into the feedback loop is a ton of extra friction. From-source package managers like Elpaca put the developer very, very close to upstreaming changes.
Ways We Can Improve
CI/CD? 🤠 What I have planned is to optimistically apply updates to pins throughout the leaf repos in CI. This will identify problems early. I plan to leave the results in a machine PR, letting a human to flip the switch so we don't get blindsided by automation.
On the Rust side, re-export is definitely one of the better tools emerging. Exporting preludes is not just a great way to eliminate version dispersion. It also just makes life easier by avoiding the need to edit use
statements and Cargo.toml
altogether in many cases.
Want to Work With Nix and Rust?
Check our careers page. PrizeForge is really important. I need co-founders who can handle cascading updates and continuous materialization. Better yet, make us a great frontend based on our existing Leptos work! PrizeForge needs testers to help get off the ground. Anyone can treat PrizeForge like our company tip jar while we go from prototype to MVP. Funds are matched two-dimensionally with an expanding threshold. Contributors are in control and programmers don't have to run campaigns or sell Solar Roads. Follow our socials to help us get messages out and be there when things happen.