NixOS is magic

I’ve been using NixOS recently on my laptop, because I know how great declarative configuration is from my experience deploying software on servers with tools like Kubernetes and Terraform. Along the way, I’ve had a few experiences with it that have left me impressed with how powerful a declarative approach to the configuration of my personal computers can be.

Declarative vs imperative

If you’ve used any electronic device that you can install software on, you are already aware of the imperative approach to device configuration.

If a piece of software doesn’t exist on your machine and you want it to, you run an installer or tell the package manager to install it:

apt-get install -y neovim

A problem with installing packages this way is that over time you may build a collection of software with incompatible versions of dependencies. You might want to upgrade all the software on your machine, but you don’t have any guarantee that the upgrade will succeed. And if you do break something, the only way to fix it might be to reinstall. You might even want to reinstall every couple years anyways to start fresh.

NixOS lets you define the configuration of your system declaratively. The software you want to be installed on your system is listed (or enabled) in a config file — configuration.nix — and after you add new software to this file, you tell NixOS to rebuild your entire system by running nixos-rebuild switch.

{ config, pkgs, ... }:
{
  # ...
  programs.thunderbird.enable = true;

  programs.fish.enable = true;
  users.defaultUserShell = pkgs.fish;

  programs.neovim = {
    enable = true;
    defaultEditor = true;
  };

  # Allow unfree packages
  nixpkgs.config.allowUnfree = true;

  # List packages installed in system profile. To search, run:
  # $ nix search wget
  environment.systemPackages = with pkgs; [
    #  vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default.
    wget
  ];
}

Every time you rebuild your system, Nix “evaluates” all the packages you’ve chosen and their dependencies, and saves the output — the build artifacts — in the Nix store. Usually, nix doesn’t need to build the packages you’ve chosen and they can be downloaded directly from a Nix “cache server”. After download or build, the packages involved in the active configuration have their binaries symlinked into a special directory included in the PATH.

On my machine, this is /run/current-system/sw/, and /run/current-system/sw/bin/nvim is a symlink to /nix/store/xz3jy2lzzzycq3dnavn7v1hyr598pyr9-neovim-0.11.5/bin/nvim, for example. In fact, /run is mounted as a tmpfs that is setup when the machine boots or the configuration is switched. Changing the configuration is an atomic operation that can be reversed easily, including by rebooting and selecting an older configuration from the GRUB startup menu. A NixOS system, including the installed packages and system-wide configuration, is essentially itself a build artifact — the result of an evaluation of your configuration.nix.

Package installation and system configuration happen in one place

There are a number of configuration options available in NixOS that will both install any relevant packages as well as write (and symlink) any configuration files.

The networking.firewall option set will install iptables for you, and you can use allowedTCPPorts to open up ports as needed.

{ ... }:
{
  networking.firewall.allowedTCPPorts = [ 22 ];
}

allowedTCPPorts and other values are merged during the inclusion of nix code from other files, so you can put the code to open a port associated with a service next to the nix configuration for that service.

There’s a whole Nix “channel” of hardware configurations you can import into your configuration.nix. Importing the configuration for my 7040 AMD Framework 13, for example, brings in kernel parameters to fix common issues with the iGPU, power profiles that work well, and fingerprint reader support.

Packages can be patched

Something I find exciting about NixOS is that if you need a specific modification to one of the packages in your system, you can put instructions to add a patch to that package in configuration.nix (or one of the files it includes) too. Overlays are the feature that lets you do this.

Want an unreleased feature in a piece of software you use that only exists as a pull request? You can apply a .patch at build time. Or maybe you just want to build a version from git that nixpkgs doesn’t have yet. You can specify an exact commit to build the package from.

You can use Rust uutils instead of GNU coreutils by replacing them as a dependency system wide.

But the graphical installer is broken if you have a Nvidia GPU

After using NixOS on my laptop for a few months, I decided to install it on my desktop. Unfortunately, the installer never loaded, always crashing with a trace related to the open source nvidia driver “nouveau”. There are text mode installers, but I found the automatic partitioning with Hard Drive Encryption extremely handy.

