Install NixOS with root on ZFS

2026-08-30 - Build a UEFI NixOS disk image with an encrypted ZFS root filesystem and remote initrd unlocking
Tags: Nixos ZFS

Introduction

I had some fun migrating my NixOS servers to root on ZFS a while ago but did not take the time to write about it. This was mostly for fun and consistency with my other setups because thanks to Nix, the operating system itself does not benefit that much from being on ZFS. You can (almost) always revert to a previous working state right from the boot loader entries, but ZFS still adds its usual benefits for all the other parts: snapshots you can send remotely, zvols, native encryption, native compression.

Bootstrap the NixOS installer on a local virtual machine

Create the RAW hard drive image for your virtual machine. Using the minimal necessary size will speed up later image transfer if it needs uploading to some VPS or cloud provider. At the time of this writing, a minimal system seems to fit in about 4GB, but feel free to use a bigger image if you do not intend to transfer it later.

qemu-img create -f raw nixos.raw 4G

Download the installer image from an official mirror then start up the installer in the virtual machine.

I start a UEFI virtual machine with something like:

qemu-system-x86_64 \
    -bios /usr/share/edk2/OvmfX64/OVMF_CODE.fd \
    -drive if=none,id=disk,file=$PWD/nixos.raw,format=raw,cache=writeback \
    -cdrom $HOME/Downloads/nixos-minimal-26.05.6200.8623c4c20aa4-x86_64-linux.iso \
    -boot d -machine type=q35,accel=kvm \
    -cpu host -smp 2 -m 4096 \
    -nic user,model=virtio-net-pci,hostfwd=tcp::10022-:22,hostfwd=tcp::10023-:2222 \
    -device virtio-blk-pci,drive=disk \
    -device virtio-serial-pci \
    -device virtserialport,chardev=spicechannel0,name=com.redhat.spice.0 \
    -vga qxl -spice port=5902,addr=127.0.0.1,disable-ticketing=on \
    -chardev spicevmc,id=spicechannel0,name=vdagent

If you are short on memory, tune down the -m 4096 flag that configures the amount allocated to the virtual machine.

This virtual machine starts up with a SPICE display device, which I like better than VNC, and can be accessed with a SPICE client like spicy. If you would rather use VNC instead, replace the lines mentioning SPICE with the following to start a VNC server on port 5900:

    -display vnc 127.0.0.1:0 \
    -vga none -device virtio-vga,edid=on,xres=2560,yres=1440

Prepare the disk

Grab a shell using the spice or VNC connection, then set a password so that we can SSH in instead:

sudo passwd

Then if you did not change the host forwarded tcp port in the qemu command above, you will be able to SSH in with:

ssh -o UserKnownHostsFile=/dev/null -p10022 root@localhost

Partition the disk with:

sgdisk -n1:0:+1G -t1:EF00 -c1:"EFI system partition" /dev/vda
sgdisk -n2:0:0 -t2:8300 -c2:ZROOT /dev/vda

I use the partition UUIDs for everything. They can be listed using the blkid command. Format your partitions with:

mkfs.fat -F 32 -n efi-boot /dev/disk/by-partuuid/cae9469a-c8ee-4c85-b86b-990dd6b7fb5b
zpool create \
      -O acltype=posixacl \
      -O atime=off \
      -O compression=zstd-fast \
      -O encryption=aes-256-gcm \
      -O keyformat=passphrase \
      -O keylocation=prompt \
      -O mountpoint=none \
      -O relatime=on \
      -O xattr=sa \
      -o ashift=12 \
      -o autoexpand=on \
      -o autotrim=on \
      -m none zroot /dev/disk/by-partuuid/80d4e8b7-c8af-41a9-b1c0-969a30d484cb

I create the following ZFS datasets in two groups: system which I do not intend to snapshot, and data which I do. All mountpoints are marked as legacy to let NixOS handle when to mount each filesystem during the boot process:

zfs create zroot/system
zfs create zroot/system/nixos
zfs create -o mountpoint=legacy zroot/system/nix
zfs create -o mountpoint=legacy zroot/system/tmp
zfs create -o mountpoint=legacy zroot/system/usr
zfs create -o mountpoint=legacy zroot/system/usr/local
zfs create -o mountpoint=legacy zroot/system/var-tmp
zfs create zroot/data
zfs create -o mountpoint=legacy zroot/data/home
zfs create -o mountpoint=legacy zroot/data/home/julien
zfs create -o mountpoint=legacy -o quota=1M zroot/data/home/root
zfs create -o mountpoint=legacy zroot/data/var
zfs create -o mountpoint=legacy zroot/data/var/log

Mount everything with:

mount -t zfs -o zfsutil zroot/system/nixos /mnt
mkdir /mnt/{boot,home,nix,root,tmp,var}
mount /dev/disk/by-partuuid/cae9469a-c8ee-4c85-b86b-990dd6b7fb5b /mnt/boot
mount -t zfs zroot/data/home /mnt/home
mount -t zfs zroot/data/home/root /mnt/root
mount -t zfs zroot/system/nix /mnt/nix
mount -t zfs zroot/system/tmp /mnt/tmp
mount -t zfs zroot/data/var /mnt/var
mkdir /mnt/home/julien /mnt/usr /mnt/var/{log,tmp}
mount -t zfs zroot/data/home/julien /mnt/home/julien
mount -t zfs zroot/system/usr /mnt/usr
mount -t zfs zroot/data/var/log /mnt/var/log
mount -t zfs zroot/system/var-tmp /mnt/var/tmp
mkdir /mnt/usr/local
mount -t zfs zroot/system/usr/local /mnt/usr/local

