====================================
== Knights of the Lambda Calculus ==
====================================

Nix(OS) Thoughts

linux

This post is relatively scatterbrained, and if you’re familiar with Nix, there’s not any explicitly new ground to tread here. However, I have enjoyed my experience with NixOS so much that I felt compelled to write this post, although there’s already a plethora of posts drilling the same points.

From time to time, I find software that immediately seems to click with me, and I start integrating it almost irreversibly into my workflow, to the point where it’s difficult to think outside of its scope. Emacs is one of these: when I began using it, I started integrating most of my software into Emacs, be it IRC or RSS.

My recent experience with NixOS, though not my first (more on that later), was like this. As of today, my two laptops and my server all run NixOS, and they all use the same configuration – just with different things enabled/disabled across different machines. From both the perspective of a system administrator and the perspective as someone with a meticulous set of dotfiles, this is one of the best decisions I’ve ever made.

The bad parts

Figured I’d get this out of the way first, as doing Nix-everything hasn’t been entirely painless. Namely, some of the things I find notable are:

  • Doing things “the normal way” is either ill-advised or impossible – in general, a lot of system-level things can only be accomplished declaratively (through Nix) via mutability
  • Running binaries from the internet is a pain because they don’t know where to find shared libraries – a workaround for this is, oddly enough, using the Steam runtime (packaged as steam-run)
  • The Nix language is syntactically very ugly and has a very distinct learning curve – this was lessened by personal Haskell knowledge, which helped me understand its overall paradigm
  • The documentation oftentimes is bad or nonexistent – you will often find yourself reading packages’ Nix expressions to understand exactly how they work

The most notable of these is the lack of documentation. Much of my configuration would be extremely difficult had I not just loaded up nix repl and played around with things in builtins and pkgs.lib. The Nix language itself is extremely obtuse if you have no FP knowledge, and while efforts such as Nix Pills have helped, it’s not even close to enough, in my opinion.

First impressions

My first experience with NixOS was in early 2017, as I was doing Haskell development at the time and had heard Nix was a good build system as an alternative or complement to Cabal, which is a garbage fire of a tool. At this time, home-manager did not exist, and most of my dots were managed traditionally via GNU Stow or just not at all. I did things as I always had – installing packages with the package manager on the command-line, and stowing my dotfiles – all of which was done in an imperative fashion.

NixOS is terrible at being a “traditional Linux” like this. I ended up with a mess of declarative/imperative work, none of which was reproducible, which is one of the promises of Nix as a whole – and my system was cluttered with trash. I hated NixOS for this reason, and didn’t return to it for a long time.

I would later learn that the more you buy into NixOS’s declarative model, the more utopian it becomes.

Second attempt, and thoughts on home-manager

Recently, I got a new laptop – a Lenovo ThinkPad T495. I opted to try NixOS again on the merit that I saw people talking about a tool caled home-manager, which after reading up on it, appeared to alleviate my former problems of doing the majority of things imperatively. Additionally, I was armed with more knowledge of functional programming as a whole, meaning I was better able to (ab)use the Nix expression language.

home-manager is a tool for managing a user’s environment with Nix – this means what would be traditionally known as “dotfiles” (even though they aren’t actually “dots” here) can now be encoded and managed with Nix. It also means I am able to rollback my dots, which I’ve never explicitly needed to do, but is nice should I accidentally/intentionally break something.

I set up my new NixOS system with home-manager immediately and avoided using nix-env -iA (imperative package management) at all costs. With my FP knowledge, the Nix expression language came very naturally to me – it felt like an uglier, simpler version of ML (perhaps this feel comes from the let... in convention). While encoding my configs, I began to find tricks here and there to add abstraction to my configurations – writing functions, making variables, even in forms of configuration that formerly didn’t support this kind of work.

As a former fan of programmable window managers like xmonad and dwm, forced off them by Wayland’s promises of no screen tearing (which it absolutely fulfills), I realized that with Nix, everything was like xmonad. I could configure things with the power of nearly a full programming language, FP knowledge in hand. This was the first thing that really caused me to love Nix.

Additionally, I do CTFs, and for these challenges you often have to have esoteric software that you’re unlikely to touch again. Nix solves this problem very well by allowing creation of a temporary environment – just run nix-shell -p <package> and you’re dropped into an environment with the package available. This avoids cluttering your system with random trash.

Reproducibility

As a test, I took my old laptop (which ran Void Linux), and decided to slap my NixOS configuration onto it. I had to do some modularization such that I wouldn’t copy system-specific settings (such as partition layout), but after that, my mind was absolutely blown.

With a proper declarative configuration, I was up and running with all my software, dotfiles, and all on a brand new system in less than an hour, even with some software compiled from source. It felt somehow utopian – the promise that NixOS made of reproducible configurations was made. As someone who puts far too much work into their dotfiles, this was what I had been looking for all along – the ultimate dotfile manager.

NixOS on the server

