Nix

The traditional Linux package management workflow is great in theory: you are able to install a package from a centralized source without annoying installers and with automatic dependency resolution.

That should be enough, right?

No, it’s not.

What happens if two packages need two different versions of the same library?

[root@arch test]# pacman -Syu
:: Starting full system upgrade...
:: Replace dbus-python with extra/python-dbus? [Y/n]
resolving dependencies...
looking for conflicting packages...
:: ffmpeg-2:7.0.1-1 and ffmpeg-obs-6.1.1-10 are in conflict. Remove ffmpeg-obs? [y/N]
error: unresolvable package conflicts detected
error: failed to prepare transaction (conflicting dependencies)
:: ffmpeg-2:7.0.1-1 and ffmpeg-obs-6.1.1-10 are in conflicts

These problems with the traditional approach aren’t new and here are a few that I consider very important:

  • No atomic package upgrades

There is a time window in which we have some of the files of the old version, and some of the new version – Eelco Dolstra

  • No rollbacks

When we upgrade components, it is important to be able to undo, or roll back the effects of the upgrade, if the upgrade turns out to break important functionality. This requires both that we remember what the old configuration was, and that we have some way to reproduce the old configuration – Eelco Dolstra

  • Upgrading dynamic libraries often breaks applications

This is the well-known DLL hell, where upgrading or installing one application can cause a failure in another application due to shared dynamic libraries. It has been observed that software systems often suffer from the seemingly inexplicable phenomenon of “bit rot,” i.e., that applications that worked initially stop working over time due to changes in the environment. – Eelco Dolstra

That’s where Nix comes in: it stores packages in isolation using cryptographic hashes in a central store (e.g., /nix/store/m9s6nrg172nc055vsyr27avlzxb7gd7h-ffmpeg.drv), preventing undeclared dependencies and enabling multiple versions to coexist and also multiple versions of dynamic libraries. It supports lock files like, for instance, the cargo package manager and can be used in almost any Linux distribution and macOS.

Nix packages are defined using the Nix programming language, a purely functional and declarative language designed for reproducible builds.

Nix is not a container, and so it doesn’t suffer from the same common Docker problems:

  • Build once, Run anywhere but actually not. FROM foo:latest is a terrible practice because it’s inherently not reproducible and Docker doesn’t have the concept of lock files.
  • Docker-based development IDE support is tricky, especially for LSPs and debugging tools. Since Nix integrates directly with the system environment, you get native IDE support without the complexity of containerized workarounds.

I am not saying that Docker is not useful, it is, but not for every use case (you can even build Docker images with Nix!)

With Nix you start a shell with Ansible 2.16 and another with 2.17 in the same system just by running nix-shell -p ansible_2_16 and nix-shell -p ansible_2_17.

Nix drawbacks

Nix breaks many standard Linux conventions and therefore has some drawbacks:

  • Downloading random application binaries from the internet is unlikely to work as they assume system libraries are in standard locations. nix-ld and envfs provide a mostly pain-free compatibility layer, but they break Nix’s core principles of purity and reproducibility.
  • It has quite a big learning curve largely due to being a functional programming language.

What about NixOS?

NixOS is a full operating system built around the principles of Nix.

Instead of manually configuring system dependencies, NixOS lets you declare your entire system state in a config file (configuration.nix), making rollbacks and reproducibility trivial.

The following snippet declares a minimal system with the sshd systemd service that only accepts key logins, the systemd-boot bootloader and a few packages available at the shell:

