Pinning NixOS with npins, or how to kill channels forever without flakes
Start of Meetup: "hmm, Kane is using nixos channels, that's not good, it's going to gaslight you"
6 hours later: Utterly bamboozled by channels
6.5 hours later: I am no longer using channels
Nix channels, which, just like Nix, is a name overloaded to mean several
things, are an excellent way to confuse and baffle yourself with a NixOS
configuration by making it depend on uncontrolled and confusing external
variables rather than being self-contained. You can see an excellent
explanation of the overloaded meanings of "channels" at samueldr's
blog. In this post I am using "channels" to refer to the
nix-channel
command that many people to manage what <nixpkgs>
points to,
and thus control system updates.
It is a poorly guarded secret in NixOS that nixos-rebuild
is simply a bad
shell script; you can read the sources here. I would even go
so far as to argue that it's a bad shell script that is a primary contributor
to flakes gaining prominence, since its UX on flakes is so much better: flakes
don't have the /etc/nixos
permissions problems or the pains around pinning
that exist in the default non-flakes nixos-rebuild
experience. We rather owe
it to our users to produce a better build tool, though, because nixos-rebuild
is awful, and there are currently the beginnings of efforts in that direction
by people including samueldr; colmena
is also an example of a better build
tool.
Both the permissions issue and the pinning are extremely solvable problems though, which is the subject of this post. Flakes have their flaws and, more to the point, plenty of people just don't want to learn them yet, and nobody has yet met people where they are at with respect to making this simplification without doing it with flakes.
This is ok! Let's use something more understandable that does the pinning part of flakes and not worry about the other parts.
This blog post teaches you how to move your NixOS configuration into a repo
wherever you want, and eliminate nix-channel
altogether, instead pinning the
version of <nixpkgs>
and NixOS in a file in your repo next to your config.
Background: what NixOS builds actually do
First, let's say how NixOS builds actually work, skipping over all the remote
build stuff that nixos-rebuild
also does.
For non-flakes, <nixpkgs/nixos>
is evaluated; that is, nixos/default.nix
in
<nixpkgs>
. This resolves the NIX_PATH
entry <nixos-config>
as the first
user-provided NixOS module to evaluate, or alternatively
/etc/nixos/configuration.nix
if that doesn't exist. For flake configurations,
substitute yourflake#nixosConfigurations.NAME
in your head in place of
<nixpkgs/nixos>
.
The default NIX_PATH
is the following:
nix-path = $HOME/.nix-defexpr/channels nixpkgs=/nix/var/nix/profiles/per-user/root/channels/nixpkgs /nix/var/nix/profiles/per-user/root/channels
That is to say, unless it's been changed, <nixpkgs>
will reference root's
channels, managed with nix-channel
.
Next, the attribute config.nix.package
of <nixpkgs/nixos>
is evaluated then
built/downloaded (!!) unless it is a flake config (or --no-build-nix
or
--fast
is passed). Then the attribute config.system.build.nixos-rebuild
is
likewise evaluated and the nixos-rebuild
is re-executed into the one from the
future configuration instead of the one from the current configuration, unless
--fast
is passed.
Once your configuration has been evaluated once or twice pointlessly, it is
evaluated a third time, for the attribute config.system.build.toplevel
, and
that is built to yield the new system generation.
This derivation is what becomes /run/current-system
: it contains a bunch of
symlinks to everything that forms that generation such as the kernel, initrd,
etc
and sw
(which is the NixOS equivalent of /usr
).
Finally, the-build-result/bin/switch-to-configuration
is invoked with an
argument switch
, dry-activate
, or similar.
From this information, one could pretty much write a NixOS build tool: it really is
just nix build -f '<nixpkgs/nixos>' config.system.build.toplevel
(in old
syntax, nix-build '<nixpkgs/nixos>' -A config.system.build.toplevel
), then
result/bin/switch-to-configuration
. That's all it does.
Background: what is npins anyway?
npins
is the spiritual successor to niv, the venerable Nix pinning
tool many people used before switching to flakes. But what is a pinning tool
for Nix anyway? It's just a tool that finds the latest commit of something,
downloads it, then stores that commit ID and the hash of the code in it in a
machine-readable lock file that you can check in. When evaluating your Nix
expressions, they can use builtins.fetchTarball
to obtain that exact same
code every time.
That is to say, a pinning tool lets you avoid having to copy paste Git commit IDs around, and ultimately does something like this in the end, which hands you a path in the Nix store with the code at that version.
builtins.fetchTarball {
# https://github.com/lix-project/lix/tree/main
url = "https://github.com/lix-project/lix/archive/992c63fc0b485e571714eabe28e956f10e865a89.tar.gz";
sha256 = "sha256-L1tz9F8JJOrjT0U6tC41aynGcfME3wUubpp32upseJU=";
name = "source";
};
Let's demystefy how pinning tools work by writing a trivial one in a couple of lines of code.
First, let's find the latest commit of nixos-unstable with git ls-remote
:
~ » git ls-remote https://github.com/nixos/nixpkgs nixos-unstable
4a6b83b05df1a8bd7d99095ec4b4d271f2956b64 refs/heads/nixos-unstable
~ » git ls-remote https://github.com/nixos/nixpkgs nixos-unstable | cut -f1
4a6b83b05df1a8bd7d99095ec4b4d271f2956b64
Then we can construct an archive URL for that commit ID, and fetch it into the Nix store:
~ » nix-prefetch-url --name source --unpack https://github.com/nixos/nixpkgs/archive/4a6b83b05df1a8bd7d99095ec4b4d271f2956b64.tar.gz
0zmyrxyrq6l2qjiy4fshjvhza6gvjdq1fn82543wb2li21jmpnpq
And finally fetch it from a Nix expression:
~ » nix repl
Lix 2.90.0-lixpre20240517-0d2cc81
Type :? for help.
nix-repl> nixpkgs = builtins.fetchTarball { url = "https://github.com/nixos/nixpkgs/archive/4a6b83b05df1a8bd7d99095ec4b4d271f2956b64.tar.gz"; name = "source"; sha256 = "0zmyrxyrq6l2qjiy4fshjvhza6gvjdq1fn82543wb2li21jmpnpq"; }
nix-repl> nixpkgs
"/nix/store/0aavdx9m5ms1cj5pb1dx0brbrbigy8ij-source"
This is essentially exactly what npins does, minus the part of saving the
commit ID and hash into npins/sources.json
.
We could write a simple shell script to do this, perhaps called
./bad-npins.sh
:
#!/usr/bin/env bash
name=nixpkgs
repo=https://github.com/nixos/nixpkgs
branch=nixos-unstable
tarballUrl="$repo/archive/$(git ls-remote "$repo" nixos-unstable | cut -f1)"
sha256=$(nix-prefetch-url --name source --unpack "$tarballUrl")
# initialize sources.json if not present
[[ ! -f sources.json ]] && echo '{}' > sources.json
# use sponge from moreutils to deal with jq not having the buffering to safely
# do in-place updates
< sources.json jq --arg sha256 "$sha256" --arg url "$tarballUrl" --arg name "$name" \
'.[$name] = {sha256: $sha256, url: $url}' \
| sponge sources.json
and then from Nix we can load the sources:
let
srcs = builtins.fromJSON (builtins.readFile ./sources.json);
fetchOne = _name: { sha256, url, ... }: builtins.fetchTarball {
name = "source";
inherit sha256 url;
};
in
builtins.mapAttrs fetchOne srcs
Result:
~ » nix eval -f sources.nix
{ nixpkgs = "/nix/store/0aavdx9m5ms1cj5pb1dx0brbrbigy8ij-source"; }
We now have a bad pinning tool! I wouldn't recommend using this shell script, since it doesn't do things like check if redownloading the tarball is necessary, but it is certainly cute and it does work.
npins
is pretty much this at its core, but well-executed.
Fixing the UX issues
We know that:
<nixpkgs>
as seen bynixos-rebuild
determines what version of nixpkgs is used to build the configuration.- Where the configuration is is simply determined by
<nixos-config>
- Both instances of duplicate configuration evaluation are gated on
--fast
not being passed.
So, we just have to invoke nixos-rebuild
with the right options and
NIX_PATH
such that we get a config from the current directory with a
nixpkgs
version determined by npins
.
Let's set up npins, then write a simple shell script.
$ npins init --bare
$ npins add --name nixpkgs channel nixos-unstable
You can also use nixos-23.11
(or future versions once they come out) in place
of nixos-unstable
here, if you want to use a stable nixpkgs.
Time for a simple shell script. Note that this shell script uses nix eval
,
which we at Lix are very unlikely to ever break in the future, but it does
require --extra-experimental-features nix-command
as an argument if you don't
have the experimental feature enabled, or
nix.settings.experimental-features = "nix-command"
in a NixOS config. (The
experimental feature can be hacked around with
nix-instantiate --json --eval npins/default.nix -A nixpkgs.outPath | jq -r .
,
which works around nix-instantiate --eval
missing a --raw
flag, but this is
kind of pointless since we are about to use flakes features in a second)
#!/usr/bin/env bash
cd $(dirname $0)
# assume that if there are no args, you want to switch to the configuration
cmd=${1:-switch}
shift
nixpkgs_pin=$(nix eval --raw -f npins/default.nix nixpkgs)
nix_path="nixpkgs=${nixpkgs_pin}:nixos-config=${PWD}/configuration.nix"
# without --fast, nixos-rebuild will compile nix and use the compiled nix to
# evaluate the config, wasting several seconds
sudo env NIX_PATH="${nix_path}" nixos-rebuild "$cmd" --fast "$@"
Killing channels
Since building the config successfully, we can now kill channels to stop their
reign of terror, since we no longer need them to build the configuration at
all. Use sudo nix-channel --list
and then sudo nix-channel --remove CHANNELNAME
on each one. While you're at it, you can also delete /etc/nixos
if you've moved your configuration to your home directory.
Now we have a NixOS configuration built without using channels, but once we are
running that system, <nixpkgs>
will still refer to a channel (or nothing, if
the channels are deleted), since we didn't do anything to NIX_PATH
on the
running system. Also, the nixpkgs
flake reference will point to the latest
nixos-unstable
at the time of running a command like nix run nixpkgs#hello
.
Let's fix both of these things.
For context, by default, on NixOS 24.05 and later, due to PR
254405, flake-based NixOS
configs get pinned <nixpkgs>
and a pinned nixpkgs
flake of the exact same
version as the running system, such that nix-shell -p hello
and nix run nixpkgs#hello
give you the same hello
every time: it will always be the same
one as if you put it in systemPackages
. That setup works by setting
NIX_PATH
to refer to the flake registry /etc/nix/registry.json
, which then
is set to resolve nixpkgs
to /nix/store/xxx-source
, that is, the nixpkgs of
the current configuration.
We can bring the same niceness to non-flake configurations, with the exact same code behind it, even!
Let's fix the NIX_PATH
. Add this module worth of code into your config
somewhere, say, pinning.nix
, then add it to imports
of configuration.nix
:
{ config, pkgs, ... }:
let sources = import ./npins;
in {
# We need the flakes experimental feature to do the NIX_PATH thing cleanly
# below. Given that this is literally the default config for flake-based
# NixOS installations in the upcoming NixOS 24.05, future Nix/Lix releases
# will not get away with breaking it.
nix.settings = {
experimental-features = "nix-command flakes";
};
# FIXME(24.05 or nixos-unstable): change following two rules to
#
# nixpkgs.flake.source = sources.nixpkgs;
#
# which does the exact same thing, using the same machinery as flake configs
# do as of 24.05.
nix.registry.nixpkgs.to = {
type = "path";
path = sources.nixpkgs;
};
nix.nixPath = ["nixpkgs=flake:nixpkgs"];
}
New workflow
When you want to update NixOS, use npins update
, then ./rebuild.sh
(./rebuild.sh dry-build
to check it evaluates, ./rebuild.sh boot
to switch
on next boot, etc). If it works, commit it to Git. The version of nixpkgs comes
from exactly one place now, and it is tracked along with the changes to your
configuration. Builds are faster now since we don't evaluate the configuration
multiple times.
Multiple machines can no longer get desynchronized with each other. Config commits will build to the same result in the future, since they are self-contained now.
Conclusion and analysis
We really need to improve nixos-rebuild
as the NixOS development community.
It embodies, at basically every juncture, obsolescent practices that confuse
users and waste time. Modern configurations should be using either
npins/equivalent or flakes, both of which should be equally valid and easy to
use choices in all our tooling.
Flags like --no-rebuild-nix
come from an era where people were building
flake-based configs from a Nix that didn't even have flakes, so they needed
to be able to switch to an entirely different Nix to be able to evaluate
their config. We should never be rebuilding Nix by default before re-evaluating
the configuration in 2024. The Nix language is much, much more stable these
days, almost frozen like a delicious ice cream cone, and so the idea of
someone's config requiring a brand new Nix to merely evaluate is bordering on
absurd.
It doesn't help that this old flakes hack actually breaks cross compiling
NixOS configs, for which --fast
is thus mandatory. The re-execution of
nixos-rebuild
is more excusable since there is still work to do on that like
capturing output to the journal,
but it is still kind of bothersome to eat so much evaluation time about it; I
wonder if a happier medium is that it would just build pkgs.nixos-rebuild
instead of evaluating all the modules, but that has its own drawback of ignoring
overlays in the NixOS config...
Another tool that needs rewriting, documentedly
so is nixos-option
, which is
a bad pile of C++ that doesn't support flakes, and which could be altogether
replaced by a short bit of very normal Nix code and a shell script.
There's a lot of work still to do on making NixOS and Nix a more friendly toolset, and we hope you can join us. I (Jade) have been working along with several friends on https://lix.systems, a soon-to-be-released fork of CppNix 2.18 focused on friendliness, stability, and future evolution. People in our community have been working on these UX problems outside Nix itself as well. We would love for these tools to be better for everyone.