9 min read

NixOS, everything but the kitchen sink

Recently, I added a second machine to my self hosting fleet, a tiny offsite backup target to pair together with my NAS. Together with my gaming desktop and work laptop, brings the total to four computers that need to be configured and maintained over time.

Most of the dotfiles, Docker Compose files and other configuration files are nicely tucked away in a Git repository. This makes it easy not only to review how something was done, say adding and exposing a new service to the compose stack, but also to roll back to a previous version when something doesn’t go as planned.

Although this type of setup above works fairly well, it requires a bit of manual work to ensure that all relevant files are always tracked and symlinked to the repository. It’s also fairly easy to be lazy or, in the moment, simply forget that the changes you just made should have been tracked and versioned.

A couple weeks back I started messing around with NixOS in order to make the management of my systems more streamlined, and oh boy, does it deliver. NixOS not only makes it possible to define your entire operating system through a set of declarative configuration files, but it also comes with a broad ecosystem of tools that allows for some awesome workflows.

The idea behind this post is to outline the parts of Nix that drew me in. It by no means covers everything that Nix has to offer, but hopefully it will be enough to get you hooked and give it a try yourself.

The NixOS goodies

Declarative, composable and versioned

The three musketeers of good configuration. When working with NixOS your entire system will be defined through expressions written in the Nix language. Simply put, these are functions, which when executed by Nix return key-value pairs, called modules, describing the behavior and state of the system. Here is a chunk from an expression that ensures fzf and ripgrep are available system wide, and that SSH is running and open only for the dudek user.

environment.systemPackages = with pkgs; [ fzf ripgrep ];

services.openssh = {
  enable = true;
  settings = {
    AllowUsers = [ "dudek" ];
    PasswordAuthentication = false;
  };
};

Nix makes this configuration declarative, meaning it serves as a single source of truth for the system’s expected state. Any changes are automatically applied by Nix each time you load the configuration through the nixos-rebuild switch command. As an example, if you were to remove ripgrep from the list above, you never have to think about manually uninstalling the package or cleaning up any leftover files that might be lying around. You simply rerun the nixos-rebuild switch, and you are good to go.

Defining your system’s state using the Nix language makes for a very flexible experience. You have access to all the typical programming language constructs, which makes it easy to make the configuration modular and composable. For example, we could extract the openssh expression above into its own ssh.nix file. That file can then easily be imported and reused when defining configurations for multiple machines. If we had two servers, mallard and pelican, we could use our ssh.nix along with other extracted expressions like this.

nixosConfigurations.mallard = nixpkgs.lib.nixosSystem {
  modules = [ ./modules/ssh.nix ./modules/wireguard.nix ];
};

nixosConfigurations.pelican = nixpkgs.lib.nixosSystem {
  modules = [ ./modules/ssh.nix ./modules/defaultPackages.nix ];
};

In order to further customize our modules, we can turn them into functions accepting various parameters. For example let’s modify our ssh.nix to accept which allowedUsers are permitted on the corresponding machine, and then express our configuration like this.

nixosConfigurations.mallard = nixpkgs.lib.nixosSystem {
  modules [
    (import ./modules/ssh.nix { allowedUsers = [ "ben" "alice" ]; })
    ./modules/ssh.nix
  ];
};

Then comes versioning. Since our entire system configuration has been neatly put together in .nix files, these can be tracked and versioned with Git exactly as would with any other codebase. On top of that, each time the nixos-rebuild switch command is run, NixOS creates a new snapshot of your system. These snapshots can then, for instance, be accessed from your computer’s boot menu, allowing you to easily roll back to an earlier version.

Lastly, it’s hard to talk about Nix without mentioning reproducibility. It’s a huge cornerstone of why Nix exists in the first place. In a nutshell, Nix puts significant effort into ensuring that the configuration you have created is reproducible to an extremely high degree. This means that if you were to apply your configuration to a new computer, the resulting system would be mostly identical. While useful or even critical in certain contexts, this property is not of as much benefit when it comes to putting together my personal computer fleet.

Into the shell

Trying new tools with Nix is probably as frictionless as it can be.

$ nix shell nixpkgs#bottom nixpkgs#stow

After a few seconds, a new shell is spawned with btm and stow available. You can use and interact with these tools freely, and once you exit that shell instance, they’re gone. This allows us to create structured sub-environments with dedicated tools for different tasks. The shell definition can also live inside a project repository allowing any developer to quickly set up a shell using nix develop containing any dependencies that are required to run or contribute to the project. This makes it easier to work on multiple projects where different versions of the same dependency are used.

While we are on the topic of shells, Nix comes with a very convenient feature, the Nix shebang. This little bad boy allows you to define your script’s dependencies, along with the shell/program that executes the script, at the top of the file. For example, if we wanted to run a script using fish, and ensure that jq is available, we would do it like so.

#! /usr/bin/env nix
#! nix shell nixpkgs#fish nixpkgs#jq --command fish

curl -s https://api.github.com/users/octocat | jq ".name"

What about my laptop?

While I am very much looking forward to it, I haven’t yet tried the tools mentioned in this section

Your servers are buzzing away, elegantly configured with Nix and the configs nicely stowed away in a git repository. You lean back in your chair, take a sip of tea, while glancing down on your laptop. Little does it know, it has become the next target of your configuration frenzy.

In my case, the laptop is a company issued M1 Macbook Pro, perhaps not the kind of platform one would expect to be supported by Nix. While we can’t install NixOS on it directly, we can use nix-darwin to achieve a similar experience. nix-darwin does require jumping through a couple hoops, like setting up a supplementary read only volume on the system, but once in place, it behaves more or less like a typical Nix-based setup. In addition, through macOS defaults, it’s even possible to handle system settings from your declarative configuration, no more spelunking around in System Settings.app.

system = {
  defaults = {
    dock = {
      autohide = true;
      show-recents = false;
    };
  };
};

I was also pleasantly surprised to discover that you can declaratively install applications from the Mac App Store.

But wait! The rabbit hole does not end here, if you are crazy enough, there is also NixOS-WSL and nix-on-droid. Which as expected allow you to handle Linux packages on Windows and Android respectively.

But at what cost?

If you have read this far, you can probably tell that NixOS and the Nix ecosystem are substantial both in terms of features they offer, and how they can be applied to building out your digital environment. It’s therefore probably not surprising that the learning curve is similarly substantial.

The unique approach of how Nix handles configuration and dependencies, while being it’s biggest strength is also the biggest hurdle. It requires a non-insignificant time investment, from you, the user, to get comfortable in the new environment. The amount of high quality blogs really does help both to inspire and lead you through the process. At the same time, since Nix has iterated through features, you should watch out for older content that might not be as relevant.

The broad applicability of Nix might come with an overwhelming sensation. It’s tempting to dive right into the deep end and migrate everything at once using tools like Snowflake or Determinate. However Nix warrants a slow methodical approach, some things just take time, and in this case, it’s well worth the patience.

That said, I find that a lot of content written on this topic to be bit harsh. A few articles even claim that you are more or less throwing away your previous Linux skills when moving over to NixOS, but in my view, that’s an exaggeration at best. Any skills you pick up on your Nix journey will still be useful if you were to decide to move back to a more classic setup.