{
  lib,
  config,
  pkgs,
  nixpkgs,
  ...
}:
{

  imports = [
    ./hardware-configuration.nix
    # Minimal stuff
    (nixpkgs.outPath + "/nixos/modules/profiles/minimal.nix")
  ];

  networking.hostName = "bar";

  users.users = {
    alice = {
      initialPassword = "bob";
      isNormalUser = true;
      openssh.authorizedKeys.keys = [
        "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQCo9+BpMRYQ/dL3DS2CyJxRF+j6ctbT3/Qp84+KeFhnii7NT7fELilKUSnxS30WAvQCCo2yU1orfgqr41mM70MB foobar"
      ];
      # sudoer
      extraGroups = [ "wheel" ];
    };
  };

  # sshd systemd service
  services.openssh = {
    enable = true;
    settings = {
      PermitRootLogin = "no";
      PasswordAuthentication = false;
    };
  };

  # Bootloader
  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;
  boot.loader.systemd-boot.configurationLimit = 10;

  environment.systemPackages = [ pkgs.ansible pkgs.htop ];

  # This value determines the NixOS release from which the default
  # settings for stateful data, like file locations and database versions
  # on your system were taken. It‘s perfectly fine and recommended to leave
  # this value at the release version of the first install of this system.
  # Before changing this value read the documentation for this option
  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
  system.stateVersion = "24.11"; # Did you read the comment?
}

NixOS VM tests

NixOS has built-in support for VM-based integration testing. It provides a declarative way to define and run system tests using QEMU-based virtual machines. It includes a very good python framework to write the tests.

With NixOS VM tests, you can:

  • Define a full system environment with services, users, and network configurations.
  • Spin up multiple VMs to simulate real-world distributed systems like testing VPN or DNS servers connectivity with their clients.
  • Test declaratively in a hermetic (fully isolated with no Internet access) environment.
  • Have a CI for your IaC.

For example, this is the upstream NixOS headscale integration test:

import ./make-test-python.nix (
  { pkgs, lib, ... }:
  let
    tls-cert = pkgs.runCommand "selfSignedCerts" { buildInputs = [ pkgs.openssl ]; } ''
      openssl req \
        -x509 -newkey rsa:4096 -sha256 -days 365 \
        -nodes -out cert.pem -keyout key.pem \
        -subj '/CN=headscale' -addext "subjectAltName=DNS:headscale"

      mkdir -p $out
      cp key.pem cert.pem $out
    '';
  in
  {
    name = "headscale";
    meta.maintainers = with lib.maintainers; [
      kradalby
      misterio77
    ];

    nodes =
      let
        headscalePort = 8080;
        stunPort = 3478;
        peer = {
          services.tailscale.enable = true;
          security.pki.certificateFiles = [ "${tls-cert}/cert.pem" ];
        };
      in
      {
        peer1 = peer;
        peer2 = peer;

        headscale = {
          services = {
            headscale = {
              enable = true;
              port = headscalePort;
              settings = {
                server_url = "https://headscale";
                ip_prefixes = [ "100.64.0.0/10" ];
                derp.server = {
                  enabled = true;
                  region_id = 999;
                  stun_listen_addr = "0.0.0.0:${toString stunPort}";
                };
                dns.base_domain = "tailnet";
              };
            };
            nginx = {
              enable = true;
              virtualHosts.headscale = {
                addSSL = true;
                sslCertificate = "${tls-cert}/cert.pem";
                sslCertificateKey = "${tls-cert}/key.pem";
                locations."/" = {
                  proxyPass = "http://127.0.0.1:${toString headscalePort}";
                  proxyWebsockets = true;
                };
              };
            };
          };
          networking.firewall = {
            allowedTCPPorts = [
              80
              443
            ];
            allowedUDPPorts = [ stunPort ];
          };
          environment.systemPackages = [ pkgs.headscale ];
        };
      };

    testScript = ''
      start_all()
      headscale.wait_for_unit("headscale")
      headscale.wait_for_open_port(443)

      # Create headscale user and preauth-key
      headscale.succeed("headscale users create test")
      authkey = headscale.succeed("headscale preauthkeys -u test create --reusable")

      # Connect peers
      up_cmd = f"tailscale up --login-server 'https://headscale' --auth-key {authkey}"
      peer1.execute(up_cmd)
      peer2.execute(up_cmd)

      # Check that they are reachable from the tailnet
      peer1.wait_until_succeeds("tailscale ping peer2")
      peer2.wait_until_succeeds("tailscale ping peer1.tailnet")
    '';
  }
)

It starts the VMs headscale, peer1, peer2 and checks connectivity between the peers through the VPN.