This week I received a new 12th Gen Intel laptop from Framework. And like with any new piece of hardware I get these days, my first instinct was to put NixOS on it 😄.

But I wasn’t just content with firing up the NixOS installer and getting to work. Oh no no no. You see, I knew there was a better way. I didn’t now exactly what that better way looked like just yet, but I could feel in my bones that it existed.

So I did what I usually do when I suspect there’s a better way of doing something in Nix land and pinged Mic92. What you’ll read in the rest of this post is the result of our conversations.

All the things meme template with the caption: 'Nix all the things!'

Customise the install iso

Like with any other distro, NixOS has an installer that you can flash onto a USB drive, plug into your computer, and then boot into a Nix based environment that lets you do things like partition disks, create file systems and ultimately install NixOS.

In my experience though, this means sitting at my desk using my desktop computer to look up various bits of instructions and then typing them into my laptop. I mean, who remembers how to create a LUKS partition off the top of their head unless that’s what you do day in and day out, right?

But what if I told you that you can customise the NixOS installer?

    packages = {
      sd-image = nixos-generators.nixosGenerate {
        inherit pkgs modules;
        format = "install-iso";
      };
    };

By using the install-iso format in NixOS Generators we can customise the installer with our own nixpkgs and nixos modules. This can then be built (assuming flakes) with nix build .#sd-image and the resulting iso found in ./result/iso/... flashed to a USB drive.

Since we can add our own modules, we can then do things like configuring authorized keys for the root account:

  users.extraUsers.root.openssh.authorizedKeys.keys = [
    "ssh-rsa AAAAB3NzaC1yc2etc/etc/etcjwrsh8e596z6J0l7 example@host"
    "ssh-ed25519 AAAAC3NzaCetcetera/etceteraJZMfk3QPfQ foo@bar"
  ];

And that lets us stick in our USB drive, fire up our hardware, boot into the nixos installer, grab its IP address with ip addr and then continue the rest of the installation remotely.

A woman with her back turned throwing her hands in the air in jubilation

Create the disk layout with Disko

Now that we can comfortably ssh into our new device and copy and paste to our heart’s content, the next step is to partition some disks and create some filesystems.

In my particular case I like encrypting partitions with LUKS and then using BTRFS as the filesystem. During a typical NixOS install I would use gdisk to split my root device into a boot and root partition, and then do something like this:

# format the root partition with the luks structure
$ cryptsetup luksFormat /dev/nvme0n1p2
# open the encrypted partition and map it to /dev/mapper/cryptroot
$ cryptsetup luksOpen /dev/nvme0n1p2 cryptroot
# add a filesystem
$ mkfs.btrfs -L nixos /dev/mapper/cryptroot
# mount
$ mount /dev/disk/by-label/nixos /mnt
$ mkdir /mnt/boot
$ mount /dev/nvme0n1p1 /mnt/boot

Recently though I came across Disko which is maintained by Lassulus. It allows for declarative disk partitioning, doing away with the need for the manual steps listed above.

Note: at the time of writing BTRFS support in Disko had a path issue and was missing the ability to pass mount options to sub volumes. Luckily Lilyinstarlight had already created a PR, so I switched to using their branch.

For the sake of simplicity I will reference the original disko url for the flake instead of Lily’s branch. I expect their PR will be merged soon anyway.

Instead of manually creating everything in the terminal, we can instead define our disk layout using nix:

{
  disk = {
    nvme = {
      type = "disk";
      device = "/dev/nvme0n1";
      content = {
        type = "table";
        format = "gpt";
        partitions = [
          {
            type = "partition";
            name = "ESP";
            start = "1MiB";
            end = "512MiB";
            bootable = true;
            content = {
              type = "filesystem";
              format = "vfat";
              mountpoint = "/boot";
              options = [
                "defaults"
              ];
            };
          }
          {
            type = "partition";
            name = "luks";
            start = "512MiB";
            end = "100%";
            content = {
              type = "luks";
              name = "crypted-root";
              content = {
                type = "btrfs";
                mountpoint = "/";
                mountOptions = ["noatime"];
                subvolumes = {
                  "/home" = {};
                };
              };
            };
          }
        ];
      };
    };
  };
}

To apply this configuration we copy it into a file called luks-btrfs.nix and run the following:

# create the disk layout
nix run github:nix-community/disko --no-write-lock-file -- -m create ./luks-btrfs.nix
# mount the disk layout
nix run github:nix-community/disko --no-write-lock-file -- -m mount ./luks-btrfs.nix

We now have everything laid out the way we want, with the crypted-root partition mounted at /mnt and the boot partition mounted at /mnt/boot, just the way nixos-install likes it.

Even better, we can re-use this nix config in a nixos module for our eventual system configuration:

{inputs, ...}: {

  # inputs is made accessible by passing it as a specialArg to nixosSystem{}
  imports = [
    inputs.disko.nixosModules.disko
  ];

  disko.devices.disk.nvme = {
    type = "disk";
    device = "/dev/nvme0n1";
    content = {
      type = "table";
      format = "gpt";
      partitions = [
        {
          type = "partition";
          name = "ESP";
          start = "1MiB";
          end = "512MiB";
          bootable = true;
          content = {
            type = "filesystem";
            format = "vfat";
            mountpoint = "/boot";
            options = [
              "defaults"
            ];
          };
        }
        {
          type = "partition";
          name = "luks";
          start = "512MiB";
          end = "100%";
          content = {
            type = "luks";
            name = "crypted";
            content = {
              type = "btrfs";
              mountpoint = "/";
              mountOptions = ["noatime"];
              subvolumes = {
                "/home" = {};
              };
            };
          };
        }
      ];
    };
  };
}

