Diving Into NixOS (Part 2): The Power Of Declarative Configuration
Landing on planet NixOS
If you've ever taken a look at the Linux universe, you will have found numerous flavors - more correctly known as distributions - of operating systems. Each one slightly different from the other, almost like planets within a solar system, or perhaps akin to the thousands of delicious flavors of ice cream (this analogy sounds more appealing). The one thing keeping them all in orbit? The singular, common star at the center that is the Linux kernel.
Every distribution will have made a variety of design choices that brought them into their respective orbits, each possessing different properties that make them particularly desirable to different space travelers. Planet Ubuntu for the plain Jane, Kali for the security conscious, or Elementary for the design savvy. Some of the distributions lie light years away from our familiar planet Ubuntu (read Arch), and require the more foolhardy, renegade space cowboys to tame the lay of the land, and build a self-sustaining civilization before they can start beaming more spacefarers over.
In my case, I feel as though I was beamed right into NixOS' warm and welcoming atmosphere, stumbling onto a medium-sized planet whose surface is entirely composed of a beautiful-yet-functional botanical garden of software and configuration. Beautiful, because it doesn't take shape to the user as a single, clunky desktop environment, but instead hosts a whole floral arrangement of options. Functional, because each part of the garden is organized into modular, declarative components, allowing newcomers to grow the "garden of their dreams".
The NixOS ecosystem
Enough with the analogies, what I really want to hone in on, are the reasons why I jumped from Ubuntu to NixOS. In Part 1 of the series, we looked at how I set-up my new laptop and made an initial installation of NixOS. Now, we take a deep dive into configuring the OS, looking briefly at why it is advantageous to have declarative control, before highlighting some of the available options and the decisions I took to build my current setup - which I've been using happily for about a half year at the time of writing.
As there will be snippets of configuration, it will probably be helpful to gloss over the Nix language syntax.
Why is NixOS' declarative approach so special?
To understand the significance of declarative configuration, it is helpful to
gain some insight into the limitations of dealing with non-declarative, or
"ad-hoc", systems. With other distributions, such as Ubuntu and Debian, or for
the tinkerers - Arch Linux, you would typically use a combination of package
managers (apt
, pacman
) to manage installed software and
their dependencies, alongside innumerable configuration scripts - some of which
are managed by root
, and others which are confusingly overridden by the
regular user.
Traditional systems
Consider the following scenario. You spend a painstaking amount of blood, sweat, and tears to create the "perfect" Arch setup. As time passes, you make modifications to your configurations, tuning the OS to your liking. You will occasionally find yourself unhappy with the changes you've made and want to revert them. Unfortunately, this means you will have to recall the five different files to which you made your changes, not to mention what the changes themselves were. Depending on your mood you ultimately end up either: spending another hour or so recovering your StackOverflow answers and undoing your changes bit-by-bit, or giving up and living with your undesired changes, leaving your system just that little bit less "perfect".
Now consider another scenario. One in which you have a C/C++ project that, for whatever reason, must be tested against multiple versions of GCC (I've encountered similar requirements at work). This is near impossible, if not a hassle, with standard package managers alone. A common solution would be to add a layer of virtualization, typically in the form of Docker or Vagrant, both of which are excellent tools for specific problems but add a layer of memory and computational overhead.
Let's not even begin to consider the possibility of building and installing
third-party software directly to the root directory /
- this works fine, at
least until you want to uninstall it! Might I dare to detail the process of
migrating the entire shebang to a fresh install of the OS on your new rig?
You probably get the picture.
Nix
NixOS avoids all of the aforementioned situations and introduces some added benefits. The Nix package manager is at the heart of the OS' success. Similar to other package managers, Nix also tracks dependencies between different pieces of software. It's most glowing innovation, however, is in how it manages the installed software under-the-hood and hands you a usable software stack.
Every software package in Nix is declared publicly on GitHub. They are each defined in Nix's own expression language, which in summary tells Nix how to build the package from source ("should it run CMake, Autoconf, plain Make?"), and what other software it depends on.
From a definition, Nix will build the software into its own directory (it
expects the child directory structure to match the Filesystem Hierarchy
Standard, FHS) named after a computed hash based-on the inputs to the
build definition together with the package name and version. Here's an example
of how the tree
command has been packaged on from my machine (Nix
definition).
/nix/store/x50x926i805qz046qbhssj5r6w2w05a6-tree-1.7.0/
And the directory contents (tree
-ing tree, how meta).
$ tree /nix/store/x50x926i805qz046qbhssj5r6w2w05a6-tree-1.7.0/
/nix/store/x50x926i805qz046qbhssj5r6w2w05a6-tree-1.7.0/
├── bin
│ └── tree
└── share
└── man
└── man1
└── tree.1.gz
4 directories, 2 files
Notably, once a package has been built, the contents of its store directory are immutable. Coupled with the hashes, each version of is guaranteed to be atomic. It should be obvious from this, that managing multiple versions of a software package alongside one another becomes trivial.
Our question then becomes: how is the software then made available to the user if each version of each software lives independently of each other? The answer is surprisingly simple.
$ ls -al /run/current-system/sw/bin | tail -n5
lrwxrwxrwx 1 root root 70 Jan 1 1970 zipdetails -> /nix/store/7yf3fh95ljf90nnw6cv70dry5jvqin0l-perl-5.28.1/bin/zipdetails
lrwxrwxrwx 1 root root 62 Jan 1 1970 zless -> /nix/store/zrzqgdm6jxihsban195vrlcskmx9m4zc-gzip-1.9/bin/zless
lrwxrwxrwx 1 root root 62 Jan 1 1970 zmore -> /nix/store/zrzqgdm6jxihsban195vrlcskmx9m4zc-gzip-1.9/bin/zmore
lrwxrwxrwx 1 root root 61 Jan 1 1970 znew -> /nix/store/zrzqgdm6jxihsban195vrlcskmx9m4zc-gzip-1.9/bin/znew
lrwxrwxrwx 1 root root 77 Jan 1 1970 zramctl -> /nix/store/hlk44cpp9nn7isb1jycxcj5f9lz0qa1v-util-linux-2.32.1-bin/bin/zramctl
Everything is symlinked! Nix knows how and what to symlink
because either the built package follows the FHS or the package definition
prescribes the information accordingly. So for each of the packages the user
requires in their environment, the contents of the package directory under
/nix/store/
are linked to common directories like bin/
or lib/
. Better
yet, using symlinks grants additional flexibility - changing the version of a
package means pointing the link to another target! Note that this also applies
to rolling back version changes - just point it back to the previous target.
This paradigm can be taken a step further and applied to the management of the
software and configuration for a whole OS, let alone per-user packages. In the
previous post, we took a quick look at the configuration.nix
file.
The OS bases its current state off this file, written in Nix's functional
expression syntax. Every new state of your machine that occurs as a result of
changes made to the file is versioned. Given the guarantees made by Nix, you can
be sure your whole setup is deterministic and hence reproducible. Rolling back
undesired changes to the OS works the same as rolling back software versions -
swapping a bunch of symlinks. Migrating your setup to a new machine is as
simple as copying the configuration.nix
file over and running nixos-rebuild
.
NixOS has quite an extensive catalog of configuration options, you may my current setup on GitLab.
When I began writing this post I didn't expect it to end-up quite so long. Consequently, I've split this topic out into two posts. This one acts as a primer Nix's design, whilst the next is a more practical case study of my xinit configuration.
Turns out there is quite a lot to say about the OS, despite its simplicity. I implore you to read more about Nix/NixOS here and here. Whoever took this concept and applied it at OS level raised its potential to a whole new level. NixOS has even been taken a step further and is being leveraged to provision infrastructure via NixOps. I'm a huge fan of Hashicorp, but I'm skeptical Terraform can match NixOS's simple-yet-functional power, although I'll finish by stealing one of their marketing taglines which I feel also applies to NixOS/Ops.
Infrastructure As Code - Terraform
NB I've intentionally avoided mentioning nix-shell
at this stage as we see more of it in
Part 4.