geschätzte Lesezeit: 42 min

This post is part of a series about NixOS. The series is done in support of my upcoming Opus Magnum, "Partical NixOS: the Book", and you can read the detailed post here.

Should you find this article lacking some information, be sure to tell me in the comments or elsewhere, since it is a living document and I am very keen on expanding it.

This article's full code alongside the rest of configuration is available on GitHub.

Table of Contents (Click to Hide/Show)

This article is organized in 2 parts. The 1st part reflects on my observations, tries to give shape to my thoughts, and touches very briefly on the most common nix concepts. The 2nd part is the hands-on part. Reading the 1st part is in no way crucial for the understanding of the 2nd, but I encourage you to finish the 1st one, and then let yourself internalize it by taking a small break before proceeding to the hands-on. The footnotes in the article are not obligatory, so I suggest reading them afterwards.

If you've found this article via Google with a specific issue in mind, you may want to jump straight to the hands-on part.

This article is first and foremost dedicated to those, who may have found home with the distributions like Ubuntu and Fedora. Those, who are still at such a stage of their learning journey, that they may wonder what the difference between apt-get update and apt-get upgrade is (and why some online tutorials may omit the -get part). Those, who may have used ssh a couple of times already, but would not recall the process of generating and installing a key-pair. That someone is also me, many-many years ago, but since I have found shelter with NixOS.

I also hope to offer something new to those, who are already familiar with these concepts (and way beyond), so do not give up on this article just yet.

The idea of the Arrest of Change belongs to Plato. A critique of this idea makes a nice book1. Plato envisions an ideal world, a State, in which every person has their place and knows their place. Roughly speaking, in the Plato's State, no change would take place, since his State is ideal, and any change would only bring with itself harm. Alluding to this idea, I would tell the reader about me and maybe let them recognize themselves too.

I remember the days when tinkering with computers was a novelty and not some unpleasant mundane task. Nonetheless, even back then I've learned the feeling of mundane tasks creeping into my tinkering (and gaming) flow: being on Windows, I would want to avoid the day, when the systems would need a reinstall just because the software decided to irrecoverably rot. A tiresome process, followed by an even more tiresome process of installing and configuring the applications to a good-enough state resembling that of the previous system. It's important to mention, that it comes with a feeling that you are not in control of your system2, and in turn not in control of your time. A famous series3 tells us the story of taking back the control. Now, the question is, how much control is one able to take back, what exactly that control is, and what tools are out there to help us? This is where the NixOS enters the stage.

Before jumping to NixOS itself, let's have a metasurvey of the environment. More importantly, let's take a look at the articles you may have read already, maybe tried to follow, on the scattered all over the place documentation, on the huge single page NixOS manual, and on the inherent problems with those. Telling, that the documentation is one of the weakest points of NixOS is nothing new4. Now, what makes its documentation and the likes so unapproachable? One of the biggest issues I would like to mention, is that the vast majority of NixOS materials bake-in too much of assumption about prior Linux- and NixOS-related knowledge, and are not helping in the way, that they do not provide a shortest actionable shortcut to some desired outcome. Authors of those materials prefer to systematically dive into rabbitholes instead of supplying the tutory material with just enough amount of concepts needed for immediate use to achieve a task or resolve an issue. There is often no task in mind as well. Neither is there an approachable entrypoint -- where exactly should a novice begin their way? Such materials start with explaining the nix language syntax, which is in no way neccessary for the understanding of how to build and deploy your first customized system, start implementing complex derivations, stray into devops++ territory by mentioning the hermetic builds, etc. "If you wish to make an apple pie from scratch, you must first invent the universe" vibes. Some authors would start with the theoretical base and suggest early that you may want to read the 267-pages-long PhD thesis of NixOS creator, Dutch computer scientist Eelco Dolstra. I do not in any way want to devalue the work of others; more than that -- I'm very grateful to the community, to all the people, who put such a titanic effort into spreading the word and supporting the NixOS ecosystem, including the documentation. That biased way of presenting things may be inherent to the NixOS itself, which attracts very smart people. Thes are the hackers writing for other hackers. Yes, NixOS is complex5. Nix Expression Language does not make it easier. It's a functional programming language, and the functional programming may be a completely foreign concept to many. In Nix Language, it may be rather hard to start with doing by copying, since the eye of uninitiated programmer may lack devices for pattern-matching within the language. Copy-pasting some stuff verbatim in hopes that it would works is one thing, copy-pasting from multiple sources and trying to stitch something sensible is another. Another issue to mention is the deprecation of tech (NixOS and technology in general), which goes at a very high pace. You may not immediately stumble upon a mention, that nix-env or channels are a no-go, and you simply should not waste your time trying to learn those. And you would be quite happy to learn early, that flakes are the way you should be packaging the applications, or that home-manager is how you should manage user-specific dotfiles.

To give you a clue, NixOS is not that hard. One may compare it to playing a violin vs playing a piano. Those, who play (or tried to) both, would know, that it's very hard to start with the violin. Right from the beginning, you have to accomodate quite a lot of new stuff in your head and muscles, that is even before you can produce a single sound. You get to learn how to support the violin by its neck, as well as hold it by your own neck, how to put your playing hand's fingers on the bow, how to hold the bow, how to move your elbow, how the bow should make contact with the strings. And even after all of that, your first try (and N-th try too, with N being quite large) would produce nothing but some squeaky noises. Contrast that with starting on the piano -- you press a key and, immediately, you get a valid sound! You still have to learn how to properly touch the keyboard (touché), how to sit in front of it. But the feedback is immediate. Those of you, who play piano and/or violin professionally, would also know, that the moment you've grokked the violin past the initial stage, you acquire the pace and start making significant progress in your ability to play it quite fast (assuming you do give in the required hours to practice). Not so with the piano: when you reach a certain point, each improvement, both technical (think Chopin etudes) and expressive -- your ability to convey the message with your performance -- results in diminishing returns. Ubuntu in this case is the piano and NixOS is the violin. After you have spent some time with NixOS, you'll see a near-limitless potential for application, both in your professional and personal venues. You may even suddenly feel an urge to put it on all the devices you own. The gift keeps on giving.

Now, back to the metasurvey, Plato and finally onto the NixOS itself. What also is lacking in the community (despite its best efforts) is a simple statement in layman's terms, why exactly NixOS is the solution to a layman's problem. An elevator pitch.

And here it is: NixOS reclaims you the control back. NixOS is the Plato's Ideal. And everything that comes with that. Isolation and stuff6. Why is it important? What does that mean? To me personally, and to many others, it means the following:

  • Never have to fear breaking the system irrecoverably, since rolling back to a previous completely functioning state takes less than a single shell command (all of your previous configurations are actually shown at the boot to be chosen from, so it is really a matter of three key strokes -- Right, Down, Right)
  • Total elimination of software rot, software cruft and configuration drift. You may even want to explore the possibility of building a custom immutable LiveUSB and using it as your daily driver, which would fullfill all your needs
  • Access to the largest pre-packaged applications repository among all the distributions -- more than 80 thousand nixpkgs and counting!
  • Unmatched ease of setting up and tweaking your system with full control and understanding of what is being installed. Each configuration (which is called the "generation") is isolated and idempotent, so it does not care about your previous ones
  • Holistic approach to configuration: your text editor configuration, your browser extensions configuration, your hardware, filesystem, packages, environment configurations are all first-class citizens working together and in accord (with no yaml involved!)

To note, I find it quite magical, that NixOS exists and does what it does (and does that more than well). Remember, how you may have traditionally set up your system? -- Lots of googling, many lines of code. Each person would then have their own collection of imperative procedures (shell scripts and what not), which they either remember by heart and muscle memory, or store in git, or even organize into playbooks with provisioning tools like ansible, or maybe simply copy from numerous tutorials online only to forget those immediately after the script is run and the job is done. The Nix Expression Language shines here in the way that it is the medium (and I dare say the most convenient one) to incorporate all those single-use scripts into a holistic system. All those scripts are pieces of knowledge, and when the medium does not allow it, the knowledge is fractured, not shared, and even lost. With Nix Language, everyone could finally agree on something, and that something brings us together and ignites a desire to contribute. Observing, how others start with nix, I could tell, that it takes about a single week of time with no prior knowledge whatsoever to package an application and make it work on NixOS, in case it hadn't been packaged before. And one may suddenly feel the want to make their work a part of something bigger, so they fearlessly make a PR to nixpkgs. Contrast that with the approach of compiling from sources or installing from a repo in other distributions. Even though you still may have to tweak something here and there, maybe install a library or two, it will most likely work, and most likely on the first try. But you don't get the feeling of it being holistic, it still feels one-shot. Your tacit knowledge is not shared. Intricacies of your implementation, which arise from configuration differences, are never told. NixOS is different. It gives you a universal knowledge sharing language for system configuration. The knowledge is not lost, and more that that, the knowledge is amplified and put into a very neatly structured form, which is approachable by others.

