Easy Ergonomic Telemetry in Elixir with Sibyl
Feb 02, 2023
10 min. read
Elixir
Project
Refining Ecto Query Composition
Nov 08, 2022
13 min. read
Elixir
Absinthe
Ecto
Compositional Elixir—Ecto Query Patterns
Jun 10, 2021
18 min. read
Elixir
Ecto
Stuff I use
May 28, 2021
13 min. read
General
A minimal Nix development environment on WSL
Apr 04, 2021
13 min. read
WSL
Nix/NixOS
Elixir pet peeves—Ecto virtual fields
Dec 15, 2020
5 min. read
Elixir
Ecto
My usage of Nix, and Lorri + Direnv
Nov 26, 2020
5 min. read
Nix/NixOS
Build you a `:telemetry` for such learn!
Aug 26, 2020
19 min. read
Elixir
Erlang
Project
Drinking the NixOS kool aid
Jun 30, 2020
9 min. read
Nix/NixOS
Testing with tracing on the BEAM
May 20, 2020
8 min. read
Elixir
Compositional Elixir—Contexts, Error Handling, and, Middleware
Mar 06, 2020
13 min. read
Elixir
GraphQL
Phoenix
Absinthe
Taming my Vim configuration
Jan 14, 2020
7 min. read
(Neo)Vim
Integrating GitHub Issues as a Blogging Platform
Nov 06, 2019
6 min. read
Elixir
GraphQL
About Me
Nov 05, 2019
1 min. read
General

Drinking the NixOS kool aid

The last time I blogged about my development environment I had become disillusioned with the numerous problems that affected me when using Linux as my daily driver—scrolling and microstutters in Firefox, mouse acceleration and sensitivity setup was archaic and obscure, GPU issues to be worked around.

Ultimately, the times I did get a comfortable setup going on, after a few months something would inadvertantly break; or if I were trying to configure another machine, it'd be a lot of time spent emulating my exact setup.

I've heard about Nix and NixOS passingly over the last few years and with some down time in between projects at work, I decided to take a look at it in depth and I've really started to drink the kool aid.

What is NixOS?

At it's simplest, NixOS is a Linux distribution that aims to let you do system management / package management in a reliable, reproducible and declarative way built atop the Nix package manager (which can be installed independantly in a plethora of operating systems).

