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 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, 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>' (in old syntax, nix-build '<nixpkgs/nixos>' -A, 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 {
url = "";
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 nixos-unstable
4a6b83b05df1a8bd7d99095ec4b4d271f2956b64	refs/heads/nixos-unstable
~ » git ls-remote nixos-unstable | cut -f1

Then we can construct an archive URL for that commit ID, and fetch it into the Nix store:

~ » nix-prefetch-url --name source --unpack

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 = ""; name = "source"; sha256 = "0zmyrxyrq6l2qjiy4fshjvhza6gvjdq1fn82543wb2li21jmpnpq"; }
nix-repl> nixpkgs

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 ./

#!/usr/bin/env bash


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:

srcs = builtins.fromJSON (builtins.readFile ./sources.json);
fetchOne = _name: { sha256, url, ... }: builtins.fetchTarball {
name = "source";
inherit sha256 url;
builtins.mapAttrs fetchOne srcs


~ » 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:

  1. <nixpkgs> 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 <nixos-config>
  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)

#!/usr/bin/env bash

cd $(dirname $0)

# assume that if there are no args, you want to switch to the configuration

nixpkgs_pin=$(nix eval --raw -f npins/default.nix nixpkgs)

# 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. = {
type = "path";
path = sources.nixpkgs;
nix.nixPath = ["nixpkgs=flake:nixpkgs"];

New workflow

When you want to update NixOS, use npins update, then ./ (./ dry-build to check it evaluates, ./ 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, 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.