8 minutes
Setting up my new laptop: nix style
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.
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.
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.
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… 😉