Switching to NixOS

Contents

NixOS is a radical reimagining of what a Unix-like operating system can be like. It has a concept of generations that permit you to roll back (or forward) to previous instances of your operating system state. It’s defined entirely by code written in the Nix expression language, a functional programming language designed to make reproduicble builds easy. That’s right, NixOS is fully reproducible: if you have a NixOS machine definition, then you can reproduce not only the exact bytes of each package installed on that machine, but the entire state of the machine itself (besides ephemeral machine-specific bits).

The only comparable project I am aware of is GNU Guix, a relatively newer project that attempts similar goals using Scheme instead of a domain-specific programming language.

It took a long time for me to warm up to the Nix approach to Unix systems. It takes the entire FHS and throws it out the door, and by extension requires you to un-learn concepts that have been more-or-less stable for 30 years. While I have learned to appreciate what Nix offers, certain parts of NixOS remain opaque to me behind veils of abstraction. But with a few months of experience behind me, I can at least state that I like NixOS, that it is usable as a daily driver operating system, and that NixOS is (generally speaking) successful in achieving its lofty goals.

What is Nix?

The term Nix can have three meanings, depending on context. I’ll cover each of them here.

Expression language

The Nix expression language is a domain-specific programming language for reproducible configuraiton of packages and systems. It is a lazy, functional, mostly pure language primarily concerned with the manipulation of associative data structures termed sets:

nix-repl> mul = { left, right }: left * right

nix-repl> mul { left = 10; right = 30; }
300

Most Nix functions take sets as arguments and return sets to inform the caller about the result of a computation.

The standard way to learn Nix is to study the Nix Pills, which start at the interpreter and work their way up to full package definitions.

Alternatively, you can study the wiki page, which is a terse reference for the Nix language.

Package manager

The Nix pacakage manager is a tool for installing and managing derivations (packages) written in the Nix expression language. The official nixpkgs repository contains a whopping 60,000 packages, which puts it near the top distributions in terms of package count (approximately the same number as Debian or Fedora).

Operating system

Installation

Installing NixOS is similar to installing Arch Linux in many ways. There is no official installer, just live .iso and a giant manual. You boot the live USB, partition a disk, mount the disk, generate hardware configuration, and run the installer, which installs the necessary components of the operating system to the mounted disk(s). Unlike Arch, if you wish to customize this system, you have one file to modify: /etc/nixos/configuration.nix. This file defines the entire machine state.

Opt-in state

I used some modifications to this installation from grahamc’s Erase Your Darlings and mt-caret’s Encypted Btrfs Root with Opt-in State on NixOS. Although NixOS tries to make its systems fully reproducible, this is not always possible.

Consider /etc/resolv.conf. This file is generated by resolvconf on a typical systemd Linux system and expected to change if your device roams between networks. In lieu of persistently keeping (and tracking) all the state from /etc, we treat /etc as a semi-ephemeral filesystem, and link only the required persistent configuration from /persist/etc.

In /etc/nixos/configuration.nix:

  environment.etc = {
    nixos.source = "/persist/etc/nixos";
    machine-id.source = "/persist/etc/machine-id";
    NIXOS.source = "/persist/etc/NIXOS";
    adjtime.source = "/persist/etc/adjtime";
  };

Then /etc/nixos/configuration.nix is a hardlink to /persist/etc/nixos/configuration.nix:

# stat /etc/nixos/configuration.nix /persist/etc/nixos/configuration.nix
  File: /etc/nixos/configuration.nix
  Size: 5629            Blocks: 16         IO Block: 4096   regular file
Device: 0,55    Inode: 1141        Links: 1
Access: (0600/-rw-------)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2022-03-23 09:55:00.014521725 -0400
Modify: 2022-03-23 09:55:00.014521725 -0400
Change: 2022-03-23 09:55:00.015521737 -0400
 Birth: 2022-03-23 09:55:00.014521725 -0400
  File: /persist/etc/nixos/configuration.nix
  Size: 5629            Blocks: 16         IO Block: 4096   regular file
Device: 0,55    Inode: 1141        Links: 1
Access: (0600/-rw-------)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2022-03-23 09:55:00.014521725 -0400
Modify: 2022-03-23 09:55:00.014521725 -0400
Change: 2022-03-23 09:55:00.015521737 -0400
 Birth: 2022-03-23 09:55:00.014521725 -0400