It enables you to do all your OS management, package management and configuration in the Nix programming language (which again, you can read more about here—Whats more: every time you make a change to your NixOS configuration and tell NixOS to rebuild your system, the exact specifications of your current system aren't just overwritten but instead a new 'generation' is created and you can switch between these versions of your operating system setup as you please. This means that if you somehow happen to break your environment you can just roll back to how it was before.

When jumping to NixOS, I made the mistake of setting up my configuration such that I could do what I usually do on UNIX-like operating systems: install a bunch of packages to enable me to compile whatever tools I need for work, and while this worked for awhile, for a few things, it wasn't the optimal way of doing things on NixOS. I spend some time looking the the Nix language and some NixOS configurations from other people around the internet and I slowly pieced together a configuration that worked for me. Besides just working for me: I can take my configuration and put it on any machine I own and run nixos-rebuild switch and boom, my entire operating system is set up exactly how I like it.

My NixOS configuration has come a long, long way since the first commit (documentation for Nix is pretty sparse) and I've learned a bunch of things that I feel would be useful to share, so take a look at my NixOS configuration if you'd like a decent, modular starting point and I'll outline a few things below.

My configuration

Modular configuration

A lot of NixOS configurations I've looked at essentially just look like one absolutely huge configuration.nix file; this is an ok way to do things but it can definitely get a bit unwieldy very easily. One of the really cool things about the Nix language is that it is a programming language. I don't leverage anything particularly complicated in my setup, but one nice thing is that you can define modules and import them in a top level configuration.nix module.

My NixOS configuration has the top level configuration.nix and hardware-configuration.nix files gitignored because installing NixOS will generate these for you anyway—because these files contain auto-generated nix expressions and are install/machine dependant (UEFI installation, GPU drivers, boot device etc) I don't think it makes sense to version these. Instead, my NixOS config is set up with the following three directories:

  1. profiles/, which contains nix modules such as desktop.nix, laptop.nix, server.nix. These nix modules, when imported, set up things that I'd expect from that particular class of machine. If I import server.nix, I won't bother setting up any X11 related functionality because I won't need a GUI. If I import laptop.nix, I might automatically include packages such as powertop or enable bluetooth. In fact, my profiles/ directory also contains a base.nix in which I define a standard set of packages to install which for me I use on all machines, as well as configure my preferred timezone, keyboard layout and other random things.
  2. modules/ which contains nix modules such as neovim.nix, dwm.nix, amd_gpu.nix. These are smaller nix modules which are set up to be enabled or disabled explicitly. My neovim.nix module not only downloads Neovim, but also downloads my dotfiles and configures Neovim exactly how I like it, plugins and all; my dwm.nix grabs my personal build of DWM, compiles it and installs it. Modules defined in modules/ can literally be anything that I might want to install but want to abstract away—installing GPU drivers on my workstation requires three different lines of configuration, so I'd rather just add modules.amd_gpu.enable = true; to my configuration instead. The idea there is that these modules can be easily enabled/disabled on a machine-machine basis.
  3. machines/ which contains nix modules which acts as configuration for particular machines of mine. These modules will do things such as set the hostname of a machine, as well as include any profiles/*.nix and modules/*nix files neccessary for that particular machine. The VPS running my site has a HTTP server, SSL certificate generation and related things configured in machines/cbailey.co.uk.nix for example 😄

The main upside to this kind of configuration is that I can share configuration between multiple machines I want to manage in a DRY fashion. My girlfriend also recently installed NixOS and since she's not too familiar with Linux, it's extremely convinient that she can use my NixOS configuration; swapping out say, modules/dwm.nix for modules/plasma5.nix and I can contribute changes that solve technical difficulties she might be having.

Writing a Nix module

I won't go too deeply into the details of the Nix language (I wouldn't call myself an authority on this, I picked up what I know from other configs and playing around), but there are a few nice features/quirks of Nix which seemed you might not expect, which can be leveraged in your modules.

Configuration merging

Nix modules are essentially just attribute sets (basically a map/JSON object like thing) containing keys and values. There are a few different sections to a nix module, but the main bit that is important for NixOS configuration is that an attribute set has a value set to the key config, like this:

{ config, lib, pkgs, ... }:
{
  config = {
   something = true;
  }
}

When this module is imported by your top level configuration.nix, the key config gets merged with your configuration.nix's configuration.

We can utilise this to define a modules/vmware_guest.nix module for instance:

{ config, lib, pkgs, ... }:
{
  virtualisation.vmware.guest.enable = true;
}

When this module gets imported to your top level configuration.nix, virtualisation.vmware.guest.enable = true gets added to your configuration. This is pretty cool because you can utilise this to break your configuration up into smaller chunks.

The merging that nix does is a deep merge as well, so if you have services.xserver.enable = true; in your top level configuration and want to import multiple window managers or desktop environments, these window managers and desktop environments not only can set values nested inside services.xserver, but these values can be used in the top level configuration.nix as well.

If your top level configuration.nix has a list of packages to install; but you also include another nix module that defines a seperate list of packages to install:

# configuration.nix
environment.systemPackages = with pkgs; [
  firefox
];

# some_module.nix
environment.systemPackages = with pkgs; [
  wget
  unzip
  git
];

Then these lists get concatenated and merged together as well, installing all the packages define across all modules imported. This seemed pretty unintuitive but it's pretty cool!

Enabling/disabling modules

Alongside the config key, you can also create options of varying types in your module's options key. Actually, all nix packages you install are defined with modules, and they use this options key to define what configuration options can be set at all!

In my modules/ directory, I have a convention where all of my modules define a modules.<name>.enable boolean option for instance, so I can explicitly enable or disable these modules regardless of whether or not they're imported.

You get access to a bunch of types such as paths, strings, integers etc; pretty much what you'd expect. I'm not sure where exactly this is documented since I've only really needed to have simple boolean type options but reading other nixpkgs was a decent and quick way to learn.

You can define a modules.<name>.enable options like I do with the following boilerplate:

{  config, lib, pkgs, ...}:
with lib;
  let
    cfg = config.modules.NAME;
  in
  {
    options.modules.NAME = {
      enable = mkOption {
        type = types.bool;
        default = false;
        description = ''
          Some documentation here
        '';
      };
    };
  
    config = mkIf cfg.enable (mkMerge [{
      # my optional configuration here
    }]);
  }

Overriding packages

NixOS and Nix (the language) can be pretty intimidating at times. The documentation around Nix/NixOS is pretty sparse, potentially outdated and dense. I got a lot of help from the related IRC channels though and definitely suggest you seek help there since it's one of the friendliest and most helpful communities I've had the pleasure of interacting with!

One of the coolest features hidden behind the lack of documentation is the fact that you can override packages. This is especially useful if you want to install a standard package with a patch or small tweak; the standard library for Nix contains functions to fetch remote files from Git or just fetch a tarball from somewhere. I try not to overuse this feature though because it kind of clashes with the declarative-ness of the system otherwise; but for things like my DWM build, it's super helpful.

DWM is a window manager which is configured by recompiling it. When setting up NixOS, I didn't want to learn how to package my personal build of DWM before even getting to grips with the OS, so being able to override the existing DWM package but point at a different set of source code was super helpful. You can override a package with the following pattern:

nixpkgs.overlays = [
  (self: super: {
    dwm = super.dwm.overrideAttrs(_: {
      src = builtins.fetchGit {
        url = "https://github.com/vereis/dwm";
        rev = "b247aeb8e713ac5c644c404fa1384e05e0b8bc6f";
        ref = "master";
      };
    });
  })
];

Home-Manager

Home-Manager is an application which lets you configure your applications (basically a declarative nix-ish implementation of dotfiles). It's super useful and I've transitioned to using it for a bunch of the applications I use.

My terminal emulator's colors, default Firefox plugins and other things are configured via Home-Manager and thus are versioned in my NixOS generations and upgraded whenever I do a nixos-rebuild switch.

There isn't too much else to say about Home-Manager; it integrates well with Nix and it just works. Check it out if you're using NixOS.

Developer Environments

NixOS by default comes with a feature which lets you set up environments for different projects (similar to how ASDF might work for your Erlang/Elixir projects).

Essentially, you can create a shell.nix file in a directory that contains a list of applications you want to be available in that directory and activate it by invoking nix-shell. You can see an example shell.nix set up for a Phoenix application below:

let
  pkgs = import <nixpkgs> {};
in
pkgs.mkShell {
  buildInputs = [
    pkgs.elixir_1_10
    pkgs.nodejs-10_x
    pkgs.yarn
  ];
}

If you're using direnv (a really nice application which lets you automatically set environment variables when you enter a directory), you can use a cool project called lorri which sets up a daemon and automatically updates your local dev environment when you enter a directory (avoiding the need to run nix-shell manually) as well as automatically watching for changes in your shell.nix.

This emulates the flow of using asdf and defining a .tool-versions file pretty well, and I've actually found it even more useful because if someone has trouble compiling one of my applications, even if they don't use Nix, you can look at the shell.nix and tell them exactly what packages you need and at what versions.

Wrapping up

The more I play with Nix and NixOS, the more in love I fall with it. I've not had this much fun (and drunk this much kool-aid) since falling for OTP or discovering Arch Linux or Gentoo when I was just starting to get into Linux.

Hopefully the contents of this post will be helpful for other people looking to get into NixOS and I'm sure I'll have more to contribute w.r.t this as time goes on!

Thanks for reading 😄

edit: man configuration.nix gives you an exhaustive list of configuration options! happy tinkering!