These were my musings which I wanted to share, and now, let's get to the hands-on part.

Prerequisites, KVM

All the code for this article is available at GitHub.

Since this is "NixOS for Apt/Yum Users", we would install NixOS in a virtual machine, with Ubuntu or Fedora as the host system.

Let's ensure we got a working QEMU KVM:

Ubuntu:

sudo apt install \
    qemu-kvm \
    libvirt-daemon-system \
    virtinst \
    libvirt-clients \
    bridge-utils

Fedora:

sudo dnf install \
    qemu-kvm \
    virt-manager \
    libvirt \
    virt-install \
    bridge-utils

After that, complete the configuration with the following commands (for both distributions):

# enable virtualization daemon
sudo systemctl enable libvirtd
sudo systemctl start libvirtd
# add your user to required groups
sudo usermod -aG kvm $USER
sudo usermod -aG libvirt $USER

After QEMU KVM is installed, grab the lastest stable NixOS 22.11 minimal installation ISO image to hack on.

Start the QEMU KVM and navigate to the installation wizzard:

  • Press the top-left button Create a new virtual machine;
  • The default set Local installation media (ISO image or CDROM) radiobutton is what we need, click Forward;
  • Press Browse..., then Browse Local in the pop-up, and select the ISO image you have just downloaded;
  • Un-tick the box for Automatically detect from the installation media/source at the bottom, type in "generic" into the input field and select Generic default (generic), press Forward;
  • Let's give the machine at least 8000 MiBs of Memory and 4 CPUs to work with. Less is okay too, but we want our machine to be swifty. Press Forward;
  • Enable storage for this virtual machine and Create disk image for the virtual machine are both set by default, use those and type in 60 GiBs into the input field. Press Forward;
  • Now, we're at the last step of the prompt, but do not press Finish as yet. It's very important to tick the Customize configuration before install, or else we wouldn't be able to modify it afterwards.

Since we have ticked the Customize configuration before install box, we would be able to use the UEFI for the boot. Your PC would most likely feature the UEFI (or both UEFI and BIOS with the option to use either of those), and as you may eventually want to replicate the current setup for the actual hardware host, in order to eliminate the differencies between virtualized and physical hardware configuration, we would be using the UEFI instead of the legacy BIOS.

What is UEFI though? And why would an average user pick it over BIOS?

The UEFI (Unified Extensible Firmware Interface) is a specification that defines a software interface between an operating system and platform firmware. UEFI is meant to replace the Basic Input/Output System (BIOS) firmware interface. The UEFI interface is the first thing you see when you turn on your computer. It’s a graphical interface that allows you to choose how you want to boot your computer. The UEFI is designed to be more user-friendly than the legacy BIOS. It also has a built-in boot manager, which makes it easy to choose which operating system you want to boot.

The main advantages of UEFI over BIOS are:

  • UEFI has a much richer and more powerful interface;
  • UEFI is better at handling errors and recovery from boot failures;
  • UEFI provides more security features, such as secure boot and trusted platform module (TPM) support;
  • UEFI is faster and has better support for large hard drives.

Overall, UEFI is a more modern and robust firmware interface that provides more features and better security than BIOS.

After pressing Finish, we see the overview of configured system. We're going to change just one more option: in the Overview pane (the one you should be seeing by default), set the Firmware at the bottom to UEFI x86_64. After that, press Apply, and then the Begin Installation button.

We are greeted by the graphical interface. Press enter to proceed with NixOS installation.

You should now see the shell. The default user nixos has an empty password.

It won't be quite convenient to use the interface provided by the KVM. We won't be able to copy-paste from and to the NixOS terminal, and thus, we should log in with ssh. Ssh is a secure shell protocol that allows you to securely connect to a remote computer. Ssh uses encryption to protect your data and provides a secure connection between two computers. It doesn't matter if our host system and KVM (guest) system are actually on a single machine -- we still would facilitate the connection with ssh.

First, let's set up a password for the NixOS root user, which we would be using to configure and install the system. Type these in the shell:

sudo su # would switch user to `root`
passwd

and then you'll be prompted to enter a password. Let's set a simple temporary password, like changeme, for example.

Assuming you have not changed the default network settings in the VM configuration, you should be using a NAT network, which means that your virtualized NixOS guest sees the Ubuntu/Fedora host as a router, and gets a dedicated IP from it.

Type in ifconfig, to see something similar:

# ifconfig
ens3: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.122.68  netmask 255.255.255.0  broadcast 192.168.122.255
        inet6 fe80::5054:ff:fe14:e974  prefixlen 64  scopeid 0x20<link>
        ether 52:54:00:14:e9:74  txqueuelen 1000  (Ethernet)
        RX packets 66  bytes 10795 (10.5 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 67  bytes 8388 (8.1 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 80  bytes 6400 (6.2 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 80  bytes 6400 (6.2 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

Alternatively, you may use ip -c address, where ip is a program, which is intended to supersede the "outdated" ifconfig, -c provides the colorful output, and address shows the IPv4 and IPv6 addresses of the available network devices:

# ip -c address
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: ens3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:14:e9:74 brd ff:ff:ff:ff:ff:ff
    altname enp0s3
    inet 192.168.122.68/24 brd 192.168.122.255 scope global dynamic noprefixroute ens3
       valid_lft 2078sec preferred_lft 1456sec
    inet6 fe80::5054:ff:fe14:e974/64 scope link
       valid_lft forever preferred_lft forever

Note the highlighted line in both outputs. It containes the IP-address we'd be using to connect to the guest from the host. Next, in your main host's shell, type in the following:

ssh \
    -o "UserKnownHostsFile=/dev/null" \
    -o "StrictHostKeyChecking=no" \
    root@192.168.122.68

This command would open a guest NixOS shell in your current host shell. Here, we also use two options:

  • -o "UserKnownHostsFile=/dev/null" to tell the ssh programm not to save the NixOS guest's sshd fingerprint, since the fingerprint (the piece of information used to identify the machine you're connecting to) is going to change after the installtion, while staying on the same IP
  • -o "StrictHostKeyChecking=no" so you won't be asked, if you trust the host, since it's on the same machine

Enter the root user's password you've specified (changeme) to enter the NixOS shell.

Partitions and File Systems

Next, we would proceed with partitioning the disk to then install a filesystem.

Now, what's a partition? And what is the difference between a partition and a filesystem?

A partition is just a section of a disk, like a slice in a pie. If you need more than one filesystem on a disk, or if you want to keep different kinds of data in different places, you can have more than one partition on the same disk. Each partition can be formatted with a different filesystem.

A filesystem in turn is what determines how data is organized on the partition. This includes things like the layout of files and how they're named, as well as how the system keeps track of where everything is stored.

Why do we have to use both abstractions though? As it turns out, most operating systems (including Linux) need some kind of partitioning to work. Each partition can only contain one filesystem, but one filesystem can span multiple partitions. For example, you might have a root partition that contains your user data, and a boot partition that contains the files needed to boot the system. These could be on the same disk or on different ones. One advantage of having multiple partitions is that it's more difficult for a single corrupted filesystem to bring down the whole system. If one partition is damaged, you may still be able to boot from another and access your data. Also, some operating systems (including Linux) can use different partitions for different purposes, such as putting frequently accessed files on a separate, faster disk.

If you followed the QEMU setup instructions, you should have a single virtualized disk called sda. Type in lsblk command, a command line utility that lists information about all available or the specified block devices:

# lsblk
NAME  MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
loop0   7:0    0 792.9M  1 loop
sda     8:0    0    60G  0 disk
sr0    11:0    1   825M  0 rom  /iso

We want to create 3 partitions, each with its own purpose and corresponding partlabel:

  • uefi -- contains information on how to start our NixOS system
  • root -- contains the NixOS system itself
  • swap -- provides additional memory in case our system should run low on it

Virtually all systems, including the BSDs, Windows and MacOS come with the tool called fdisk. On NixOS fdisk is an umbrella package featuring 3 flavors:

  • gdisk, the GPT fdisk, an interactive prompt. Running fdisk would actually run gdisk
  • cgdisk, a curses-based GPT fdisk. Curses is a library which provides the functionality of a graphic interface in the console. This is the utility we would be using to better understand the process of the disk partitioning.
  • sgdisk, a command line utility, which (obviously) accepts arguments. One would want to use that in installation scripts, however, I find it quite unituitive for the newcommers when used in tutorials.

There's also a similar tool called GNU parted (parted), which you may have seen already and even used its graphic interface, for example, when installing Ubuntu -- the GNOME Partition Editor (gparted). We would stick to cgdisk though.

There are 4 things, which would define each of our partitions:

  • the patitions' starting sector (block) on our disk
  • the end sector
  • partition type code
  • an optional partlabel

Type in cgdisk /dev/sda to jump into interactive text-based interface.

The boot Partition

Warning! Non-GPT or damaged disk detected! This program will attempt to
convert to GPT form or repair damage to GPT data structures, but may not
succeed. Use gdisk or another disk repair tool if you have a damaged GPT
disk.
                  Press any key to continue....

Ignore the scary message and press space (or any key, really).

                          cgdisk 1.0.8
                      Disk Drive: /dev/sda
                    Size: 125829120, 60.0 GiB
Part. #     Size        Partition Type            Partition Name
----------------------------------------------------------------
            60.0 GiB    free space
    [ Align  ]  [ Backup ]  [  Help  ]  [  Load  ]
    [  New   ]  [  Quit  ]  [ Verify ]  [ Write  ]
              Create new partition from free space

The free space line should be highlighted, navigate to [ New ] with Left and Right arrow keys (or you may simply press N).

Let's start with a uefi parition. You're prompted to enter the first sector, leave it as is and press enter:

First sector (2048-125829086, default = 2048):

Now, type in 512MiB and press enter to make the partition 512-mebibytes-big:

Size in sectors or {KMGTP} (default = 125827039): 512MiB

Next, we should enter the partition type code, we see the following prompt:

Current type is 8300 (Linux filesystem)
Hex code or GUID (L to show codes, Enter = 8300):

Type in L and press enter to get list all the codes:

Hex code or GUID (L to show codes, Enter = 8300): L

Type in efi and press enter:

Type search string, or <Enter> to show all codes: efi
ef00 EFI system partition

Press enter once again, to get back to the type code prompt, and enter ef00, which you've learned earlier:

Hex code or GUID (L to show codes, Enter = 8300): ef00

Enter the new partition's name uefi. Note, that the "name" in this context means "partlabel", and we'll use the partlabels to reference the partitions later during the installation setup. However, there is also a "label", which is not equal to the "partlabel". The "label" piece comes from the file system configuration, not from the partition configuration.

Current partition name is ''
Enter new partition name, or <Enter> to use the current name:
uefi

Finally, you'll see:

                          cgdisk 1.0.8
                      Disk Drive: /dev/sda
                    Size: 125829120, 60.0 GiB
Part. #     Size        Partition Type            Partition Name
----------------------------------------------------------------
            1007.0 KiB  free space
   1        512.0 MiB   EFI system partition      uefi
            59.5 GiB    free space
    [ Align  ]  [ Backup ]  [ Delete ]  [  Help  ]
    [  Info  ]  [  Load  ]  [  naMe  ]  [  Quit  ]
    [  Type  ]  [ Verify ]  [ Write  ]
                  Delete the current partition

Good! Don't worry, if you've made a mistake or don't quite follow, we're only preparing the configuration for partitions, and no changes have been written to the disk yet. In case you have to correct a mistake, simply navigate to the newly created partition with the Up and Down arrow keys, and then to the [ Delete ] button with Left and Right (or press D.)

The root Partition

One parititon is configured, two to go. Navigate with the Up and Down arrow keys to the third line with 59.5 GiB free space and press [ New ]:

                          cgdisk 1.0.8
                      Disk Drive: /dev/sda
                    Size: 125829120, 60.0 GiB
Part. #     Size        Partition Type            Partition Name
----------------------------------------------------------------
            1007.0 KiB  free space
   1        512.0 MiB   EFI system partition      uefi
            59.5 GiB    free space
    [ Align  ]  [ Backup ]  [  Help  ]  [  Load  ]
    [  New   ]  [  Quit  ]  [ Verify ]  [ Write  ]

Empty default for the starting sector of the root partition:

First sector (1050624-125829086, default = 1050624):

50GiB for the size:

Size in sectors or {KMGTP} (default = 124778463): 50GiB

Default 8300 type code for the Linux filesystem (empty value, so press enter) :

Current type is 8300 (Linux filesystem)
Hex code or GUID (L to show codes, Enter = 8300):

root for the partition label:

Current partition name is ''
Enter new partition name, or <Enter> to use the current name:
root

Now we have:

                          cgdisk 1.0.8
                      Disk Drive: /dev/sda
                    Size: 125829120, 60.0 GiB
Part. #     Size        Partition Type            Partition Name
----------------------------------------------------------------
            1007.0 KiB  free space
   1        512.0 MiB   EFI system partition      uefi
   2        50.0 GiB    Linux filesystem          root
            9.5 GiB     free space
    [ Align  ]  [ Backup ]  [ Delete ]  [  Help  ]
    [  Info  ]  [  Load  ]  [  naMe  ]  [  Quit  ]
    [  Type  ]  [ Verify ]  [ Write  ]
              Change the filesystem type code GUID

The swap Partition

I'll tell you what the Swap is in the next section, but for now, let's set up a partition for it.

Press Down arrow key once to select the 9.5 GiB free space and press [ New ].

Empty default for the starting sector of the swap partition

First sector (105908224-125829086, default = 105908224):

Empty default for the size in sectors (utilizes the entire free space):

Size in sectors or {KMGTP} (default = 19920863):

Since you already know how to find the partition type codes, I'll just tell you straight away: 8200 (not 8300 !) is the code for the Linux swap:

Current type is 8300 (Linux filesystem)
Hex code or GUID (L to show codes, Enter = 8300): 8200

swap for the partlabel:

Current partition name is ''
Enter new partition name, or <Enter> to use the current name:
swap

It now looks like this:

                          cgdisk 1.0.8
                      Disk Drive: /dev/sda
                    Size: 125829120, 60.0 GiB
Part. #     Size        Partition Type            Partition Name
----------------------------------------------------------------
            1007.0 KiB  free space
   1        512.0 MiB   EFI system partition      uefi
   2        50.0 GiB    Linux filesystem          root
   3        9.5 GiB     Linux swap                swap
    [ Align  ]  [ Backup ]  [ Delete ]  [  Help  ]
    [  Info  ]  [  Load  ]  [  naMe  ]  [  Quit  ]
    [  Type  ]  [ Verify ]  [ Write  ]

Navigate to and press [ Write ], then type in yes to confirm the action:

  Are you sure you want to write the partition table to disk?
(yes or no): yes
              Warning!! This may destroy data on your disk!

The partitioning is complete! Press Q or [ Quit ] to exit the interface back to the shell.

File Systems and Swap

Setting up the file systems is extremely simple, and this is where we reference the freshly created partitions by their respective partlabels:

mkfs.ext4 /dev/disk/by-partlabel/root
mount /dev/disk/by-partlabel/root /mnt
mkfs.fat /dev/disk/by-partlabel/uefi
mkdir /mnt/boot
mount /dev/disk/by-partlabel/uefi /mnt/boot
mkswap /dev/disk/by-partlabel/swap
swapon /dev/disk/by-partlabel/swap

Done!

mkfs, as evident from the name, "makes a file system". EXT4 is the standard file systems you would be using with Linux. FAT, however, is a file system originally coming from DOS. The UEFI specification is based on the FAT file system, thus, we should use mkfs.fat for the uefi parititon. Just FYI, Apple has its own tool (which differs in FAT's implementation), that should be used for the UEFI on Mac, but this is out of the scope of this article.

You may also stumble upon mkfs.vfat command in the wild, so just letting you know, that mkfs.vfat is an outdated version of the same tool and actually symlinks to mkfs.fat, and that would not make any difference in the end.

And what is swap, again?

The swap is a separate part of the computer's hard drive that is used as a temporary storage area when the computer's main memory (RAM) is full. It allows your computer to temporarily move some of its active data to the swap space so that it can continue working on other tasks. It is nice to have some swap, but it is not obligatory.

mount is a command that "mounts" a filesystem under some directory. swapon is similar to mount, except it produces a special type of mount (roughly speaking), which basically tells the system to treat the partition as swap.

Declarative Configuration

So far, so good. Actually, the tedious part is over, and we can finally embrace the power of NixOS.

And the long-awaited command to showcase that power is:

nixos-generate-config --root /mnt

NixOS would create two configuration files: /mnt/etc/nixos/configuration.nix and /mnt/etc/nixos/hardware-configuration.nix. After we edit one of those files, we can begin the installation.

You don't really have to touch the /mnt/etc/nixos/hardware-configuration.nix, since it matches whatever hardware-specific configuration (like the partitions and the files systems, as well as the state of hardware devices themselves) you've got already. If you are ought to change it, say, install a new disk, partition it and apply a file system, you would just run nixos-generate-config again, and your hardware-configuration.nix would be regenerated to reflect the latest changes.

The configuration.nix is the center of NixOS. This is THE declarative configuration, and the actual system mirrors whatever is defined in this configuration. To complete the install, we're going to edit this file and add the following:

  • Set up the bootloader;
  • Configure the network;
  • Provision the environment, enable graphics and sound;
  • Provision the users;
  • Enable SSH and generate a key to be used with it;
  • Configure system packages to be installed.

Before we proceed to the configuration, I would like to mention some quirks of the nix lang. It is not quite intuitive, especially for those without a background in functional programming.

The things to mention to a newcommer are:

  1. It may not be obvious or evident from the documentation, but all these are equal to each other:
something.here = "abc";
something.somethingElse = {
    param = True;
};
# equals
something = {
    somethingElse.param = True;
    here = "abc";
};
# equals
something.somethingElse.param = True;
something = {
    here = "abc";
};

That expression above is called the "attribute set" -- the stuff, that is contained within the curly brackets {} and is also accessible via dot-notation.

  1. Quotes are either "like this" or
    ''
        like this for
        multiple lines
    ''

'single quotes' are not a valid expression.

  1. It is very easy to lose a semicolon ; at the end of the expression, so either use a linter (we'll set up one later), or pay the due attention. The last curly bracket } in configuration.nix (or in any configuration file) does not require a semicolon ;.

  2. Lists items are separated by space or newline:

a = [ 2 "foo" true (2+3) ];
somePackages = [
    vim
    git
    curl
];

This is just enough information for the start, so we continue.

Setting Up the Bootloader

A bootloader is a computer program that loads an operating system or some other program from a computer's persistent storage, such as a hard disk drive into the computer's main memory. A bootloader starts up the computer by initializing the hardware and then loading and starting the operating system. For our bootloader, we would go with GRUB2.

GRUB2 (GRand Unified Bootloader 2) is a free and open source bootloader package from the GNU Project. It is used to boot a wide variety of Linux distributions, as well as Microsoft Windows. It is the successor to the GRUB bootloader and is designed to be simple and easy to use. It is also very modular, so it can be easily extended to support new operating systems and new features. There is also some other bootloader called systemd-boot, which we could have used instead, but we'd stick with GRUB2, since it has multiple advantages over systemd-boot:

  • systemd-boot only supports UEFI, while GRUB2 supports both UEFI and BIOS;
  • systemd-boot can only boot from GPT disks, while GRUB2 can boot from both GPT and MBR disks;
  • systemd-boot can only boot Linux kernels, while GRUB2 can boot Linux kernels, as well as other operating systems such as Microsoft Windows.

As you see, GRUB is more robust overall (and so is UEFI we had chosen earlier is more robust than BIOS). Now that we know what a bootloader is, and why we would choose GRUB2 over systemd-boot, let's continue with our configuration.

As was evident from the 1st part, this article assumes no prior knowledge of Linux. To edit the configuration files, you should be using a text-based editor within the terminal. The most popular choices would be either nano, vim or emacs.

nano is the easiest one to start with, so if you're not familiar with the others, you would use nano /mnt/etc/nixos/configuration.nix command to edit the file.

To set up a bootloader on our system, add the following snippet to the configuration.nix (don't worry, I'll show you the final result in the end):

boot.loader = {
  grub = {
    enable = true;
    version = 2;
    device = "nodev";
    efiSupport = true;
  };
  efi = {
    canTouchEfiVariables = true;
    efiSysMountPoint = "/boot";
  };
};

Easy? Not quite, if you do not know this stuff in advance. It doesn't involve any boilerplate or scripting tough, so it is not easy, but is simple.

Networking

You should recall that our VM is NAT-networked and sees the host as its router. The snippet below enables the network manager. You would most likely want to edit the hostname for your NixOS machine.

# networking
networking = {
  networkmanager.enable = true;
  hostName = "yourHostNameGoesHere"; # edit this to your liking
};

Setting Up the Environment, Graphics and Sound

This is also as simple as it gets, ad the following to the configuration.nix:

# QEMU-specific
services.spice-vdagentd.enable = true;
services.qemuGuest.enable = true;
# locales
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_US.UTF-8";
# graphics
services.xserver = {
  enable = true;
  # setup the resolution to match your screen
  resolutions = [{ x = 1920; y = 1080; }];
  virtualScreen = { x = 1920; y = 1080; };
  layout = "us"; # keyboard layout
  desktopManager = {
    xterm.enable = false;
    xfce.enable = true;
  };
  displayManager.defaultSession = "xfce";
  autorun = true; # run on graphic interface startup
  libinput.enable = true; # touchpad support
};
# audio
sound.enable = true;
nixpkgs.config.pulseaudio = true;
hardware.pulseaudio.enable = true;

Since we're on QEMU KVM, we've set services.spice-vdagentd.enable = true; and services.qemuGuest.enable = true; to enable the shared buffer between host and guest, so we would be able to use copy-paste seamlessly. The behavior of other options should be evident from their respecvtive names.

Users and SSH and Installation

users.users = {
  theNameOfTheUser = { # change this to you liking
    createHome = true;
    isNormalUser = true;
    extraGroups = [
      "wheel"
    ];
    openssh.authorizedKeys.keys = [
      # we're going to fill this in later
    ];
  };
  root = {
    extraGroups = [
      "wheel"
    ];
  };
};
services.openssh.settings = {
  enable = true;
  kexAlgorithms = [ "curve25519-sha256" ];
  ciphers = [ "chacha20-poly1305@openssh.com" ];
  passwordAuthentication = false;
  permitRootLogin = "no"; # do not allow to login as root user
  kbdInteractiveAuthentication = false;
};

You remember, that for the initial setup we have used a password authentication to log in with ssh. In the real environment (one, where NixOS would be exposed to the internet) this is prone to bruteforcing and opens up some attack vectors. The above settings for ssh (with the implementation package called openssh provided by the OpenBSD Project, which is considered the de-facto standard implementation) are considered secure, including the limiting selection of algorithms for encryption and key exchange7.

Since we have disabled the password authentication, we should generate a private-public keypair, which would allow us to log in.

In your host system's shell, type in the following:

ssh-keygen \
    -q \
    -N "" \
    -t ed25519 \
    -f ~/.ssh/id_ed25519_for_nixos

Using the ed25519 algorithm, this will generate two files, where:

  • ~/.ssh/id_ed25519_for_nixos is the private key, with which we're going to authenticate ourselves
  • ~/.ssh/id_ed25519_for_nixos.pub is the public key, that we're going to add to the NixOS configuration

We will put the contents of the public key (~/.ssh/id_ed25519_for_nixos.pub) inside configuration.nix. First, read the contents on your host system (be sure that you are reading the key ending in .pub):

cat ~/.ssh/id_ed25519_for_nixos.pub

You should get something like this as the output in console:

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBv9RyYuStPdBzst/sO5Uiik9zS6Lu/K98T2PJROMc/r yourHostUser@your-host-system

Now, copy this output to the configuration.nix (you may omit the names of the user and the host, as they play no role in this configuration):

openssh.authorizedKeys.keys = [
    "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBv9RyYuStPdBzst/sO5Uiik9zS6Lu/K98T2PJROMc/r"
];

It's okay to store the contents of the public key in plaintext, since your private key stays secure with you. The public key is used to validate, that you are indeed in control of the secret key.

Done!

Initial System Packages

Now, we're going to add some packages (the applications) to our system. Don't worry, we will add more packages after the installation, so this initial batch here is just to showcase the feature.

environment.systemPackages = with pkgs; [
  # cli utils
  git
  curl
  wget
  vim
  htop
  # browser
  chromium
];

Very simple! To the installation!

The Installation

Our /mnt/etc/nixos/configuration.nix file should look like this:

{ config, pkgs, ... }:
{
  imports =
    [
      ./hardware-configuration.nix
    ];
  boot.loader = {
    grub = {
      enable = true;
      version = 2;
      device = "nodev";
      efiSupport = true;
    };
    efi = {
      canTouchEfiVariables = true;
      efiSysMountPoint = "/boot";
    };
  };
  networking = {
    networkmanager.enable = true;
    hostName = "yourHostNameGoesHere"; # edit this to your liking
  };
  # QEMU-specific
  services.spice-vdagentd.enable = true;
  services.qemuGuest.enable = true;
  # locales
  # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
  time.timeZone = "America/New_York";
  i18n.defaultLocale = "en_US.UTF-8";
  # graphics
  services.xserver = {
    enable = true;
    resolutions = [{ x = 1920; y = 1080; }];
    virtualScreen = { x = 1920; y = 1080; };
    layout = "us"; # keyboard layout
    desktopManager = {
      xterm.enable = false;
      xfce.enable = true;
    };
    displayManager.defaultSession = "xfce";
    autorun = true; # run on graphic interface startup
    libinput.enable = true; # touchpad support
  };
  # audio
  sound.enable = true;
  nixpkgs.config.pulseaudio = true;
  hardware.pulseaudio.enable = true;
  # user configuration
  users.users = {
    theNameOfTheUser = { # change this to you liking
      createHome = true;
      isNormalUser = true;
      extraGroups = [
        "wheel"
      ];
      openssh.authorizedKeys.keys = [
        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBv9RyYuStPdBzst/sO5Uiik9zS6Lu/K98T2PJROMc/r"
      ];
    };
    root = {
      extraGroups = [
        "wheel"
      ];
    };
  };
  # ssh
  services.openssh.settings = {
    enable = true;
    kexAlgorithms = [ "curve25519-sha256" ];
    ciphers = [ "chacha20-poly1305@openssh.com" ];
    passwordAuthentication = false;
    permitRootLogin = "no"; # do not allow to login as root user
    kbdInteractiveAuthentication = false;
  };
  # installed packages
  environment.systemPackages = with pkgs; [
    # cli utils
    git
    curl
    wget
    vim
    htop
    # browser
    chromium
  ];
  system.stateVersion = "22.11";
}

Notice, what the last line says: system.stateVersion = "22.11";. It was generated by nixos-generate-config --root /mnt with rest of the file initially, and we should leave it as is.

Run the following command to begin the installation:

nixos-install

You may now relax and observe a mesmerizing dance of same-length hashes reeling in front of you during the install for some minutes. As the last step, you will also be prompted to enter the desired password for the root user:

Installation finished. No error reported.
setting up /etc...
/etc/tmpfiles.d/journal-nocow.conf:26: Failed to resolve specifier: uninitialized /etc detected, skipping
All rules containing unresolvable specifiers will be skipped.
setting up /etc...
/etc/tmpfiles.d/journal-nocow.conf:26: Failed to resolve specifier: uninitialized /etc detected, skipping
All rules containing unresolvable specifiers will be skipped.
setting root password...
New password:

Enter it (I've used changeme once again) and you may now safely reboot:

reboot

Voila! We have a working NixOS system!

Post-installation

Set User's Password and Log In to Graphical Desktop Environment

After the reboot, in your QEMU Virtual Machine, you should see a GRUB2 menu (the boot loader). Wait for several seconds, or for the impatient: just press Enter or Right arrow key to select the Default configuration (the only one currently available to us).

You would be greeted by a login menu. However, you won't be able to login here with your normal user just yet, since we had not specified any password for this user during the installation. Is that something to worry about? Absolutely not, since we have configured ssh. We'll set up the password over ssh to then be able to use both ssh and graphical interface.

In your shell on host system you would also see:

[root@nixos:/mnt/etc/nixos]# reboot
Connection to 192.168.122.68 closed by remote host.
Connection to 192.168.122.68 closed.

Use the following command from your main host to connect to NixOS:

ssh theNameOfTheUser@192.168.122.68 -i ~/.ssh/id_ed25519_for_nixos

If case you've somehow missed the information regarding the host fingerprint change and used ssh in the way you may be used to (so, without specifying the -o "UserKnownHostsFile=/dev/null" that I've mentioned earlier) , you would see the following:

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
Someone could be eavesdropping on you right now (man-in-the-middle attack)!
It is also possible that a host key has just been changed.
...

Run the following command to remove the fingerprint from the list of known hosts, so you would be to add it again after the succesful connect:

# remove the fingerprint of from the `known_hosts` file, be sure to enter the right IP
ssh-keygen -f "~/.ssh/known_hosts" -R "192.168.122.68"
# connect to NixOS guest via ssh
ssh theNameOfTheUser@192.168.122.68 -i ~/.ssh/id_ed25519_for_nixos

After entering the NixOS shell, type in the following command:

su root

to become root (superuser). Then enter

passwd theNameOfTheUser

where theNameOfTheUser is obviously the name of the user you have used inside configuration.nix. Enter the password you want to use for this user (twice). Done! You may now log in with graphic interface as well.

Add a Chromium Extension to the System

First, let's tweak our system a bit to explore the configuration possibilities and to learn how NixOS manages the changes in a declarative way.

We have added chromium as a package to be installed, but Chromium API as well as the abstractions predefined with nix lang for the chromium package provide us with the possibility to also specify the desired browser's extensions. This only works with the extensions, which were published to the Chrome Web Store.

Specifying an extension to install is extremely simple: just add programs.chromium and include the hash of extension, which is available from the URL in the Chrome Web Store.

We'll add the all-time classics: the uBlock Origin, and, as you can see from the URL, its hash is cjpalhdlnbpafiamejdnhcphjbkeiagm.

Edit the the configuration with nano /etc/nixos/configuration.nix, where nano is run with sudo or as root. Note, that after the install, the configuration.nix should be found under /etc/nixos/configuration.nix, and not /mnt/etc/nixos/configuration.nix. Add the following snippet before the closing bracket:

programs.chromium = {
  enable = true;
  extensions = [
    "cjpalhdlnbpafiamejdnhcphjbkeiagm" # uBlock Origin
  ];
};

Now, for changes to take the effect, run the following command, again, with sudo or as root:

nixos-rebuild switch

It will instruct NixOS to do 2 things:

  • rebuild the configuration (while preserving the previous ones)
  • apply the new configuration system-wide

When the procedure is finished in a minute or so, you would be able to see the uBlock Origin extension in Chromium.

Add VSCode to System

Now, we'll add the VSCode editor and some of its extensions to the system.

nixpkgs.config.allowUnfree = true;
environment.systemPackages = with pkgs; [
  # cli utils
  git
  curl
  wget
  vim
  htop
  # browser
  chromium
  (vscode-with-extensions.override {
    vscodeExtensions = with vscode-extensions; [
      bbenoist.nix # syntax highlight for .nix files in vscode
    ];
  })
];

Since VSCode is a proprietary application, we should add the nixpkgs.config.allowUnfree = true; line to our configuration.

Note, both the specific package of VSCode (the one, which allows us to install extensions declaratively), as well as the extension itself go into our declarative configuration under environment.systemPackages. The extension we have added is bbenoist.nix, which adds nix language support to VSCode (syntax highlighting and basic punctuation linting).

You should understand, that this abstraction provides access to only those extensions, that were already defined in nixpkgs. Any extension outside of that would be treated as an undefined variable. The full list of defined extensions is available in the package declaration on GitHub

Luckily, we can also declaratively specify any VSCode extension published to the Visual Studio Marketplace. The user experience however is not as smooth as one could wish. Let's take a sample extension available on the Visual Studio Marketplace, but not on nixpkgs: the search-crates-io, an extension for Rust developers. Modify the snippet in the following way:

nixpkgs.config.allowUnfree = true;
environment.systemPackages = with pkgs; [
  # cli utils
  git
  curl
  wget
  vim
  htop
  # browser
  chromium
  (vscode-with-extensions.override {
    vscodeExtensions = with vscode-extensions; [
      bbenoist.nix # syntax highlight for .nix files in vscode
    ] ++ pkgs.vscode-utils.extensionsFromVscodeMarketplace [
      {
        name = "search-crates-io";
        publisher = "belfz";
        version = "1.2.1";
        sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
      }
    ];
];
];

We have specified all the information required to fetch the extension and build it from the source: the name, the publisher, the version and the something called sha256. This sha256 naming however is not quite correct, since it is not sha256, but a sha256-based Subresource Integrity String (SRI).

We cannot know in advance, which exactly SRI should we specify, since it requires NixOS to evaluate the configuration and to try building it. Thus, we first put a placeholder SRI: sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";

Then, we do the usual nixos-rebuild switch for it to error on the incorrect hash and tell us the actual one in the console output:

error: hash mismatch in fixed-output derivation '/nix/store/ymrx4rqznf0y106v739wclmma01d554g-belfz-search-crates-io.zip.drv':
         specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
            got:    sha256-K2H4OHH6vgQvhhcOFdP3RD0fPghAxxbgurv+N82pFNs=

After that, we again edit the configuration.nix to match the specified and actual hashes:

  (vscode-with-extensions.override {
    vscodeExtensions = with vscode-extensions; [
      bbenoist.nix # syntax highlight for .nix files in vscode
    ] ++ pkgs.vscode-utils.extensionsFromVscodeMarketplace [
      {
        name = "search-crates-io";
        publisher = "belfz";
        version = "1.2.1";
        sha256 = "sha256-K2H4OHH6vgQvhhcOFdP3RD0fPghAxxbgurv+N82pFNs=";
      }
    ];
  })

Now,

nixos-rebuild switch

in console as root or with sudo.

After the changes take effect, and since we're running a graphic desktop environment, in order to find the freshly installed VSCode in the Applications Menu, you may need to log out and log in again with your current user.

After that, we get a working VSCode with 2 extensions, one from nixpkgs, and one from the Visual Studio Marketplace!

In this not so short introduction to NixOS and Nix Expression Language we have learned the basic concepts to get a working NixOS system running. This is very far from being comprehensive, and I have not covered varios topics, which include but are not limited to:

  • deep dive into nix language and nix package manager
  • flakes
  • home-manager
  • packaging a precompiled binary
  • compiling from source
  • setting up dev environments
  • and many other things!

I plan on releasing a series of articles covering the NixOS concepts in detail in the upcoming weeks, and this is the Part I.

You may want to sign up for the newsletter to stay up-to-date about the series progress.

This article's full code alongside the rest of configuration is available on GitHub.

Footnotes

  1. "The Open Society and Its Enemies" by Karl Popper, but this is not the case study for this article ↩

  2. Indeed, you are not, which is more evident by the day. "This PC" is not your computer. ↩

  3. Mr. Robot ↩

  4. Hacker News Search: "nixos documentation" ↩

  5. And I really want to share this recent article about complexity and cognitive load: Cognitive Loads in Programming ↩

  6. Modules, monoliths, and microservices, an article about isolation ↩

  7. Secure Secure Shell ↩

geschätzte Lesezeit: 8 min

If you are a Nix user, you probably already have heard about Nix Flakes. There are already many great posts about it, among them we have the author of flakes - Eelco Dolstra’s

  1. Nix Flakes, Part 1: An Introduction And Tutorial
  2. Nix Flakes, Part 2: Evaluation Caching
  3. Nix Flakes, Part 3: Managing NixOS Systems

I plan to start a new series talking about the more practical side of flakes, i.e. how to use it to solve my day-to-day pain point, one example per post. This is the first part of the series, in which I will talk about how to package with flakes.

Nix Software Packaging The Old Way

Suppose we have a very simple application hello-repeater, that periodically prints Hello, <name of your choice> on the screen to greet someone. You can find the source code here, but it is just as simple as:

#include <cstdio>
#include <chrono>
#include <thread>
int main(int argc, char **argv) {
  while (true) {
    printf("Hello, sir!\n");
    std::this_thread::sleep_for(std::chrono::seconds(5));
  }
  return 0;
}

A typical use case is that I will need to create a package (derivation) out of it and use the package in another project or a NixOS configuration. This means I will need to write a .nix file to package it like this:

{ pkgs, stdenv, cmake, ... }:
stdenv.mkDerivation {
  pname = "hello-repeater";
  version = "1.0.0";
  src = pkgs.fetchgit {
    url = "https://github.com/breakds/flake-example-hello-repeater.git";
    rev = "c++-code-alone";
    sha256 = "sha256-/3tT3jBmWLaENcBRQhi2o3DHbBp2yiYsq2HMD/OYXNU=";
  };
  nativeBuildInputs = [
    cmake
  ];
}

And then I can add this as a dependency by calling callPackage on it. So far so good.

The problem: Imagine the case when I have to use this package in 5 different projects or NixOS configurations, and they live in 5 different git repos. It does not make sense to replicate the above .nix file 5 times!

Ideally, you only want to define that package once, preferrably in the original software’s repo. Althgouh it is possible to work that around without having to use Nix Flakes, but I think Nix Flakes provides one of the most elegant way to deal with that. Let’s dive in!

Preparation

You will first need to enable flakes. Since I am running NixOS, it is as simple as adding the following to my NixOS configuration with a nixos-rebuild switch:

nix = {
  package = pkgs.nixFlakes;
  extraOptions = ''
    experimental-features = nix-command flakes
  '';
};

Packaging with flake.nix

Let’s add a flake.nix to the root of our software’s repo. You can go ahead and touch it or use the following command to create it from a template and modify it:

nix flake init 

Our first flake.nix looks like this:

{
  description = "Package the hello repeater.";
  outputs = { self, nixpkgs }: {
    packages.x86_64-linux.hello-repeater =
      let pkgs = import nixpkgs {
            system = "x86_64-linux";
          };
      in pkgs.stdenv.mkDerivation {
        pname = "hello-repeater";
        version = "1.0.0";
        src = pkgs.fetchgit {
          url = "https://github.com/breakds/flake-example-hello-repeater.git";
          rev = "c++-code-alone";
          sha256 = "sha256-/3tT3jBmWLaENcBRQhi2o3DHbBp2yiYsq2HMD/OYXNU=";
        };
        nativeBuildInputs = with pkgs; [
          cmake
        ];
      };
  };
}

Let me explain it a bit. The whole purpose of this flake.nix is to provide a package called hello-repeater. To achieve that, we need to specify outputs.

The variable outputs is defined as a function that returns an attribute set, where our package (derivation) is defined under packages.x86_64-linux.hello-repeater. The code that actally defines the derivation is not interesting, as it is merely a copy of the above derivation with a specialized pkgs derived from nixpkgs.

Q: Why the attribute path packages.x86_64-linux.hello-repeater? Is it some kind of convention?

A: Yes it is a Nix flakes convention to put the packages you want to expose at packages.<system>.<package-name>. The type of such attributes must be derivation. There are quite a few other special attribute paths, and we will talk about them later in this series.

Q: What is <system> in packages.<system>.<package-name>?

A: A package can be built under different systems, and we put them under each <system> attribute for all the systems that we want the package to support. In the above example we only enabled it for x86_64-linux, which stands for 64 bit Linux on X86 architecture CPU. In the next post, we will talk more about different systems.

Play With the Flake

So that’s all the code we need to write today. The full repo snapshot can be found at this git tag.

So now it is time to introduce the two frequently used commands to operate with flakes: nix flake show and nix build.

Command - nix flake show

You can use the command

$ nix flake show <specify-your-flake>

to show the structure of the outputs of the flake. How to specify a flake then? There are multiple ways.

  1. If you want to specify a local flake on your machine, just use the absolute or relative path to the root directory that contains the flake.nix. Examples:

    .  # The flake in the current directory
    ../projects/my-proj  # The flake at a relative path
    /home/myname/projects/my-proj  # The flake at an absolute path
    

    The is extremely useful when you are still developing the software and flake, and simply want to test them locally.

    NOTE: You will need to make sure all the .nix files (including the flake.nix) are already tracked by git before referring to them locally.

  2. If you want to specify a remote flake from GitHub, follow the examples below:

    # Specify a github repo 
    github:breakds/flake-example-hello-repeater
    # Specify a tagged commit called "single-flake" of a github repo
    github:breakds/flake-example-hello-repeater/single-flake
    
  3. There are many other variations of specifying a flake as well. See this reference for a full list of them.

You can now test by running the following command from the root of the local repo:

flake-example-hello-repeater> $ nix flake show .
github:breakds/flake-example-hello-repeater/4959ddd073f9f1ebf7532044d5f3f515470b4d44
└───packages
    └───x86_64-linux
        └───hello-repeater: package 'hello-repeater-1.0.0'

or just show the remote repo’s flake with

$ nix flake show github:breakds/flake-example-hello-repeater
github:breakds/flake-example-hello-repeater/4959ddd073f9f1ebf7532044d5f3f515470b4d44
└───packages
    └───x86_64-linux
        └───hello-repeater: package 'hello-repeater-1.0.0'

and if you want to specify the tag/commit as well

$ nix flake show github:breakds/flake-example-hello-repeater/4959ddd073f9f1ebf7532044d5f3f515470b4d44
github:breakds/flake-example-hello-repeater/4959ddd073f9f1ebf7532044d5f3f515470b4d44
└───packages
    └───x86_64-linux
        └───hello-repeater: package 'hello-repeater-1.0.0'

Command - nix build

The next command is useful to actually build the flake. The syntax to call that command looks like:

$ nix build <specify-your-flake>#<specify-the-target>

We are already familiar with how to specify the flake. Specifying the target to build is even simpler - you can just specify the attribute path under outputs. In the above case, the path to the attribute is

packages.x86_64-linux.hello-repeater

Note that you can only specify an attribute as build target if its type is derviation.

So from the root of this repo, you can actually run (using . to specify the flake, and packages.x86_64-linux.hello-repeater to specify the build target) the command below to build the package hello-repeater locally:

flake-example-hello-repeater> $ nix build .#packages.x86_64-linux.hello-repeater

This command will build the package as result, the same as what you used to see after a nix-build. You will find the executable at result/bin/hello-repeater, and you can even run it to see it repeats the greeting every 5 seconds.

Apparently you can build it from a remote repo as well with

$ nix build github:breakds/flake-example-hello-repeater#packages.x86_64-linux.hello-repeater

Save some typing in nix build

You actually do not have to specify the full attribute path to the target when you are running nix build. You could actually run the command below from a 64 bit Linux to achieve the same build:

flake-example-hello-repeater> $ nix build .#hello-repeater

It actually knows to find the package name under packages.<current-system>, where the current system is evaluated as the host system, i.e. x86_64-linux in this case. This saves a bit of boiler plate like packages.x86_64-linux.

Implicit build with defaultPackage

Sometimes you will want to specify defaultPackage.<system> (yes, default packages are also specified by system) in addition to packages. This tells nix build what target to build if you just specify the flake without any targets.

In the above example we just need to add one line:

{
  description = "Package the hello repeater.";
  outputs = { self, nixpkgs }: {
    packages.x86_64-linux.hello-repeater =
      let pkgs = import nixpkgs {
            system = "x86_64-linux";
          };
      in pkgs.stdenv.mkDerivation {
        pname = "hello-repeater";
        version = "1.0.0";
        src = pkgs.fetchgit {
          url = "https://github.com/breakds/flake-example-hello-repeater.git";
          rev = "c++-code-alone";
          sha256 = "sha256-/3tT3jBmWLaENcBRQhi2o3DHbBp2yiYsq2HMD/OYXNU=";
        };
        nativeBuildInputs = with pkgs; [
          cmake
        ];
      };
    # Specify the default package
    defaultPackage.x86_64-linux = self.packages.x86_64-linux.hello-repeater; # <- add this
  };
}

Note that how we use self to refer to the outputs itself and to refer to the package hello-repeater (that is the beauty of nix too). The updated repo can be find at this git tag.

Now you can build the package locally from the repo’s root with

$ nix build .

or remotely with

$ nix build github:breakds/flake-example-hello-repeater

without having to specify the package explicitly at all.

Summary

In this post we went over the basics of how to use nix flakes to package a software into derivation. If you are new to nix flakes, I am sure you still have many questions. Stay tuned and we will try to answer them in the following posts of the series. May the flakes be with you!

geschätzte Lesezeit: 9 min

Posted on June 10, 2022 by Richard GoulterTags:

As with anyone who’s been using git for long enough, I’m familiar enough with SSH keys (Even to the point where I don’t really need to follow the GitHub docs for generating a new key.

My developer journey hasn’t led me to build the same intuition for GPG keys.

So, when I see fancy wiki pages and guids, such as Debian wiki’s page on subkeys, on offline master keys, or on an airgapped master key, it sounds really fancy; but I’m left curious to get an intuition for what’s going on.

I hope to use this blogpost illustrate enough of the details to build an intuition.

Public Key Cryptography

The Wikipedia page on Public Key cryptography has some nice diagrams.

This itself should still be relatively straightforward, comparing with how SSH keys get used with git forges like GitHub, and with SSHing into servers:

image

  • You have a key pair with a private key and public key,

  • you keep the private key private,

  • you share the public key with the servers you want to access to.

    • Having the private key gives access. And this is ‘secure’ because only the user with the private key which matches the public keys will be granted access.

So it makes sense: Encrypting;

imageimage

  • You encrypt something for someone else using their public key.

    • Everyone has their public key, so everyone can send them something.

    • Only they have their private key, so only they can read the message.

Or signing:

imageimage

  • You sign something with your private key.

    • You’re the only person who has your private key, so only you can sign something.

    • Everyone has your public key, so everyone else can verify you signed it.

The Rote GPG Commands

So far, we can just label everything above with “GPG” and it still makes sense:

e.g. Key generation could be done by invoking a command like:

gpg --quick-generate-key "Richard Goulter (NixOS Desktop) <richard.goulter@rgoulter.com>"

e.g. exporting a key, to copy the public part to GitHub or whatever could be done by invoking a command like:

gpg --export --armor "Richard Goulter (NixOS Desktop)"

e.g. encrypting/decrypting a message could be done by invoking a command like:

gpg --encrypt --recipient "My Colleague" document.txt

and the recipient could decrypt it with:

gpg --decrypt document.txt.gpg

e.g. signing could be done by invoking a command like:

gpg --clearsign file.txt

or

gpg --detach-sign file.txt

and the signature could be verified with a command like:

gpg --verify file.txt.asc

or

gpg --verify file.txt.sig

What Starts to Confuse Me: Subkeys

Okay, but having created a key, then list the keys:

gpg --list-keys

outputs something like:

pub   rsa4096 2020-10-05 [SC] [expires: 2022-08-01]
      D9463A85F093811B887DE4B03229F4BB09CC8A22
uid           [ultimate] Richard Goulter (NixOS Desktop) 
sub   rsa4096 2020-10-05 [E] [expires: 2022-08-01]

and secret keys:

gpg --list-secret-keys

outputs something like:

sec   rsa4096 2020-10-05 [SC] [expires: 2022-08-01]
      D9463A85F093811B887DE4B03229F4BB09CC8A22
uid           [ultimate] Richard Goulter (NixOS Desktop) 
ssb   rsa4096 2020-10-05 [E] [expires: 2022-08-01]

I can see the only difference is pub, sub, vs sec and ssb. What?

What’s [SC], [E]?

And if you enable some recommended options in ~/.gnupg/gpg.conf:

keyid-format LONG
with-fingerprint
with-keygrip

re-running:

gpg --list-keys

outputs:

pub   rsa4096/3229F4BB09CC8A22 2020-10-05 [SC] [expires: 2022-08-01]
      Key fingerprint = D946 3A85 F093 811B 887D  E4B0 3229 F4BB 09CC 8A22
      Keygrip = 480A928A7AA17A693FDF4F4E9DEAEBEE1B778FB7
uid                 [ultimate] Richard Goulter (NixOS Desktop) 
sub   rsa4096/15BAE7FB6F3DA787 2020-10-05 [E] [expires: 2022-08-01]
      Keygrip = A996CF5366A2FFCA11EE958FBA3AC4BDFDB6AC76

I feel like this one I’m allowed to be confused by.

Long Id?

Fingerprint??

Keygrip???

A Key Consisting of Multiple Keys

Uh. Hopefully it’s not too late in this blogpost to mention it.But “key” gets used in multiple ways e.g. “running gpg --quick-generate-key generates a GPG ‘key’”, and in “a GPG ‘key’ consists of at least one key pair”.

Generally, context is often enough to disambiguate these. But given that GPG is confusing enough as it is, it’s worth mentioning the ambiguity.

Taking a look at the terminology GPG’s manual uses: https://www.gnupg.org/gph/en/manual.html#CONCEPTS

GnuPG uses a somewhat more sophisticated scheme in which a user has a primary keypair and then zero or more additional subordinate keypairs. The primary and subordinate keypairs are bundled to facilitate key management and the bundle can often be considered simply as one keypair.

This excerpt refers to the keys being ‘bundled’, and that the bundle can often be considered as a keypair.

The GnuPG FAQ has an answer which better describes the ambiguity. https://www.gnupg.org/faq/gnupg-faq.html#define_key

The word ‘key’ is unfortunately ambiguous. It can either refer to the mathematical structures that allow encryption, decryption, signing and verification to occur, or to the rather large blobs of data that contain those mathematical structures as well as information about the person associated with it, additional subkeys, and so forth.

With respect to the large blobs of data, it is preferable to call them ‘certificates’, so that the word ‘key’ may be unambiguously recognized as meaning just the mathematical structures. Unfortunately, this is a custom that seems to be honored mostly in the breach.

In this blogpost, I’ll try to use the word “key” in scare quotes to indicate “bundle of key pairs”.

Other GPG Features/Complications

So while I feel like the idea of “a key pair, with a private part and a public part” is understandable, there are other features of GPG “keys” which make things more complicated:

GPG “keys” can be set with an expiry date.(This is useful as a way of indicating you’ve still got access to the private key).

GPG “keys” can have multiple “User IDs” (Name + email). Photos can be included in the GPG “key”.

GPG “keys” can be signed. Or more specifically: the keys and UIDs can be signed.– I don’t find this intuitive. Sure, Bruce Wayne and Batman are different identities, but I don’t see why they’d want to use the same key.

GPG “keys” can be revoked. That is, an indication that other subkeys or UIDs or signatures should not be trusted.– I didn’t find this intuitive.

GPG “keys” can have some level of trust.– This also seems unintuitive to me.

Why Subkeys are Subkeys

I found this StackOverflow answer about why different keys are used for encryption vs signing illuminating:

If you look into the details of the math of public-key encryption, you will discover that signing and decrypting are actually identical operations. Thus in a naïve implementation it is possible to trick somebody into decrypting a message by asking them to sign it.

i.e. it would be bad a idea to use the same (sub)key for both signing and encryption. So, GPG uses a separate subkey for at least encryption.

This answer also explains what the [SC] and [E] refer to:

E = encrypt/decrypt (decrypt a message you received encrypted for you to read)S = sign (sign data. For example a file or to send signed e-mail)C = certify (sign another key, establishing a trust-relation)A = authentication (log in to SSH with a PGP key; this is relatively new usage)

The Wikipedia page about RSA gives an example of using RSA keys, which is useful for understanding “signing and decrypting are identical operations”.

The GPG User Guide (Section “Key Management”, subsection “Key integrity”) sort of does explain the “master” and “subordinate” part:

Using digital signatures is a solution to this problem. When data is signed by a private key, the corresponding public key is bound to the signed data. In other words, only the corresponding public key can be used to verify the signature and ensure that the data has not been modified. A public key can be protected from tampering by using its corresponding private master key to sign the public key components and user IDs, thus binding the components to the public master key. Signing public key components with the corresponding private master signing key is called self-signing, and a public key that has self-signed user IDs bound to it is called a certificate.

The ‘self-signing’ of sub-keys by the master key is a detail which wasn’t clear to me.

“Subordinate” is a surprising word here.Even the absolutely excellent post “anatomy of a gpg key” expands “subkey” to “sub-component of another key”, not “subordinate key”.

I further feel vindication in that the website https://gpg.wtf/ also discusses ‘what a subkey is’ in its “things that are confusing about gpg”.

The “master key signs the subkeys” can be seen by running a command like:

gpg --check-signatures 0x3229F4BB09CC8A22

which outputs something like:

pub   rsa4096/0x3229F4BB09CC8A22 2020-10-05 [SC] [expires: 2022-08-01]
      Key fingerprint = D946 3A85 F093 811B 887D  E4B0 3229 F4BB 09CC 8A22
      Keygrip = 480A928A7AA17A693FDF4F4E9DEAEBEE1B778FB7
uid                   [ultimate] Richard Goulter (NixOS Desktop) 
sig!3        0x3229F4BB09CC8A22 2022-05-03  Richard Goulter (NixOS Desktop) 
sub   rsa4096/0x15BAE7FB6F3DA787 2020-10-05 [E] [expires: 2022-08-01]
      Keygrip = A996CF5366A2FFCA11EE958FBA3AC4BDFDB6AC76
sig!         0x3229F4BB09CC8A22 2022-05-03  Richard Goulter (NixOS Desktop) 
gpg: 2 good signatures

i.e. The GPG “key” has:

  • the public master key,

  • a UID which is signed with the [SC] key,

  • a subordiate key, which is intended for [E] usage, as signed with the [SC] key.

Illustrating the Rote GPG Operations

Going back over the rote commands again, now with a diagrams to illustrate subkeys at work:

Key Generation

image

could be done by invoking a command like:

gpg --quick-generate-key "Richard Goulter (NixOS Desktop) <richard.goulter@rgoulter.com>"

Export Public Key

Exporting the key to copy the public part to GitHub or whatever:

image

could be done by invoking a command like:

gpg --export --armor "Richard Goulter (NixOS Desktop)"

Encrypting/Decrypting a Message

imageimage

could be done by invoking a command like:

gpg --encrypt --recipient "My Colleague" document.txt

and the recipient could decrypt it with:

gpg --decrypt document.txt.gpg

Signing

imageimage

could be done by invoking a command like:

gpg --clearsign file.txt

or

gpg --detach-sign file.txt

and the signature could be verified with a command like:

gpg --verify file.txt.asc

or

gpg --verify file.txt.sig

GPG for SSH

Again, coming from the mindset of “SSH keys for pushing to git repositories, gpg for signing stuff”, hearing “GPG ‘keys’ can be used for SSH too” sounds like wizardry.

This is also where “keygrip” comes into use. As this blogpost discussing GPG fingerprint vs SSH key fingerprint vs GPG keygrip explains, the keygrip is a way of identifying the key regardless of protocol/usage.

Now that I have a better understanding of subkeys, it seems the main points for “using GPG for SSH” involves two things to understand:

  • Using a suitably configured gpg-agent instead of ssh-agent, by pointing the SSH_AUTH_SOCK and SSH_AGENT_PID to GPG.

  • Having a subkey with [A] capability.

Visualisation of the Offline Master Key Setup

With the above in mind, a summary of the result of these tutorials for setting up master keys could be visualised as follows:

image

Which seems less complex than all the confusion I had over it made it out to be.

As the Debian wiki article pages discuss, because the private master key is used to self-sign the subkeys in the GPG “key”, it can then be used to revoke the other subkeys.

Yubikey as a GPG Smart Card

Yubikeys can then allow a nicer User Experience for using GPG keys. e.g. the Yubikey supports 3 slots for private keys: for signing, encryption, or authentication.

image

e.g. Yubico’s PGP walkthrough.

Newer post Older post


geschätzte Lesezeit: 3 min

ZFS Datasets for NixOS

The outdated and historical nature of the Filesystem Hierarchy Standard means traditional Linux distributions have to go to great lengths to separate “user data” from “system data.”

NixOS’s filesystem architecture does cleanly separate user data from system data, and has a much easier job to do.

Traditional Linuxes

Because FHS mixes these two concerns across the entire hierarchy, splitting these concerns requires identifying every point across dozens of directories where the data is the system’s or the user’s. When adding ZFS to the mix, the installers typically have to create over a dozen datasets to accomplish this.

For example, Ubuntu’s upcoming ZFS support creates 16 datasets:

rpool/
├── ROOT
│   └── ubuntu_lwmk7c
│       ├── log
│       ├── mail
│       ├── snap
│       ├── spool
│       ├── srv
│       ├── usr
│       │   └── local
│       ├── var
│       │   ├── games
│       │   └── lib
│       │       ├── AccountServices
│       │       ├── apt
│       │       ├── dpkg
│       │       └── NetworkManager
│       └── www
└── USERDATA

Going through the great pains of separating this data comes with significant advantages: a recursive snapshot at any point in the tree will create an atomic, point-in-time snapshot of every dataset below.

This means in order to create a consistent snapshot of the system data, an administrator would only need to take a recursive snapshot at ROOT. The same is true for user data: take a recursive snapshot of USERDATA and all user data is saved.

NixOS

Because Nix stores all of its build products in /nix/store, NixOS doesn’t mingle these two concerns. NixOS’s runtime system, installed packages, and rollback targets are all stored in /nix.

User data is not.

This removes the entire complicated tree of datasets to facilitate FHS, and leaves us with only a few needed datasets.

Datasets

Design for the atomic, recursive snapshots when laying out the datasets.

In particular, I don’t back up the /nix directory. This entire directory can always be rebuilt later from the system’s configuration.nix, and isn’t worth the space.

One way to model this might be splitting up the data into three top-level datasets:

tank/
├── local
│   └── nix
├── system
│   └── root
└── user
    └── home

In tank/local, I would store datasets that should almost never be snapshotted or backed up. tank/system would store data that I would want periodic snapshots for. Most importantly, tank/user would contain data I want regular snapshots and backups for, with a long retention policy.

From here, you could add a ZFS dataset per user:

tank/
├── local
│   └── nix
├── system
│   └── root
└── user
    └── home
        ├── grahamc
        └── gustav

Or a separate dataset for /var:

tank/
├── local
│   └── nix
├── system
│   ├── var
│   └── root
└── user

Importantly, this gives you three buckets for independent and regular snapshots.

The important part is having /nix under its own top-level dataset. This makes it a “cousin” to the data you do want backup coverage on, making it easier to take deep, recursive snapshots atomically.

Properties

  • Enable compression with compression=on. Specifying on instead of lz4 or another specific algorithm will always pick the best available compression algorithm.
  • The dataset containing journald’s logs (where /var lives) should have xattr=sa and acltype=posixacl set to allow regular users to read their journal.
  • Nix doesn’t use atime, so atime=off on the /nix dataset is fine.
  • NixOS requires (as of 2020-04-11) mountpoint=legacy for all datasets. NixOS does not yet have tooling to require implicitly created ZFS mounts to settle before booting, and mountpoint=legacy plus explicit mount points in hardware-configuration.nix will ensure all your datasets are mounted at the right time.

I don’t know how to pick ashift, and usually just allow ZFS to guess on my behalf.

Partitioning

I only create two partitions:

  1. /boot formatted vfat for EFI, or ext4 for BIOS
  2. The ZFS dataset partition.

There are spooky articles saying only give ZFS entire disks. The truth is, you shouldn’t split a disk into two active partitions. Splitting the disk this way is just fine, since /boot is rarely read or written.

Note: If you do partition the disk, make sure you set the disk’s scheduler to none. ZFS takes this step automatically if it does control the entire disk.

On NixOS, you an set your scheduler to none via:

{ boot.kernelParams = [ "elevator=none" ]; }

NixOS’s clean separation of concerns reduces the amount of complexity we need to track when considering and planning our datasets. This gives us flexibility later, and enables some superpowers like erasing my computer on every boot, which I’ll write about on Monday.