Recently, I switched from a Raspberry Pi 4 running Alpine to a PCEngines APU2, namely because it’s x86, has AES-NI, is quad-core, and is overall faster. I’ve noticed significant improvements with Nextcloud, namely, after moving to it.

In my opinion, server settings are where NixOS shines the most! When setting up the server, I was able to merely take my existing configuration, disable the graphical session in my system-specific settings, and deploy it – instant user account, instant shell configuration, et cetera. Setting up services was a breeze as well: for example, here’s the entirety of a configuration to set up Nextcloud over an nginx reverse proxy with HTTPS (unmodularized, but modularization is pretty trivial):

{ config, pkgs, lib, ... }:
with lib; {
  services.nextcloud = {
    enable = true;
    hostName = "cloud.qtp2t.club";

    nginx.enable = nginxCfg.enable;
    https = nginxCfg.ssl;
    maxUploadSize = "5G";

    config = {
      dbtype = "pgsql";
      dbuser = "nextcloud";
      dbhost = "/run/postgresql";
      dbname = "nextcloud";
      dbpassFile = "/etc/nextcloud-db-pass";

      adminuser = "hazel";
      adminpassFile = "/etc/nextcloud-pass";
    };
  };

  services.postgresql = {
    enable = true;
    ensureDatabases = [ "nextcloud" ];
    ensureUsers = [
      { name = "nextcloud";
        ensurePermissions."DATABASE nextcloud" = "ALL PRIVILEGES";
      }
    ];
  };

  systemd.services."nextcloud-setup" = {
    requires = [ "postgresql.service" ];
    after = [ "postgresql.service" ];
  };

  services.nginx.virtualHosts."cloud.qtp2t.club" = {
    forceSSL = true;
    enableACME = true;
  };
}

Merely writing this expression was enough to create a fully functional Nextcloud instance. I never had to touch the actual Postgres prompt, I never had to touch occ – just enabling this module immediately got everything up and running. It even automatically fetched HTTPS certificates for me via LetsEncrypt, and automatically created the required database.

This approach applies to the majority of services under Nix – even the derivations I had to write myself (for example, for my webring manager) were far easier with Nix than without. Everything was unified under one language!

Furthermore, the fact that my desktop and server run the same dotfiles, just with different things enabled/disabled, means that I can have an instant environment akin to my “production” server (if you can call it that).

Nix as a build system

Nix at its fundamental level is just a way to create reproducible builds, and NixOS is its application at an extreme level. It makes sense, then, that Nix makes a good system for reproducible builds. While none of the projects I work on truly need to be reproducible, it’s nice to not have my system cluttered with garbage – I can have libraries or entire compilers only available in the context of one project.

Notable tools that complement Nix here are:

  • direnv, which allows to have an environment specific to a directory – this allows being dropped into a Nix shell without an explicit step
  • lorri, which is a replacement for nix-shell with tight direnv integration
  • niv, which is useful for pinning the entirety of Nixpkgs to a certain commit

My workflow/setup for Nix-based projects is something like this:

  • Run lorri init to create an environment and direnv allow to use it

  • Run niv init to pin Nixpkgs, and switch the branch to NixOS 20.03

  • Write a shell.nix. For a Racket project, for example, it would look like:

      let
        sources = import ./nix/sources.nix;
        pkgs = import sources.nixpkgs {};
      in
      pkgs.mkShell {
        buildInputs = with pkgs; [
          racket
        ];
      }
    

    This automatically pulls the Racket interpreter, regardless of whether the target system has Racket installed. This is a simplistic example – more complex projects would have more complex dependencies. Regardless, with direnv,

  • Work on the project! As dependencies flow in, add them to shell.nix.

  • When at a stable version, create a default.nix that serves as a derivation, and test it with nix-build. An example for the aforementioned Racket project is:

      { sources ? import ./nix/sources.nix
      , pkgs ? import sources.nixpkgs {} }:
      pkgs.stdenv.mkDerivation rec {
        name = "perihelion";
        version = "unstable";
    
        src = ./.;
    
        nativeBuildInputs = with pkgs; [ racket ];
    
        # https://github.com/NixOS/nixpkgs/issues/11698
        buildPhase = ''
          raco exe --gui -o ${name} main.rkt
        '';
        installPhase = ''
          mkdir -p $out/bin
          cp ${name} $out/bin
        '';
      }
    

    Again, this will build regardless of where you are.

Overall, I find this to be a very effective way to manage the clutter that development tools impose on a system.

Final thoughts

I’m fully intending on sticking with NixOS. It’s probably one of the best choices I’ve ever made for myself, and it fulfills its promises of being a truly reproducible system. Its tight integration with build systems means that it’s easy to extend an existing project with Nix.

Overall, though, I can’t say I’d recommend it to everyone, solely on the merit that the documentation is poor. It’s unfortunate, really – Nix when applied properly is bliss. With better documentation, however, I think that NixOS could be one of the best distributions out there, and Nix one of the best package managers/build tools.