NixOS, channels, flakes and my take on a fully reproducible config
Intro
If you had a chance to stumble upon my NixOS configuration repository on GitHub,
you might have read deep enough to find holy-mother-of-scripts.fish.
Every NixOS user has to write their own deployment script at some point and mine started ballooning pretty quickly.
The primary goal of course is to perform nixos-rebuild and track the config using git.
You can then add extra stuff like committing only on a successful build, pushing, cleaning etc. It quickly becomes a NixOS update all-in-one solution.
Eventually the problem of full reproducibility started staring me right in the face.
I have my config, I commit the changes sure, but what about the pesky sudo nix-channel --update?
How can you track that? It seems to be a one way street. If you follow the official guides, you're told to just keep your stable channel up to date and don't worry about it.
This has some obvious problems (pinning, rollbacks?), but what do we do about it? what even are channels??
Channels, Hydra, flakes?
Let's roll back a little and define what is needed to fully define a single nixos-rebuild execution. There's always 2 parts:
- Nixpkgs i.e. your input. The exact version, branch, fork, whatever. You need a nixpkgs store to even have nix in the first place
- your nix configuration. Declaration of your system and package derivation. How
nixos-rebuildwill derive your system from nixpkgs
- Channel branch of nixpkgs
- Channel output of a Hydra job
- the
nix-channelcommand
Hydra is a CI/CD build tool for nix/nixpkgs. The nixos-unstable branch of nixpkgs contains the output of nixos:unstable:tested aggregate jobs (?), which basically is the master branch getting tested periodically and if the build succeeds it ends up in nixos-unstable. The bi-annual stability campaign for nixos-stable is a bit more complex, but both are basically Hydra tested branches of the nixpkgs repository with different frequencies/policies. You can track whichever one you want as your input.
Now for your derivation, you need some way of grabbing and maintaining one of these channel branches. There are a few different ways, but I present 3 of them here:
- the
nix-channelcommand (still the default) - flakes (bleh)
- npins (cool)
The flakes topic is too big for this blog post. I don't like flakes as a feature, because they are doing too much all at once and make simple stuff unnecessarily complex. You like using flakes? Fantastic, I'm happy for you. Please let me keep not using them thanks.
To the point
So the concept is actually quite simple. Arriving here took a bit of digging and trial and error, as with all nix development, but here I am.
We start with npins as mentioned earlier. Self described as a tool for "Nix dependency pinning".
It works as described, you can use the CLI to add dependencies including, but not limited to channels.
They all get saved in sources.json
and thus can be edited and tracked by git, allowing for transparent and clear version pinning and rollbacks.
Once you have an initialized npins folder with a nixpkgs pin there are only 2 components in your config/deployment script that do the magic.
First is this nix code snippet I have saved as pinning.nix
{
config,
pkgs,
...
}:
{
# kill channels
nix = {
channel.enable = false;
nixPath = [
"nixpkgs=/etc/nixos/nixpkgs"
"nixos-config=/home/USERNAME/nixos-config/configuration.nix"
];
};
environment.etc = {
"nixos/nixpkgs".source = builtins.storePath pkgs.path;
};
# command-not-found fix
programs.command-not-found.dbPath = "/etc/nixos/nixpkgs/programs.sqlite";
}
First off channel.enable = false; disables the nixos channels feature. Then the nixPath is configured to point to some arbitrary 2 locations.
nixpkgs points it to a new location to save the current gen of nixpkgs (our channel essentially) and nixos-config just helps us with not having to deal with /etc/nixos/.
Important bit is happening at environment.etc = { "nixos/nixpkgs".source = builtins.storePath pkgs.path; }; where the actual source of nixpkgs (store path) gets linked to the location from above.
You might ask at this point, ok but where is the store path resolution happening?
This is done every time we run nixos-rebuild using holy-mother-of-scripts.fish.
Specifically these 2 lines
set -l nixpkgs_path (nix-instantiate --json --eval npins/default.nix -A nixpkgs.outPath | jq -r .) sudo nixos-rebuild $rebuild_type -I nixos-config=/home/USERNAME/nixos-config/configuration.nix -I nixpkgs=$nixpkgs_path --show-trace 2>&1 | tee rebuild.logHere, nix-instantiate (modern npins has a built in command for it but I cba) is used to extract the store location of the current nixpkgs as pinned by npins. Then
nixos-rebuild is run with the -I options to force the nixpkgs and config locations. That's it.
The nix config code earlier makes sure the store is linked to our known location meaning everything else that needs it can find it easily.
Extras, sources, acknowledgments, idk everything else
So this is a result of some digging around and I think it's important how I actually arrived here. I'm by no means a computer scientist, software engineer or a sysadmin. I'm a hardware engineer who learned about compiler pre-processors while writing SystemVerilog. If anyone else is particularly autistic about NixOS the way I am, but struggles with the whole math and nix language behind it, these blog posts are your only source of documentation. (bless the modern official wiki.nixos.org, but we still have a way to go).
- Jade's blog post or basically where it all started
- piegames blog postsome more modern progress that gets rid of flakes
- Pinning NixOS with npins One of the first links you see when googling this