Build locally and copy the system closure

Now that we have our disk partitioned and file system in place, the next step towards a basic installation would be to run nixos-generate-config followed by nixos-install.

We are still going to use nixos-generate-config because we want to grab the contents of /mnt/etc/nixos/hardware-configuration.nix as a starting point for our hardware config.

But when it comes to installing, we’re going to do things a little differently.

By now we should have constructed a basic nixosSystem config for our new piece of hardware. In my case it looked something like this:

  # mkNixosConfig is a wrapper I use for nixosSystem
  nixosConfigurations = {

    pandora = mkNixosConfig {
      inherit baseModules;
      hostName = "pandora";
      extraModules = [
        hosts.pandora
        modules.docker
        modules.gnome
        modules.gnupg
        modules.vm
        modules.yubikey
        users.brian
      ];
    };

  };

We can build this locally with:

nix build .#nixosConfigurations.pandora.config.system.build.toplevel

Once finished we are left with a symbolic link ./result, the contents of which look like:

❯ readlink -f ./result
/nix/store/xasx6dbw4jv5d1rlqgv5hl3r00bsbz8l-nixos-system-pandora-23.05.20221218.04f574a

❯ ls -l ./result
.r-xr-xr-x  16k root  1 Jan  1970 activate
lrwxrwxrwx   91 root  1 Jan  1970 append-initrd-secrets -> /nix/store/wnbyi2m14vb5s2i4gn6jqavqrx1bl7pf-append-initrd-secrets/bin/append-initrd-secrets
dr-xr-xr-x    - root  1 Jan  1970 bin
.r-xr-xr-x 3.2k root  1 Jan  1970 dry-activate
lrwxrwxrwx   51 root  1 Jan  1970 etc -> /nix/store/b52vk4pb5a87jpj3wx1frmbq7fbb5d50-etc/etc
.r--r--r--  133 root  1 Jan  1970 extra-dependencies
lrwxrwxrwx   65 root  1 Jan  1970 firmware -> /nix/store/2n9slzg6n8a7048b1ynlmwzi49kn6h7i-firmware/lib/firmware
lrwxrwxrwx   50 root  1 Jan  1970 flake -> /nix/store/swjgbmppk1n4zds1cz6aq04c2ss4kkgq-source
.r-xr-xr-x 4.5k root  1 Jan  1970 init
.r--r--r--    9 root  1 Jan  1970 init-interface-version
lrwxrwxrwx   67 root  1 Jan  1970 initrd -> /nix/store/kx5qlvl65j6abvi8lwgikfxqkavmcnml-initrd-linux-6.1/initrd
lrwxrwxrwx   61 root  1 Jan  1970 kernel -> /nix/store/963w8y25hn03xfr5cxqi9maj96w523xr-linux-6.1/bzImage
lrwxrwxrwx   58 root  1 Jan  1970 kernel-modules -> /nix/store/4w7rfgwjq7bpkp3slp84w6xk2jc9l3ik-kernel-modules
.r--r--r--  350 root  1 Jan  1970 kernel-params
.r--r--r--   22 root  1 Jan  1970 nixos-version
dr-xr-xr-x    - root  1 Jan  1970 specialisation
lrwxrwxrwx   55 root  1 Jan  1970 sw -> /nix/store/n5r63syv67yaxy5c8qzym60dq0s4w26b-system-path
.r--r--r--   12 root  1 Jan  1970 system
lrwxrwxrwx   57 root  1 Jan  1970 systemd -> /nix/store/9rjdvhq7hnzwwhib8na2gmllsrh671xg-systemd-252.1

In essence ./result points to a system closure within our local nix store, one that we want to copy to our target machine. And that’s exactly what we can do with nix copy:

# host should be the ip address of the target machine
nix copy --to "ssh://root@$host?remote-store=local?root=/mnt" ./result

Note: This might take a while depending on the size of your system, but it will be a few gigabytes at least.

Once the copy has completed, our system closure is now present in our target system’s local nix store, at the same path. So all that’s left to do is set our system closure as the current system.

# host should be the ip address of the target machine
ssh root@"$host" nix-env -p /nix/var/nix/profiles/system --set ./result

Restart & Profit!

With our system closure installed on our target machine, the last step is to remove the USB drive and reboot. Your new hardware will start with a full-blown NixOS installation matching the system we were able to construct and test locally.

Fireworks

No need for reading instructions from a screen and typing them in manually. No need for getting a basic installation up and running first, then manually configuring your ssh keys and cloning a repo etc. before being able to continue.

I was able to go from a blank slate to a copy of my desktop config on my new laptop without doing anything more than sticking in a USB drive, booting up and grabbing an IP address from said laptop.

Summary

I like to think that I’ve shown, yet again, just how powerful Nix can be.

That being said, this shit isn’t easy, and I should acknowledge that I’m incredibly lucky to be in a position where I can just jump on a call and get help & advice from high priests of these dark arts like Mic92, Zimbatm and others at Numtide.

I’ve been using NixOS as my daily driver for over a year now, and I can’t imagine ever going back to something a bit more, normal?

P.S. at some point I will make my system config public. Before I do so, I want to clean a few things up. In the meantime, I would suggest looking over Mic92’s dotfiles. You’ll find much of what I talked about in there. In fact, his repo layout is shockingly similar to my own… 😉