Arch is a gateway drug to NixOS
Most Linux users cut their teeth on a "batteries included" distro. Ubuntu, Fedora, Debian, or an equally easy-to-use option is a common way to get started with Linux.
At some point these distros fall short. You may want to try a tiling window manager or desktop environment that isn't offered out of the box, and while you can shoehorn these in, it eventually becomes easier (and cleaner) to build your system up from a minimal base than it would take to bend a pre-configured distro into shape.
It's in these situations where Arch really shines. Out of the box you get:
- tools to bootstrap the system
- an init system
- a barebones userspace environment
- a package manager and repository of pre-built/binary packages
Once the initial installation is complete, you have everything needed to start building your system, without any cruft. Oh, and the wiki; holy shit, I almost forgot to mention the wiki.
An accretion of state
With the wiki at your side, you're equipped to configure your new system exactly to your liking. There are pages for everything: desktop environments, window managers, disk encryption, Secure Boot, DNS, printing, etc. With all of that context, setting everything up feels great because you're learning about each of these systems as you piece them together. There's a sense of mastery in doing this, and as your familiarity with them increases, you become better equipped to maintain and troubleshoot these systems when things go wrong.
And despite its reputation as an advanced Linux distro, Arch installs are generally low-maintenance. Rolling releases mean packages (and the wiki) are always up to date, and in the rare cases where something breaks, you're equipped to find a solution rather than just wipe the system and start fresh.
However, when I did have to re-install, it was painful. All of the configuration I'd done over the course of years had to be re-created. Yes, I had a dotfiles repo, but none of my system-level configuration was transferrable. What packages did I have installed? Which of those was I actually using? What services were configured? How did I set up full disk encryption and the bootloader again? The list was long, and those were only the things I could actually remember.
Naturally, I started documenting the process. The Arch wiki covers generic configuration, but these docs would cover mine. This was tolerable, if a little tedious; whenever I'd run through the process, I'd take notes to make it easier the next time. Getting the system to a mostly-complete state would still take the better part of a day, and there would always be a thing or two that I'd have to tweak over the following weeks.
At some point in late 2023, the SSD I'd been using started throwing errors, and rather than run through this process again, I started shopping around. I'd heard about Nix/NixOS and its ability to define your system in a declarative fashion. With nothing left to lose, it felt as good a time as any to give it a shot.
NixOS: Kicking the tires
In broad strokes, installing NixOS involves:
- Booting into the live/installation disk
- Prepping and mounting the target disk (partitions, encryption, filesystems, swap)
- Creating your initial configuration using
nixos-generate-config
- Tweaking the configuration with things like initial passwords, wifi credentials, etc.
- Bootstrapping the system using
nixos-install
and said configuration
Once complete, the generated config spans two files on the target disk:
/etc/nixos/hardware-configuration.nix
/etc/nixos/configuration.nix
We'll cover these two files in more detail, but before doing that, we need a quick primer on the Nix language itself.
Nix: the language
Nix is many things; one of those things is a language.
While you'll eventually want to learn Nix in greater detail, that's not what we came here to do. That said, navigating the generated configuration is pretty easy, requiring only a few basic concepts. Here's a simple, annotated snippet of a Nix configuration:
{ config, lib, pkgs, ... }: { # (1)!
# Enable the default firewall.
networking.firewall.enable = true; # (2)!
# Define a list of packages to be installed system-wide.
environment.systemPackages = [ # (3)!
pkgs.firefox
pkgs.vim
pkgs.wget
];
}
-
NixOS organizes configuration into modules. By convention, these generally map 1:1 to files on disk, and contain a single function. Your newly-generated configuration files are modules and follow this convention.
This opening line is a function declaration, and the
{ config, lib, pkgs, ... }
portion is defining its arguments. These three are the most common ones provided by Nix, but they only need to be explicitly defined if you actually use them. The...
ellipsis captures anything else, allowing the function to accept an arbitrary number of arguments. -
Your configuration is built by assigning values to attribute paths like these. This terse notation has the same effect as writing out the following:
networking = { firewall = { enable = true; }; };
If we wanted to configure several related values, we would have used an expanded notation like this:
networking.firewall = { enable = true; allowedTCPPorts = [ 22 80 ]; };
-
Nix has different value types: booleans, strings, arrays, and maps/attribute sets. In this example, the
environment
module has defined asystemPackages
option with an array type. When building our configuration, Nix will check this to prevent us from incorrectly assigning the wrong type, only applying our configuration if all of the types check out.
How did we come up with these attribute paths?
Where did the networking.firewall.enable
and environment.systemPackages
paths come from?!? These map to module options, and they're
documented1; in effect, they form the API for Nix configurations. As you
declare your config, you're really just building a hierarchical data
structure. Nix will run your configuration2 to produce that structure and
will merge it with defaults. The result? A complete, validated picture of
your configured system.
That's enough context around the language; let's dive into the specific configuration files Nix generates out of the box to see how this plays out in practice.
Hardware configuration
When bootstrapping your configuration, the nixos-generate-config
script
captures all sorts of important details about your system. These make their way
into a hardware configuration file, which is used to automate things like
fstab
entries for partitions and swap, LUKS device unlocking, loading
microcode, and network device configuration.
Order is important
In order to generate a hardware configuration that captures all of these details, they need to be available when the script is run; that's why we do so after partitioning, encrypting, and formatting the target disk(s).
Having a declarative configuration for your hardware is a big deal. You can run
rm -rf /
, boot back into the installation disk, and re-install NixOS without
having to do any repartitioning, LUKS setup, or filesystem configuration.
Barring a total drive failure, boot and disk setup just work.
System configuration
The other generated artifact is your system configuration file, which is responsible for, well, everything else. That file includes declarations for what you'd expect out of the box:
- bootloader
- timezone and locale
- user account(s)
- sound
- desktop environment
- basic system-wide applications (git, vim, etc.)
The initial configuration.nix
file weighs in around 70 lines. That's
surprisingly concise for a full system configuration, and the underlying concept
that enables it is one of Nix's greatest strengths.
Short and sweet
One of the most satisfying parts of NixOS is that, not only is your configuration declared in plain text, it's concise, easy to read, and written in a single language. It also uses sensible defaults, so you only need to configure the options you care about.
Let's illustrate this with a real example. Here's the declaration I use to configure OpenSSH:
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "no";
PasswordAuthentication = false;
};
};
It's easy to understand what this does at a glance, and not only does it produce
the sshd_config
file for you, it also defines a systemd service that runs
after boot.
This is a powerful abstraction. Rather than having to learn the configuration syntax for a bunch of different tools, you have a unified, documented API3 that exposes the options you're most likely to override. All of this makes configuring your system feel an awful lot more like programming.
Solving the original problem
Let's unwind the stack and get to the original problem we were trying to solve: setting up a fully-functional Arch environment is onerous, and while it doesn't happen often, the more involved your setup, the more painful it is.
NixOS gives you the same level of control over your system, but the interface is considerably more elegant. Rather than reading wiki pages and manually configuring services in a variety of bespoke, disjoint formats, you get a universal, functional language and tooling to manage them all with powerful abstractions that allow you to do so quickly and concisely.
Most importantly, this foundation allows you to explore more exotic desktop environments, knowing the investments made in configuration are tracked and easily reproducible, and that when things go sideways, reversible.4 Tweaking your system is one of the most "Linux" things you can do, and NixOS makes that process feel less like a waste of time and more like a thoughtful investment in your tools.
All of that to say, while Arch will always have a special place in my heart,
NixOS is my default distro for the foreseeable future.
-
Run
man configuration.nix
to see the full list of configurable options. ↩ -
Which is just a function, remember? ↩
-
Again, see
man configuration.nix
for the full list. ↩ -
We didn't get to it, but NixOS has the concept of generations, where snapshots are captured every time a change is applied to your system. These are presented during boot, allowing you to revert to a previous state if you mess things up. ↩