Switching from Arch Linux to NixOS

I use Arch btw

Not any more.

During my last post about deploying PeerTube, I considered using NixOS for the server. I eventually decided against it due to a steep learning curve, but Nix's goal of declarative environment made me eager to try it out one day.

Arch Linux has been the daily driver on my laptop for well over two years. I had no complaints with it, receiving timely updates while still being a generally stable system. However, even though reproducibility is not exactly what I need in such a device, the ability to roll back with ease as well as declare my setup in a Git repository was appealing to me.

The previous setup

I use a Framework Laptop 13 with a 12th-generation Intel Core processor. With a two-terabyte SSD, it was a home to two operating systems, Arch Linux and Windows which I blame BattlEye for.

[lyuk98@framework ~]$ sudo parted --list
Model: WDS200T1X0E-00AFY0 (nvme)
Disk /dev/nvme0n1: 2000GB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags: 

Number  Start   End     Size    File system  Name                          Flags
 1      1049kB  1075MB  1074MB  fat32                                      boot, esp
 2      1075MB  1680GB  1679GB
 3      1680GB  1680GB  16.8MB               Microsoft reserved partition  msftres, no_automount
 4      1680GB  2000GB  319GB                Basic data partition          msftdata, no_automount
 5      2000GB  2000GB  675MB   ntfs                                       hidden, diag, no_automount


It uses full-disk encryption with LUKS, containing a single Btrfs partition for everything Linux. TPM is used to automatically decrypt the device on boot, and Secure Boot is enabled with my own keys registered at the firmware.

[lyuk98@framework ~]$ cat /etc/kernel/cmdline | tr ' ' '\n'
quiet
splash
vt.global_cursor_default=0
tpm_tis.interrupts=0
rd.luks.uuid=95582132-bd92-4355-8afc-f17636c73869
rd.luks.options=95582132-bd92-4355-8afc-f17636c73869=luks,discard,x-initrd.attach,tpm2-device=auto
root=UUID=391379dd-32a7-4100-8062-7492eb227b4b
rootfstype=btrfs
rootflags=defaults,rw,compress=zstd,discard=async,subvol=/@
psi=1
lockdown=integrity

The previous setup mounted a lot of subvolumes inside /var. It was a result of following an old default layout for OpenSUSE, because I did not want to disable copy-on-write for the whole /var back then.