Install NixOS

With the disks prepared, we can generate the initial configuration with:

nixos-generate-config --root /mnt

Edit /mnt/etc/nixos/hardware-configuration.nix and fix the following entries:

fileSystems."/" = {
    device = "zroot/system/nixos";
    fsType = "zfs";
    options = [ "zfsutil" ];
};
fileSystems."/boot" = {
    device = "/dev/disk/by-partuuid/cae9469a-c8ee-4c85-b86b-990dd6b7fb5b";
    fsType = "vfat";
    options= ["umask=0077" "tz=UTC"];
};

Then in this same file, add:

boot.zfs.forceImportRoot = true;
boot.zfs.requestEncryptionCredentials = true;
boot.zfs.devNodes = "/dev/disk/by-partuuid/80d4e8b7-c8af-41a9-b1c0-969a30d484cb";

Here is the rest of my not so minimal configuration.nix as an example. It features remote ZFS unlocking via an SSH server embedded in the initrd:

{ config, lib, pkgs, ... }:

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

  boot = {
    initrd = {
      network = {
        enable = true;
        ssh = {
          enable = true;
          port = 2222;
          hostKeys = [ "/etc/secrets/initrd/ssh_host_ed25519_key" ];
          authorizedKeys = [
            "ssh-ed25519 AAAAC3szaC1lZDI1NTE5AAAAILOJV391WFRYgCVA2plFB8W8sF9LfbzXZOrxqaOrrwco julien"
          ];
        };
      };
      systemd.users.root.shell = "/bin/systemd-tty-ask-password-agent";
    };
    kernelParams = [
      "console=ttyS0"
      "console=tty0"
      "console=tty1"
      "nvme.shutdown_timeout=10"
      "libiscsi.debug_libiscsi_eh=1"
      "ip=dhcp"
    ];
    loader.systemd-boot.consoleMode = "auto";
  };
  environment.systemPackages = with pkgs; [
    bmon
    curl
    dig
    gitMinimal
    gnumake
    gptfdisk
    mosh
    mtr
    ncdu
    parted
    tcpdump
    tmux
    tree
    vim
  ];
  users.users.root = {
    hashedPassword = "$y$j9T$umxLlXmPdS0KGxSnrH9CY.$bjvADE7IdfwgO6/1ii5Sl8DeBpCRdasknq3IJEAuxyA";
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3szaC1lZDI1NTE5AAAAILOJV391WFRYgCVA2plFB8W8sF9LfbzXZOrxqaOrrwco julien"
    ];
  };
  networking = {
    firewall = {
      allowedTCPPorts = [ 22 ];
      allowedUDPPortRanges = [
        { from = 60000; to = 60100; } # mosh
      ];
      logRefusedConnections = false;
      logRefusedPackets = false;
    };
    hostId = "8425e349";  # ZFS hostId
    hostName = "vin";
    usePredictableInterfaceNames = false;
  };
  systemd.network.enable = true;
  nix = {
    extraOptions = ''
        min-free = ${toString (1024 * 1024 * 1024)}
        max-free = ${toString (2048 * 1024 * 1024)}
    '';
    gc = {
      automatic = true;
      dates = "weekly";
      options = "--delete-older-than 30d";
    };
    settings.auto-optimise-store = true;
  };
  services.openssh = {
    enable = true;
    settings = {
      KbdInteractiveAuthentication = false;
      PasswordAuthentication = false;
      PermitRootLogin = "prohibit-password";
    };
  };
  virtualisation.podman.defaultNetwork.settings = {
    ipv6_enabled = true;
    subnets = [
      { gateway = "10.88.0.1"; subnet = "10.88.0.0/16"; }
      { gateway = "fd42::1"; subnet = "fd42::/48"; }
    ];
  };

  environment = {
    etc."tmux.conf" = {
      mode = "0444";
      source = ./tmux.conf;
    };
  };

  # Use the systemd-boot EFI boot loader.
  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  time.timeZone = "Europe/Paris";

  i18n.defaultLocale = "en_US.UTF-8";

  programs.mtr.enable = true;
  programs.gnupg.agent = {
    enable = true;
    enableSSHSupport = true;
  };

  system.copySystemConfiguration = true;

  # This option defines the first version of NixOS you have installed on this particular machine,
  # and is used to maintain compatibility with application data (e.g. databases) created on older NixOS versions.
  #
  # Most users should NEVER change this value after the initial install, for any reason,
  # even if you've upgraded your system to a new NixOS release.
  #
  # This value does NOT affect the Nixpkgs version your packages and OS are pulled from,
  # so changing it will NOT upgrade your system - see https://nixos.org/manual/nixos/stable/#sec-upgrading for how
  # to actually do that.
  #
  # This value being lower than the current NixOS release does NOT mean your system is
  # out of date, out of support, or vulnerable.
  #
  # Do NOT change this value unless you have manually inspected all the changes it would make to your configuration,
  # and migrated your data accordingly.
  #
  # For more information, see `man configuration.nix` or https://nixos.org/manual/nixos/stable/options#opt-system.stateVersion .
  system.stateVersion = "26.05"; # Did you read the comment?
}

Install with:

nixos-install --no-root-password

Conclusion

ZFS on NixOS works really well, I recommend giving it a try!