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.