[lyuk98@framework ~]$ findmnt
TARGET                                        SOURCE                                                                         FSTYPE          OPTIONS
/                                             /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/@]                      btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=261,subvol=/@
├─/home                                       /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/home]                   btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=256,subvol=/home
├─/dev                                        devtmpfs                                                                       devtmpfs        rw,nosuid,size=4096k,nr_inodes=4045005,mode=755,inode64
│ ├─/dev/mqueue                               mqueue                                                                         mqueue          rw,nosuid,nodev,noexec,relatime
│ ├─/dev/hugepages                            hugetlbfs                                                                      hugetlbfs       rw,nosuid,nodev,relatime,pagesize=2M
│ ├─/dev/shm                                  tmpfs                                                                          tmpfs           rw,nosuid,nodev,inode64
│ └─/dev/pts                                  devpts                                                                         devpts          rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000
├─/sys                                        sysfs                                                                          sysfs           rw,nosuid,nodev,noexec,relatime
│ ├─/sys/kernel/tracing                       tracefs                                                                        tracefs         rw,nosuid,nodev,noexec,relatime
│ ├─/sys/kernel/debug                         debugfs                                                                        debugfs         rw,nosuid,nodev,noexec,relatime
│ ├─/sys/fs/fuse/connections                  fusectl                                                                        fusectl         rw,nosuid,nodev,noexec,relatime
│ ├─/sys/kernel/security                      securityfs                                                                     securityfs      rw,nosuid,nodev,noexec,relatime
│ ├─/sys/fs/cgroup                            cgroup2                                                                        cgroup2         rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot
│ ├─/sys/fs/pstore                            pstore                                                                         pstore          rw,nosuid,nodev,noexec,relatime
│ ├─/sys/firmware/efi/efivars                 efivarfs                                                                       efivarfs        rw,nosuid,nodev,noexec,relatime
│ ├─/sys/fs/bpf                               bpf                                                                            bpf             rw,nosuid,nodev,noexec,relatime,mode=700
│ └─/sys/kernel/config                        configfs                                                                       configfs        rw,nosuid,nodev,noexec,relatime
├─/proc                                       proc                                                                           proc            rw,nosuid,nodev,noexec,relatime
│ └─/proc/sys/fs/binfmt_misc                  systemd-1                                                                      autofs          rw,relatime,fd=39,pgrp=1,timeout=0,minproto=5,maxproto=5,direct,pipe_ino=25617
│   └─/proc/sys/fs/binfmt_misc                binfmt_misc                                                                    binfmt_misc     rw,nosuid,nodev,noexec,relatime
├─/run                                        tmpfs                                                                          tmpfs           rw,nosuid,nodev,size=6508296k,nr_inodes=819200,mode=755,inode64
│ ├─/run/user/1000                            tmpfs                                                                          tmpfs           rw,nosuid,nodev,relatime,size=3254144k,nr_inodes=813536,mode=700,uid=1000,gid=1000,inode64
│ │ ├─/run/user/1000/gvfs                     gvfsd-fuse                                                                     fuse.gvfsd-fuse rw,nosuid,nodev,relatime,user_id=1000,group_id=1000
│ │ └─/run/user/1000/doc                      portal                                                                         fuse.portal     rw,nosuid,nodev,relatime,user_id=1000,group_id=1000
│ ├─/run/credentials/systemd-journald.service tmpfs                                                                          tmpfs           ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap
│ └─/run/credentials/systemd-resolved.service tmpfs                                                                          tmpfs           ro,nosuid,nodev,noexec,relatime,nosymfollow,size=1024k,nr_inodes=1024,mode=700,inode64,noswap
├─/opt                                        /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/opt]                    btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=257,subvol=/opt
├─/root                                       /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/root]                   btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=258,subvol=/root
├─/srv                                        /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/srv]                    btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=259,subvol=/srv
├─/usr/local                                  /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/usr/local]              btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=260,subvol=/usr/local
├─/var/crash                                  /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/crash]              btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=264,subvol=/var/crash
├─/var/cache                                  /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/cache]              btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=263,subvol=/var/cache
├─/var/lib/flatpak                            /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/lib/flatpak]        btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=274,subvol=/var/lib/flatpak
├─/var/lib/libvirt/images                     /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/lib/libvirt/images] btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=265,subvol=/var/lib/libvirt/images
├─/var/lib/machines                           /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/lib/machines]       btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=2646,subvol=/var/lib/machines
├─/var/lib/mailman                            /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/lib/mailman]        btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=273,subvol=/var/lib/mailman
├─/var/lib/mariadb                            /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/lib/mariadb]        btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=268,subvol=/var/lib/mariadb
├─/var/lib/mysql                              /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/lib/mysql]          btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=269,subvol=/var/lib/mysql
├─/var/lib/named                              /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/lib/named]          btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=267,subvol=/var/lib/named
├─/var/log                                    /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/log]                btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=271,subvol=/var/log
├─/var/lib/pgsql                              /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/lib/pgsql]          btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=270,subvol=/var/lib/pgsql
├─/var/spool                                  /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/spool]              btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=266,subvol=/var/spool
├─/var/opt                                    /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/opt]                btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=272,subvol=/var/opt
├─/var/swap                                   /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/swap]               btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=275,subvol=/var/swap
├─/var/tmp                                    /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869[/var/tmp]                btrfs           rw,relatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=262,subvol=/var/tmp
├─/boot                                       /dev/nvme0n1p1                                                                 vfat            rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro,discard
└─/tmp                                        tmpfs                                                                          tmpfs           rw,nosuid,nodev,nr_inodes=1048576,inode64

Installing NixOS on a virtual machine

Even though I have decided to install NixOS right on the bare metal, writing Nix files from scratch, without proper knowledge, was not going to be easy. Therefore, I first installed NixOS on a virtual machine using their installation media to see its configuration.

NixOS Installer showing summary of the installation procedure

Installing NixOS using their graphical installer was easy. When the system was ready, I went to /etc/nixos and read two Nix files configuration.nix and hardware-configuration.nix.

{ config, pkgs, ... }:

{
  imports = [ ./hardware-configuration.nix ];

  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  networking.hostName = "nixos";
  networking.networkmanager.enable = true;

  time.timeZone = "Asia/Seoul";
  i18n.defaultLocale = "en_GB.UTF-8";
  i18n.extraLocaleSettings = {
    LC_ADDRESS = "en_GB.UTF-8";
    LC_IDENTIFICATION = "en_GB.UTF-8";
    LC_MEASUREMENT = "en_GB.UTF-8";
    LC_MONETARY = "en_GB.UTF-8";
    LC_NAME = "en_GB.UTF-8";
    LC_NUMERIC = "en_GB.UTF-8";
    LC_PAPER = "en_GB.UTF-8";
    LC_TELEPHONE = "en_GB.UTF-8";
    LC_TIME = "en_GB.UTF-8";
  };

  services.xserver.enable = true;

  services.xserver.displayManager.gdm.enable = true;
  services.xserver.desktopManager.gnome.enable = true;

  services.xserver.xkb = {
    layout = "us";
    variant = "";
  };

  services.printing.enable = true;

  hardware.pulseaudio.enable = false;
  security.rtkit.enable = true;
  services.pipewire = {
    enable = true;
    alsa.enable = true;
    alsa.support32Bit = true;
    pulse.enable = true;
  };

  users.users.lyuk98 = {
    isNormalUser = true;
    description = "lyuk98";
    extraGroups = [ "networkmanager" "wheel" ];
    packages = with pkgs; [ ];
  };

  programs.firefox.enable = true;

  nixpkgs.config.allowUnfree = true;
  environment.systemPackages = with pkgs; [ ];
  system.stateVersion = "24.11";
}
{ config, lib, pkgs, modulesPath, ... }:

{
  imports = [ (modulesPath + "/profiles/qemu-guest.nix") ];

  boot.initrd.availableKernelModules =
    [ "ahci" "xhci_pci" "virtio_pci" "sr_mod" "virtio_blk" ];
  boot.initrd.kernelModules = [ ];
  boot.kernelModules = [ "kvm-intel" ];
  boot.extraModulePackages = [ ];

  fileSystems."/" = {
    device = "/dev/disk/by-uuid/23c86168-05fe-4e13-90b4-0e12d24b7ace";
    fsType = "ext4";
  };

  fileSystems."/boot" = {
    device = "/dev/disk/by-uuid/FF2C-9E3B";
    fsType = "vfat";
    options = [ "fmask=0077" "dmask=0077" ];
  };

  swapDevices = [ ];

  networking.useDHCP = lib.mkDefault true;

  nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

I later referred to these configuration files several times.

Writing Nix files

Studying basics of the Nix language could have been a better way to start for someone else, but I first tried to see how it was used in practice. I came across a repository with templates, together with the author's personal configurations, which became a great starting point.

A month later, the device was finally able to boot from my configurations. I have learned a lot, and the following are some of them that I consider important.

Partitioning

My aim during the switch to NixOS was to keep /home intact. To do so, I decided to create subvolumes inside the existing Btrfs partition without reformatting.

The idea of impermanence came across while I was searching for possible setups. Even though I did not use the module at the end, partly due to my unwillingness to declare specific paths to preserve, I still applied the idea of impermanent system by mounting / to tmpfs.

Such a setup is possible because NixOS only needs /boot and /nix in order to boot, all other system files are simply links to files in /nix. /boot and /nix still need to be stored on a hard drive or SSD.

The following is the file system configuration used for the current system state. I decided to preserve /var across reboots to keep some persistent data.

# File systems to be mounted
fileSystems = {
  "/boot" = {
    device = "/dev/disk/by-uuid/090C-E895";
    fsType = "vfat";
  };

  "/" = {
    device = "none";
    fsType = "tmpfs";
    options = [
      "defaults"
      "size=20%"
      "mode=755"
    ];
  };

  "/home" = {
    device = "/dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869";
    fsType = "btrfs";
    options = [
      "defaults"
      "subvol=home"
      "compress=zstd"
    ];
  };

  "/nix" = {
    device = "/dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869";
    fsType = "btrfs";
    options = [
      "defaults"
      "subvol=nix"
      "compress=zstd"
    ];
  };

  "/var" = {
    device = "/dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869";
    fsType = "btrfs";
    options = [
      "defaults"
      "subvol=var"
      "compress=zstd"
    ];
  };
};

SOPS

My goal was to define as many parts of the system as possible. I could set sensitive information like passwords as well, but I did not think storing them in plaintext is a good idea. The configuration I referred to have used sops-nix to encrypt them, so I also decided to do so.

There are two methods of encryption: GPG and age. I ended up not using GPG, but I nevertheless prepared both methods.

I first retrieved the fingerprint of my personal GPG key.

[lyuk98@framework ~]$ echo $(gpg --fingerprint D60E735C | head -2 | tail -1 | tr -d '[:space:]')
270CB11B1189E79A17DCB7831BDAFDC5D60E735C

The age key was created as well.

[lyuk98@framework nixos-config]$ mkdir -p ~/.config/sops/age
[lyuk98@framework nixos-config]$ nix-shell -p age --run "age-keygen -o ~/.config/sops/age/keys.txt"
Public key: age14edzmqc4r07gp9lkj8z4gchccs373s8lcdrw69d6964tallpuuzqausgmk

Public keys were then placed into .sops.yaml at the root of the repository.

keys:
  # Users
  - &users:
    - &lyuk98 270CB11B1189E79A17DCB7831BDAFDC5D60E735C
  # Hosts
  - &hosts:
    - &framework age14edzmqc4r07gp9lkj8z4gchccs373s8lcdrw69d6964tallpuuzqausgmk

creation_rules:
  # Secrets specific to host "framework"
  - path_regex: hosts/framework/secrets.ya?ml$
    key_groups:
    - age:
      - *framework
      pgp:
      - *lyuk98

  # Secrets for user "lyuk98" to be used across hosts
  - path_regex: hosts/common/users/lyuk98/secrets.ya?ml$
    key_groups:
    - age:
      - *framework
      pgp:
      - *lyuk98

It was now time to create secrets. I started with password of my user. With mkpasswd, a hashed password was created.

(The hash I actually used is different; the result below is the hash of the password password.)

[lyuk98@framework nixos-config]$ mkpasswd
Password: 
$y$j9T$O5BvDo7mdLNITy4otZk3W0$eFEFPuEdKJowvw7MfzfbIflbHV5vSreWXCwPaH34Td5

The hashed password was then written to secrets.yaml using sops.

[lyuk98@framework nixos-config]$ nix-shell -p sops --run "sops hosts/common/users/lyuk98/secrets.yaml"

Issuing sops opened a text editor, where I put the following:

lyuk98-password: $y$j9T$O5BvDo7mdLNITy4otZk3W0$eFEFPuEdKJowvw7MfzfbIflbHV5vSreWXCwPaH34Td5

The resultant file was automatically encrypted with some apparent metadata attached.

lyuk98-password: ENC[AES256_GCM,data:HqFR/AgXhTkcZwLdcn+vM8QIDvbKa8oUDdBRUsbmUcAlWTfhd8UdnTsvUEtWQkU4j3PG27Yo9GVoCRulDkbyKfgyggInWfhJ1Q==,iv:jmq7cw5SB1TokApgdY6tIqdXVrIi7zb6ur62m9BtKF4=,tag:S3narIG/Fm4HfXgItb4VXQ==,type:str]
sops:
    kms: []
    gcp_kms: []
    azure_kv: []
    hc_vault: []
    age:
        - recipient: age14edzmqc4r07gp9lkj8z4gchccs373s8lcdrw69d6964tallpuuzqausgmk
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMN0tCWmxyb09aaWhBaGFo
            NDcvUTU4ekJSQlQ1WFVmOUcvQXJ6OTdHVVFrClEvOWp5SmR6R0pEc1V6U3FLaktN
            djFaSmlVY0kzUFhoTnBvajlSNEk2czAKLS0tIDdQS2FzVHFxRkxXT1FVQXNlQ1Uw
            L0dRWFJwamZJU2NwTlYxU2RjVFBCN0UKIvCJlOGnDpbRCAKau7e+ijfc9NgRA+uF
            3vKSsSSOeJih4uccoKT2lfPgP4T4vzcJnyD9vz7S7Nmpurvedkla+g==
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2025-05-12T08:09:45Z"
    mac: ENC[AES256_GCM,data:v+b37D4dEVDr78/E4wMWAM2pH2D+m55hTAsXFmUt2fRAmeHzrVDi4PWItG4Mm3AHJ9KSgO2Aj2WE5IFg0OSEI4ehJJGL/hp1L61YGwXOXrZ9+iiBda/fiQwAVIuEojHTkxxJcyRR1blUoMScBqSUOg3SihU579oimC1J06aiH+I=,iv:D2LjheIGKK2F81I8mGPf74PpFBZYVCAnNP8W6Y70laI=,tag:hDau1W8H8fKVWH3D0qg2uw==,type:str]
    pgp:
        - created_at: "2025-05-12T08:09:20Z"
          enc: |-
            -----BEGIN PGP MESSAGE-----

            hF4D4TeHAuIWmuESAQdASOhjLuuIuejuTAiW0OtSqTCmk0w1MvWupjo9+6Pa3UUw
            YIzRTwn4KT/3cXGnIJ3DQsvPMuaMkzIgb1AFx7jhonYdABS0mDOn5c4X+7d7c1uX
            0lEBTdohXDwViilR0eaUmabxjPbVP7GTTzxVYqVAcoWHNrD4X43nBYHpZvCE8PVZ
            J9ByWvrHRdDKvP07sX8phKPe+aqBMdE5Bs6jGZS3p4PgsTg=
            =aJCD
            -----END PGP MESSAGE-----
          fp: 270CB11B1189E79A17DCB7831BDAFDC5D60E735C
    unencrypted_suffix: _unencrypted
    version: 3.9.4

I added another secret in a different directory to include machine-id.

[lyuk98@framework nixos-config]$ nix-shell -p sops --run "sops hosts/framework/secrets.yaml"
machine-id: ENC[AES256_GCM,data:KjgyeUiMflGF0u2uvCaKfvw7bKpNH29tVRxaOzt9tfY=,iv:gpc9+uwpNtuNU4SG7S5XCDRU4oWBrqRZdXr7fhDmVKA=,tag:QzNtVBmFV73w/rldwLNP2Q==,type:str]
sops:
    kms: []
    gcp_kms: []
    azure_kv: []
    hc_vault: []
    age:
        - recipient: age14edzmqc4r07gp9lkj8z4gchccs373s8lcdrw69d6964tallpuuzqausgmk
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxTlBGZTRkU280QzdTRDda
            NnhmVksxajZkcTdjVGM4RFkyR3VOYklGcFJvCk9pZ0xubjFOdzg4SFhUMEdJck42
            UXZWbGpKM1E3MXh5ZFRCUkZFNG9pMncKLS0tIHVPRnZ4Um9DUVlpZjlFa1laZ1dE
            R01HbWtQbWFLUml2YW5PTWF4bHBGUHcKuAKSzrOaoI0E8gtryfjQDtTka3IEB15M
            8CwBCR/iaXoLKiPhcYSbTMQBiUB50Ah52FcHJ2KLyjNlGjmU+psunQ==
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2025-05-12T08:18:04Z"
    mac: ENC[AES256_GCM,data:A8laj76aYG+K6rP4KsVssJsdS2y0/5j1Dtj20ORr0Hxdh9JHAikFF6mrXEGZ2QBDE8qhXKoblpfE8yEBr1K9UPmrBbHEfEVyc4mXOSVa6oqIBe4LmEM7ckU7AfRmj1ph9/f16A7os1Vh9HPwaGhFqan9lnCj5S7xOzS4jiAb3LY=,iv:iAhuPqNq9hwfs4Btj16S/WpWJ0G4QkUkKTYUPmOdLag=,tag:SLWAZ2MpagHPs4+Q25pYcA==,type:str]
    pgp:
        - created_at: "2025-05-12T08:17:34Z"
          enc: |-
            -----BEGIN PGP MESSAGE-----

            hF4D4TeHAuIWmuESAQdAtgfUKZnJXtiu2NZILkPtRpzo0gxjF4gEMGEi5zDDGmcw
            dXmXFoA0mvh3pU7BkIhoA40faJ/klhLw3pu1GLxOgix+So73SZuE8thWv+oYslwG
            0lEBKTr+3DokW1Tb5Jq3iKcpWeT47t2obBxBHNkfO5jaARPHTR8+8pwZrODOaT7e
            3Se3PP7LmI4myOiizNpn0gjkQyqc6GsAQzNCFCG5dXETWvs=
            =5exl
            -----END PGP MESSAGE-----
          fp: 270CB11B1189E79A17DCB7831BDAFDC5D60E735C
    unencrypted_suffix: _unencrypted
    version: 3.9.4

The secrets are there, but how are they going to be decrypted? It took a while before I could finally understand that they are decrypted upon every activation.

Thinking the decryption would take place every boot, I needed a place to store the keys. Since /var will be preserved, I put my age key there.

[lyuk98@framework ~]$ sudo mkdir -p /var/lib/sops-nix
[lyuk98@framework ~]$ sudo cp ~/.config/sops/age/keys.txt /var/lib/sops-nix/keys.txt
[lyuk98@framework ~]$ sudo chmod 0700 /var/lib/sops-nix

An appropriate configuration was also made to look for an age key in that location.

{ inputs, lib, ... }:
{
  imports = [ inputs.sops-nix.nixosModules.sops ];

  # Specify path of the age key to decrypt secrets with
  # This file needs to be present to decrypt secrets during activation
  sops.age.keyFile = lib.mkDefault "/var/lib/sops-nix/keys.txt";
}

Declarative Wi-Fi configurations for NetworkManager

Other configurations I came across used systemd-networkd or wpa_supplicant for networking, but I wanted to continue using NetworkManager, especially due to its integration with GNOME. It apparently became possible to declare networks using configuration networking.networkmanager.ensureProfiles.profiles, but a problem with it was that I had no apparent way to hide SSID.

Until I find a better way, I decided to simply encrypt entire .nmconnection files. To prevent SSIDs from being visible, I renamed each connection with its UUID property.

[lyuk98@framework ~]$ sudo cp --recursive /etc/NetworkManager/system-connections .
[lyuk98@framework ~]$ sudo chown --recursive lyuk98 system-connections/
[lyuk98@framework ~]$ cd system-connections/
[lyuk98@framework system-connections]$ for connection in *.nmconnection; do mv "$connection" $(grep --only-matching --perl-regexp '(?<=uuid=).*' "$connection").nmconnection; done

.sops.yaml was then updated to allow encryption of those files.

creation_rules:
  # ...

  # NetworkManager connections
  - path_regex: hosts/common/optional/system-connections/.+\.nmconnection$
    key_groups:
    - age:
      - *framework
      pgp:
      - *lyuk98

After definition of a creation rule, the files were placed and encrypted.

[lyuk98@framework nixos-config]$ cp ~/system-connections/* hosts/common/optional/system-connections/
[lyuk98@framework nixos-config]$ for connection in hosts/common/optional/system-connections/*.nmconnection; do nix-shell -p sops --run "sops encrypt --in-place $connection"; done

Since system-connections directory was to be only accessible by root, I manually ensured its permission with systemd-tmpfiles. I later reverted the change after realising that it is done automatically, however.

Secure Boot with Lanzaboote

Despite its criticisms, I wanted to continue using the system with Secure Boot enabled. I used Lanzaboote, as most guides for NixOS mentioned it.

The quick start guide for Lanzaboote used sbctl for generation of Secure Boot keys. I followed both the aforementioned guide and ArchWiki.

To start, I first put the device in Setup Mode. I did not bother backing them up since I intended to replace my existing Secure Boot keys.

The existing pacman hook for issuing kernel-install was removed. It was convenient, but it is no longer needed.

[lyuk98@framework ~]$ sudo pacman -Rns pacman-hook-kernel-install

I then installed sbctl and made sure that the device is in Setup Mode.

[lyuk98@framework ~]$ sudo pacman -S sbctl
[lyuk98@framework ~]$ sbctl status
Installed:  ✗ sbctl is not installed
Setup Mode: ✗ Enabled
Secure Boot:    ✗ Disabled
Vendor Keys:    none

New set of keys were created and were automatically enrolled, together with the ones from Microsoft and the OEM (which is Framework in this case).

[lyuk98@framework ~]$ sudo sbctl create-keys
Created Owner UUID 5f0d4030-4870-4fce-ab2f-e25c34e60ebe
Creating secure boot keys...✓ 
Secure boot keys created!
[lyuk98@framework ~]$ sudo sbctl enroll-keys --microsoft --firmware-builtin
Enrolling keys to EFI variables...
With vendor keys from microsoft...
With vendor certificates built into the firmware...✓ 
Enrolled keys to the EFI variables!

It could now be seen that sbctl was ready.

[lyuk98@framework ~]$ sbctl status
Installed:  ✓ sbctl is installed
Owner GUID: 5f0d4030-4870-4fce-ab2f-e25c34e60ebe
Setup Mode: ✓ Disabled
Secure Boot:    ✗ Disabled
Vendor Keys:    microsoft builtin-db builtin-KEK

Building from the configuration

When the code was ready enough for me, I tried to see what would happen if I were to build the system. I did not want to pull the trigger just yet, so I did dry-build.

[lyuk98@framework nixos-config]$ nix-shell -p nixos-rebuild --run "nixos-rebuild dry-build --flake ."

Doing so generated a lot of errors from my mistakes. After correcting them, however, something like the following could be seen:

[lyuk98@framework nixos-config]$ nix-shell -p nixos-rebuild --run "nixos-rebuild dry-build --flake ." 2>&1 | head -10
building the system configuration...
warning: Git tree '/home/lyuk98/nixos-config' is dirty
these 407 derivations will be built:
  /nix/store/01xk6zlzyv4kmkn3jfyfzvm73i3j11fz-nameservers.drv
  /nix/store/029p5zmbqykcb8w0hm7yv0hplmhdaj1z-evolution-with-plugins.drv
  /nix/store/03jpx2zdnkny8ja8sy9ikfx3ikkqzjqd-initrd-release.drv
  /nix/store/06j0rkhhw7qia6knms2wbfz510iy5h71-nixos-tmpfiles.d.drv
  /nix/store/wl4nlyaaz21hd28kixasixrf99gqy9i8-locales-setup-hook.sh.drv
  /nix/store/a534j021idk040xx503qpaa7q0c6x95w-glibc-locales-2.40-66.drv
  /nix/store/w8jrwsqi64x5knlp4lixs8kbvs2m2gs1-unit-script-nixos-activation-start.drv

It seemed the building process will probably succeed. It was now time to mess with the system.

Installing NixOS

Activating a new home environment with Home Manager

Together with NixOS configurations, I have also written Home Manager configurations to make a not-yet-completely-declarative home environment. Before switching to the new OS, I first switched to the new home.

[lyuk98@framework nixos-config]$ git add .
[lyuk98@framework nixos-config]$ nix-shell -p home-manager --run "home-manager switch --flake ."

The process was unexpectedly painless; I was already using Home Manager in Arch Linux.

Activating a new OS

I once again used the NixOS installation media to boot into the live environment. Fedora Media Writer was used to write to my portable USB storage.

Fedora Media Writer showing a successful result of writing the installation ISO image

Even though sbctl status previously said Secure Boot was disabled, it was in fact not the case. It prevented the live installer from booting up, so I went to firmware setup and temporarily disabled the security measure.

With the live environment up, I closed the installer window and issued a few commands to open my encrypted storage, which was then mounted to /mnt.

[nixos@nixos:~]$ sudo cryptsetup open /dev/disk/by-uuid/95582132-bd92-4355-8afc-f17636c73869 luks-95582132-bd92-4355-8afc-f17636c73869 --type luks2
[nixos@nixos:~]$ sudo mount /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869 /mnt
[nixos@nixos:~]$ cd /mnt/

I then renamed the existing var to var2. Since the data is preserved, I could still revert to Arch Linux if something was to go wrong.

[nixos@nixos:/mnt]$ sudo mv var var2

New subvolumes for the new OS were created. I disabled copy-on-write for the whole var this time.

[nixos@nixos:/mnt]$ sudo btrfs subvolume create nix
[nixos@nixos:/mnt]$ sudo btrfs subvolume create var
[nixos@nixos:/mnt]$ sudo chattr +C var

While I was at it, I also created a new swap file.

[nixos@nixos:/mnt]$ sudo mkdir -p var/swap
[nixos@nixos:/mnt]$ sudo btrfs filesystem mkswapfile --size 64g --uuid clear var/swap/swapfile
[nixos@nixos:/mnt]$ sudo chmod 0700 var/swap

NixOS uses dd and mkswap to create a new swap file if existing one's size is different from the configuration. I had a look into how the size was checked and applied the calculation to the one I have just created.

[nixos@nixos:/mnt]$ echo $(( $(sudo stat -c "%s" var/swap/swapfile 2>/dev/null || echo 0) / 1024 / 1024 ))
65536

hardware-configuration.nix was then updated to reflect the size of the new file.

# Use swap file
swapDevices = [{
  device = "/var/swap/swapfile";

  # Set swap file size in megabytes
  size = 65536;

  # Follow default discard policy by swapon(8)
  discardPolicy = "both";
}];

I was almost starting afresh, but I still wanted to preserve some data.

  • I kept /var/log to keep system logs from the previous system (just in case I want to look at it for no reason).
  • I kept /var/lib/bluetooth to preserve paired Bluetooth devices.
  • I kept /var/lib/sbctl so that Lanzaboote can still sign EFI binaries using Secure Boot keys inside.
  • I kept /var/lib/sops-nix to be able to decrypt secrets upon activation.
  • I kept /var/lib/tailscale to preserve state data related to Tailscale.
  • I kept /var/lib/vnstat to preserve previous network usage statistics.
[nixos@nixos:/mnt]$ sudo mkdir -p var/lib
[nixos@nixos:/mnt]$ sudo cp --archive --reflink=never var2/log var/
[nixos@nixos:/mnt]$ sudo cp --archive --reflink=never @/var/lib/bluetooth var/lib/
[nixos@nixos:/mnt]$ sudo cp --archive --reflink=never @/var/lib/sbctl var/lib/
[nixos@nixos:/mnt]$ sudo cp --archive --reflink=never @/var/lib/sops-nix var/lib/
[nixos@nixos:/mnt]$ sudo cp --archive --reflink=never @/var/lib/tailscale var/lib/
[nixos@nixos:/mnt]$ sudo cp --archive --reflink=never @/var/lib/vnstat var/lib/

With the actual installation ready, I umounted the device and mounted it again using different subvolumes.

[nixos@nixos:~]$ sudo umount /mnt
[nixos@nixos:~]$ sudo mount --mkdir /dev/disk/by-uuid/090C-E895 /mnt/boot
[nixos@nixos:~]$ sudo mount --mkdir -o defaults,compress=zstd,subvol=home /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869 /mnt/home
[nixos@nixos:~]$ sudo mount --mkdir -o defaults,compress=zstd,subvol=nix /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869 /mnt/nix
[nixos@nixos:~]$ sudo mount --mkdir -o defaults,compress=zstd,subvol=var /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869 /mnt/var

It was finally time to pull the trigger. The new system was then ready.

[nixos@nixos:/mnt/home/lyuk98/nixos-config]$ git add .
[nixos@nixos:/mnt/home/lyuk98/nixos-config]$ sudo nixos-install --flake .#framework --no-root-password

Post-installation configurations

Booting into NixOS was successful, even though forgetting to enable firmware left me struggling without internet connection and graphics for a while. I re-enabled Secure Boot and automatic decryption with TPM afterwards.

[lyuk98@framework ~]$ sudo systemd-cryptenroll --wipe-slot=tpm2 /dev/disk/by-uuid/95582132-bd92-4355-8afc-f17636c73869
[lyuk98@framework ~]$ sudo systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 /dev/disk/by-uuid/95582132-bd92-4355-8afc-f17636c73869

With my repository live and public, I could now build and switch using the new configuration without having them present on my device.

[lyuk98@framework:~]$ sudo nixos-rebuild boot --flake github:lyuk98/nixos-config
[lyuk98@framework:~]$ home-manager switch --flake github:lyuk98/nixos-config

The clean-up

With NixOS working well, I no longer needed to keep the existing Arch Linux installation. I first removed the existing kernel images.

[lyuk98@framework:~]$ sudo rm /boot/EFI/Linux/*-6.12.28-1-lts.efi
[lyuk98@framework:~]$ sudo rm /boot/EFI/Linux/*-6.14.5-arch1-1.efi

I then deleted remaining data from the previous system. There was no going back any more.

[lyuk98@framework:~]$ sudo mount --mkdir /dev/mapper/luks-95582132-bd92-4355-8afc-f17636c73869 /mnt
[lyuk98@framework:~]$ cd /mnt/
[lyuk98@framework:/mnt]$ sudo btrfs subvolume delete opt
[lyuk98@framework:/mnt]$ sudo btrfs subvolume delete root
[lyuk98@framework:/mnt]$ sudo btrfs subvolume delete srv
[lyuk98@framework:/mnt]$ sudo btrfs subvolume delete usr/local
[lyuk98@framework:/mnt]$ sudo rmdir usr
[lyuk98@framework:/mnt]$ sudo btrfs subvolume delete --recursive @
[lyuk98@framework:/mnt]$ sudo btrfs subvolume delete var2/lib/libvirt/images
[lyuk98@framework:/mnt]$ sudo btrfs subvolume delete var2/lib/*
[lyuk98@framework:/mnt]$ sudo btrfs subvolume delete var2/*
[lyuk98@framework:/mnt]$ sudo rm --recursive var2
[lyuk98@framework:/mnt]$ cd
[lyuk98@framework:~]$ sudo umount /mnt

What now?

Running screenFetch on the new system

I have a lot to do, actually.

There are still many parts (mostly in Home Manager configurations) that I could not declaratively manage just yet. On top of that, even though I continued using Flatpak, it does not seem to fit well with the declarative nature of this system. Considering those concerns, the next step would be switching from Flatpak to Nixpkgs.

Switching to NixOS on my Raspberry Pi, which currently hosts PeerTube, is also something I plan to do in the future. It could be an opportunity to apply something new, such as declarative partitioning with disko.

The repository will continue to be maintained as long as I keep using NixOS. If I know more about Nix than I do now, it will probably look much more different than how it does at the time of writing.

More from 이영욱
All posts