If we wanted to keep additional state, we can expand the list of links in the Nix definition for our machine.

Config-as-code

A sufficiently complete specification of a system policy leads to the notion of an ideal average state for the system. Over time, the ideal average state of the system degrades. The aim of system administration is to keep the system as close to its ideal state as possible.

-M. Burgess, Theoretical System Administration

In my professional life, I’ve written a fair amount of Puppet, Ansible, and Chef code defining the state of Linux systems. These tools all share a model of Linux systems as mutable, living systems. Their policy is designed to take a system from an unknown initial state toward an ideal state. After sufficient repeated applications of this process of convergence, the system state should tend towards our ideal state.

Nix works differently. Its policies, like most configuration management tools, model the end-state of a system. Unlike traditional configuration management tools, Nix does not assume an unknown initial state. It relies heavily on sandboxing and build isolation to produce ideal states from known initial states.

In some sense, this is a strictly easier problem to solve than convergent configuration management. Nix takes steps to ensure there is only one possible path from initial state to ideal state, and therefore can ensure repeated applications of a Nix derivaiton always result in the same end-state.

Home directory management

Like many users of Unix-like operating systems, I spent a significant amount of time building and tweaking my dotfiles collection to configure new users the way I want. I went through repeated iterations of attempts at making this process manageable, but the tools were never quite sufficient for the job. Prior to Nix, the best solution I’d found was to use GNU Stow to create and manage symlinks from ~/.vimrc to ~/src/github.com/nathantypanski/dotfiles/vim/.vimrc and so on. This works well for individual files, but many tools also have plugin directories and other state that is easy to lose track of. Over time, the dotfiles repository diverges from local system state.

The Nix solution to this problem is home-manager. This is a tool that lets you use the same declarative configuraiton for your home directory (and its many configuration files) as you would for the NixOS operating system.

Example

Here’s an example of home-manager, taken from my current configuration:

{ config, pkgs, lib, ... }:

{
  imports = [ ./zsh.nix ];

  home.username = "nathan";
  home.homeDirectory = "/home/nathan";
  home.stateVersion = "22.05";

  # Let Home Manager install and manage itself.
  programs.home-manager.enable = true;

  services.gpg-agent = {
    enable = true;
    defaultCacheTtl = 1800;
    enableSshSupport = false;
  };

  home.keyboard.options = ["ctrl:nocaps"];

  # continued below ...

Installing and configuring a window manager is as simple as including its block:

  # continued from above ...

  wayland.windowManager.sway = {
    enable = true;
    wrapperFeatures.gtk = true ;
    config = {
      terminal = "alacritty";
      fonts = {
        names = ["pango:Terminus"];
        style = "normal";
        size = 10.0;
      };
    };
  };

  # continued below ...

You can install per-user packages here. This lets you install software for a particular user that won’t be available to the rest of the system. I use users to segment different types of tasks (e.g., gaming), and I wouldn’t want my gaming user to have access to compiler toolchains.

  # continued from above ...

  home.packages = with pkgs; [
    haskell.compiler.ghc921
    pass
    rustup
    go-tools
    go
    python39
    python39Packages.pip
    python39Packages.virtualenv
  ];

  # continued below ...

Most programs you would normally have in dotfiles with per-application configuration are managed natively using the Nix domain-specific language.

  # continued from above ...

  programs.git = {
    enable = true;
    userName = "ndt";
    userEmail = "...";
  };

  # continued below ...

Even the shell configuration is defined in the Nix language:

  # continued from above ...

  programs.zsh = {
    enable = true;
    enableCompletion = true;
    enableSyntaxHighlighting = true;
    history = {
      save = 10000;
      size = 10000;
      share = true;
      extended = true;
      ignoreSpace = true;
      ignorePatterns = [
        "rm *"
        "pkill *"
      ];
    };
    shellAliases = {
      x = "tmux";
    };
  };
}

Why?

It’s probably not immediately apparent what the benefit of using Nix to configure Git, or the shell, or a window manager might be. Each of these tools has its own configuration language already, and you might have taken the time to learn each program’s config format. Migrating this configuration can take time, and besides, you’re just expressing the same config in a different format, right?