Amazingly, I found that I could make an installation ISO with a small .nix file with vendor nvidia drivers instead of the default open source ones. Below is the entirety of the .nix file I used to do that. It imports the Gnome (& calamares) installation CD that I was using, and the rest of the configuration resembles what you would use to actually configure nvidia drivers on a machine post-installation.

# nix.iso
{ config, pkgs, ... }:
{
  imports = [
    <nixpkgs/nixos/modules/installer/cd-dvd/installation-cd-graphical-calamares-gnome.nix>
  ];

  nixpkgs.config.allowUnfree = true;

  services.xserver.videoDrivers = [ "nvidia" ];

  hardware.nvidia = {
    open = false;  # Set to true for open kernel module (RTX 20+)
    nvidiaSettings = true;
    package = config.boot.kernelPackages.nvidiaPackages.stable;
  };

  # Blacklist nouveau (usually automatic, but explicit doesn't hurt)
  boot.blacklistedKernelModules = [ "nouveau" ];
}

Then, this command produces an ISO that I was then able to use to install NixOS:

nix-build '<nixpkgs/nixos>' -A config.system.build.isoImage -I nixos-config=iso.nix

Easy!

After the install I was unable to boot — the installer had written and evaluated the default configuration.nix onto the system. I had to boot into the ISO I had generated, mount the new installation under /mnt and use the nix-enter utility to “enter” the installation so I could rescue it by pasting the nvidia configuration into configuration.nix. So — not a perfect experience, but it does show off the power and flexibility of NixOS.

The downsides of NixOS

It has a learning curve

I don’t have a full mental of what is going on in Nix (the language) and NixOS. I’ve been playing around with it for a month or two now.

I have a much more complete mental model of what is happening in an imperative paradigm like scripts or Ansible playbooks. And my understanding is also complete with tools in the declarative paradigm like Terraform and Kubernetes. NixOS, however, still feels like magic in that I’m getting a lot of value out of it without understanding the full mechanics of what is going on inside.

Current limits of my understanding include:

  • How does the build system work for system configurations?
  • How does an evaluated Nix expression of configuration.nix — which returns values — get converted into a collection of built components?

Maybe after I publish this I’ll go digging to find out.

It doesn’t use the “Filesystem Hierarchy Standard”

The built packages reside in /nix/store and paths inside the store are the hash of their inputs. Applications are linked against libraries in the store with their /nix path. You could actually have two versions of the same library installed, with different applications linked against the same library they were built with.

This is an elegant solution to avoid dependency hell, but it creates problems, both for building applications that depend on an FHS build environment, and for running binaries not from NixOS that depend on libraries in the FHS location.

Nix-ld is a workaround that installs a library loader that can load configured libraries for applications that might expect them in the “normal” location.

This is a minor inconvenience as a developer, also. Early on I discovered that I wanted to casually build software against external libraries and I couldn’t, because pkgconfig doesn’t know about the Nix store. This workaround will let you link against libraries that are installed.

# This tells NixOS to link the .pc files into a global directory
environment.pathsToLink = [ "/lib/pkgconfig" ];
environment.variables.PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig/";

I’m not sure I want to run my development environments in “the Nix way” quite yet. Neovim and other IDEs also like having a “normal” build environment for making tooling work, etc.

Don’t distribute the binaries you generate with this workaround — they will be linked against the Nix store paths. They may also break if you garbage collect your Nix store.

Next steps

If you want to try out NixOS, a good tip I’ve heard is that you can run NixOS in a virtual machine, get the configuration just the way you want it, then save the configuration and apply it to your system after you’ve installed it on bare metal.

You’ll want to check out Nixpkgs search to find packages to install. You’ll also want to check out “options” before “packages”. Many applications (for example, Firefox) are installed by enabling configuration options if they also change the system beyond simple installation of runnable software.

The wiki is a good resource for information about software that might require this kind of extra configuration beyond inclusion in systemPackages. I found the pages on 1Password, Docker and Ollama helpful.