+++
date = "2024-05-20"
draft = false
path = "/blog/pinning-nixos-with-npins"
tags = ["nix"]
title = "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
\- [@riking@social.wxcafe.net](https://social.wxcafe.net/@riking/112465844452065776)
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][samueldr-channels]. In this post I am using "channels" to refer to the
`nix-channel` command that many people to manage what `` points to,
and thus control system updates.
[samueldr-channels]: https://samuel.dionne-riel.com/blog/2024/05/07/its-not-flakes-vs-channels.html
It is a poorly guarded secret in NixOS that `nixos-rebuild` is simply a bad
shell script; you can [read the sources here][nixos-rebuild]. 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][samueldr-flakes] 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.
[samueldr-flakes]: https://samuel.dionne-riel.com/blog/2023/09/06/flakes-is-an-experiment-that-did-too-much-at-once.html
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 `` and NixOS in a file in your repo next to your config.
[nixos-rebuild]: https://github.com/nixos/nixpkgs/blob/b5c90bbeb36af876501e1f4654713d1e75e6f972/pkgs/os-specific/linux/nixos-rebuild/nixos-rebuild.sh
# 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, `` is evaluated; that is, [`nixos/default.nix`][nixos-defaultnix] in
``. This resolves the `NIX_PATH` entry `` 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
``.
[nixos-defaultnix]: https://github.com/nixos/nixpkgs/blob/6510ec5acdd465a016e5671ffa99460ef70e6c25/nixos/default.nix
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, `` will reference root's
channels, managed with `nix-channel`.
Next, the attribute `config.nix.package` of `` 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 '' config.system.build.toplevel` (in old
syntax, `nix-build '' -A config.system.build.toplevel`), then
`result/bin/switch-to-configuration`. That's all it does.
# Background: what is npins anyway?
[`npins`][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.
```nix
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`:
```bash
#!/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:
```nix
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.
[npins]: https://github.com/andir/npins
[niv]: https://github.com/nmattia/niv
# Fixing the UX issues
We know that:
1. `` as seen by `nixos-rebuild` determines what version of nixpkgs
is used to build the configuration.
2. Where the configuration is is simply determined by ``
3. 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)
```bash
#!/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, `` 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](https://github.com/NixOS/nixpkgs/pull/254405), *flake*-based NixOS
configs get pinned `` 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`:
```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](https://github.com/NixOS/nixpkgs/pull/287968),
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](https://github.com/NixOS/nixpkgs/issues/293543) 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 , 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.