Generations

One of the most fundamental benefits of Nix management is its concept of generations. Every deployment of changes to your Nix-managed homedir produces a generation. You can enumerate the generations using the home-manager CLI:

$ home-manager generations | head
2022-04-11 09:07 : id 65 -> /nix/store/9j3s2canrz3q2rwh19maywlj0xgm27lr-home-manager-generation
2022-04-11 09:05 : id 64 -> /nix/store/gdcf7r29kphnq6mcmkj9zqjknlbh3gp7-home-manager-generation
2022-04-11 09:04 : id 63 -> /nix/store/rsqf8dyhz9lk1j5351r6zracaanwxq34-home-manager-generation
2022-04-05 20:49 : id 62 -> /nix/store/a2c65dx05595smgw58j02ffy2cm4rrq0-home-manager-generation
2022-03-12 18:09 : id 61 -> /nix/store/98b16il8x7rzma5pr75njwavwnadl7p4-home-manager-generation
2022-03-12 13:15 : id 60 -> /nix/store/d0rvr0nx3y2rj0ixa71n9q0a9lpc7dp8-home-manager-generation
2022-03-12 13:15 : id 59 -> /nix/store/73xqnpzsfm42hjp76kx4bi769ld4gzmm-home-manager-generation
2022-03-11 10:49 : id 58 -> /nix/store/waizbp6iqdp6zgxxrbvbn1pagxk7jh4f-home-manager-generation
2022-03-11 09:58 : id 57 -> /nix/store/bg169sw55c440f0rsd7pxsh31zsdw43q-home-manager-generation
2022-03-11 09:54 : id 56 -> /nix/store/m3bq41252rcrp8xc48982h5kfhgpbvix-home-manager-generation

If I’d like to roll back to one of these generations, I can simply run the activate script for that generation. For example, to roll back one generation (to 64), I would run:

$ /nix/store/gdcf7r29kphnq6mcmkj9zqjknlbh3gp7-home-manager-generation/activate
Starting Home Manager activation
Activating checkFilesChanged
Activating checkLinkTargets
Activating writeBoundary
Activating installPackages
replacing old 'home-manager-path'
installing 'home-manager-path'
Activating linkGeneration
Cleaning up orphan links from /home/nathan
Creating profile generation 66
Creating home file links in /home/nathan
Activating onFilesChange
Activating reloadSystemd

This is a powerful feature enabling experimentation. Mistakes are trivial to undo, and you get a fully reproducible configuration for your home directory. That means you get baked-in guarantees that if you choose to spin up a new system with the same configuration, the configuration will not only apply successfully—it will produce exactly the same result.

NixOS generations

The concept of generations doesn’t originate in home-manager. In fact, it’s a first-party feature of NixOS proper. Each version of your system configuration gets recorded in the generations list, and you can restore the system to any of those versions with a single command with nix-env --rollback (for the previous version) or nix-env -G 3. Note that you may have to set the profile to system in order to change the global system instead of per-user configuration.

# nix-env --list-generations --profile /nix/var/nix/profiles/system | tail
  91   2022-04-10 16:06:58
  92   2022-04-10 16:18:46
  93   2022-04-10 16:22:37
  94   2022-04-10 16:39:26
  95   2022-04-21 10:04:08
  96   2022-04-22 09:25:09
  97   2022-04-24 13:08:53
  98   2022-04-24 13:11:39
  99   2022-04-24 14:48:43
 100   2022-04-24 14:49:28   (current)

Shells

A feature I didn’t think I’d care for, but ended up using all the time was nix-shell -p ${package_name} to spawn a new Bash shell with some requested software available, but (crucially) without making that software available to the system as a whole.

Let’s say I want to use swayshot to take a screenshot, but I don’t have slurp or grim installed:

$ nix-shell -p slurp grim
these paths will be fetched (0.03 MiB download, 0.10 MiB unpacked):
  /nix/store/0silcp1jlicjbjbjhzvmkffj2wck4m5z-grim-1.4.0
  /nix/store/yc2sc0k5d3bm9n6wq57qmmv4dsndkzpn-slurp-1.3.2
$ ./swayshot.sh

Being able to experiment with different tools on-the-fly like this, grants a powerful feeling of freedom to try things. If you don’t like a tool you just tried, then don’t add it to /etc/nixos/configuration.nix and it won’t pollute your environment. The next time you run nixos-collect-garbage, it will be removed from the Nix store.

Likewise home-manager can list packages that are available only to a certain user, but not other users or the root user.

Downsides

NixOS is not perfect. In exchange for all this functional, immutable, reproducible OS magic, we need to trade a few things (at least today).

Documentation

This is still the worst part of NixOS. Community efforts have strived to improve the Nix documentation. Today we have Nix Pills for learning Nix-the-language, the NixOS Manual explaining how to install and configure NixOS systems, and the NixOS Wiki which provides succinct howtos on common problems.

That sounds great, right? In theory, those resources are everything you need. In practice, they each seem to land at the wrong level of abstraction, and the symptoms of this are similar to the problems found with monad tutorials in something like Haskell. The people writing monad tutorials understand monads. You do not. The problem is once you learn how monads work, you lose the ability to explain monads to anyone who doesn’t already know how they work.

This kind of pedagogy disruptor field is common when you’re explaining concepts that one day click. The day before, you didn’t understand NixOS. Then you use it for a few days/weeks/months and one day you suddenly have enough of the pieces in place and it clicks and you understand how the system works. Do you understand all the prerequisites for reaching this state of understanding? Not consciously.

The NixOS documentation authors are all plagued by the pedagogy disruptor field. For instance, the Nix store is an extraordinarily fundamental part of NixOS systems. It contains all installed packages, each prefixed by a cryptographic hash of their contents, and is located at /nix/store:

$ ls /nix/store | head
000yp1grcymcfbmncflf2bhbqyzb8p62-hook.drv
0018h2fjjq0zijmyknykxvwysaj24qw0-timeit-2.0.tar.gz.drv
001gp43bjqzx60cg345n2slzg7131za8-nix-nss-open-files.patch
001ybmr7k4bj79nknk7ykzfqa7wqw55h-source.drv
001ynjbfcyzg60w6y1x0hjx106ixydq8-unit-script-prepare-kexec-start
002gbsl500p1b9m4wlinazna58mcmn6z-gnum4-1.4.19.drv
003cl64qdhj7ng8pjmnihhda315q5czg-home-manager-path.drv
0042c0dpzvx4khk34wl8ikikjrsv3fwn-conduit-1.3.4.2.drv
004fc2vsbnzsw43ci25hqk08rpvyagy0-catalog-legacy-uris.patch.drv
004h9inrdzqj2sfgssfpmlsl8mp9dn23-source.drv

The first mention of the Nix store in the NixOS manual (not counting the syntax summary) is in the wifi setup section. There are descriptions of how to clean the nix store, sections on preventing storage of secrets in the nix store, and numerous references to the Nix store with the implication that the reader already understands what it is. At no point is the Nix store defined, or descriptively outlined.

Many attributes of Nix or NixOS are treated in this way. Maintenance tasks for obscure subsystems are described in intricate detail, but the purpose or behavior of those subsystems is absent in documentation.

The language

The Nix language is syntactically ugly. I have yet to decide whether it is actually bad. There’s a GNU reimplementation of NixOS called Guix that attempts to address this by replacing Nix with Guile, a dialect of Lisp.

The Nix language is almost entirely side-effect free. Most of the time, all you’re doing with it is templating Bash scripts with increasingly high-level abstractions.

The standard way to learn it is you go read Nix pills and then you read the wiki. When it comes time to author real-world packages you search nixpkgs for similar applications to learn patterns, using the manual for reference.

Conclusion

Nix is a radical approach to software packaging, and it makes reproducibility of complex software systems easier than any other tool I know of. At the same time, it’s a complex and largely undocumented beast. Working with Nix reminds me of pre-1.0 Rust: smart ideas, constant changes, and a growing push for stabilization and documentation that gets better each month.

I still have my gripes with it, but the promise of a new and innovative way to manage systems has finally ripped me away from Arch Linux in search of something better than Unix-style userspace organization. The ability to effortlessly experiment and rollback changes to my OS has made hacking on Linux fun again.