Declarative disk encryption and impermanent filesystem with disko
Declarative disk partitioning, LUKS2 encryption, and Btrfs impermanent filesystem setup on NixOS using Disko.
Three years on NixOS and I still couldn’t explain how it boots. So I’m wiping the machine to bare firmware and rebuilding it declaratively, documenting each step for reference. One principle at a time.
Overview
This is the first article in the series. It covers the foundation: partitioning, encryption, and an impermanent filesystem that forgets everything I don’t explicitly declare. Later articles build upward from here - flakes, home-manager, FIDO2, agenix.
I’m using Disko, a declarative disk partitioning tool for NixOS. No more manual fdisk, cryptsetup, mkfs chains. I declare my desired layout in a Nix file. Disko executes atomically. Reproducible. Self-documenting.
Disk layout
The disk stores everything that survives shutdown: OS, files, all of it. Raw bytes only - no inherent notion of “files” or “folders”. We lay down structure in three steps:
- partitioning - carving the disk into regions,
- formatting - writing a filesystem into a region (this creates the notion of files),
- mounting - attaching that filesystem to a path.
Disko does all three from one file.
My target layout for this machine (MacBook Pro 2015):
Partitions
├── ESP (EFI System Partition) → /boot
└── LUKS2 container
└── Btrfs filesystem
├── subvolume @root → /
├── subvolume @persist → /persist
├── subvolume @nix → /nix
└── subvolume @swap → /swap (swapfile)
Find your disk’s stable ID with ls -l /dev/disk/by-id/, then reference it in the config:
{ disks ? [ "/dev/disk/by-id/ata-APPLE_SSD_SM0128G_S2Z6NY1HB66610" ], ... }: {
disko.devices = {
disk.main = {
type = "disk";
device = builtins.elemAt disks 0;
# content = {...}
Configuration walkthrough
ESP
The EFI System Partition holds the bootloader. Firmware runs it before the OS starts. FAT32 format, no encryption - UEFI can’t unlock anything yet. The umask=0077 option restricts boot file access to root only.
ESP configuration
{
content = {
type = "gpt";
partitions = {
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
# luks = { ... }
}
LUKS
LUKS2 encrypts everything except /boot. The entire root filesystem stays protected at rest. I’m enabling allowDiscards so TRIM commands pass through to the SSD - keeps performance and longevity intact. The fido2-device=auto option lets me unlock with a hardware key instead of typing a passphrase (I’ll cover that in a future article).
LUKS configuration
{
luks = {
size = "100%";
content = {
type = "luks";
name = "cryptroot";
settings = {
allowDiscards = true;
crypttabExtraOpts = ["fido2-device=auto" "token-timeout=10"];
};
content = {
type = "btrfs";
extraArgs = [ "-L" "nixos" "-f" ];
extraOpenArgs = ["--perf-no_read_workqueue" "--perf-no_write_workqueue"];
# subvolumes = {...}
};
};
};
}
Subvolumes
Btrfs subvolumes let us apply different mount options and snapshot policies to different parts of the filesystem, even though they share the same underlying storage pool:
@root- the entire root filesystem, wiped on every boot for impermanence.@persist- files that survive reboots (SSH keys, logs, machine-id).@nix- the Nix store, kept separate so it’s never wiped and snapshots don’t bloat.@swap- holds the swapfile, isolated because swap shouldn’t be compressed or snapshotted.
Why separate @nix and @persist? Because impermanence is useless if the Nix store vanishes on reboot, and I’m not re-entering SSH keys every morning.
Subvolumes configuration
{
subvolumes = {
"@root" = {
mountpoint = "/";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@persist" = {
mountpoint = "/persist";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@nix" = {
mountpoint = "/nix";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@swap" = {
mountpoint = "/swap";
mountOptions = [ "nodatacow" "noatime" ];
swap.swapfile.size = "8G";
};
};
}
postCreateHook
This hook snapshots the empty @root subvolume immediately after Disko runs. Later, during boot, the initrd rolls back to this blank state. Every boot starts fresh. Without this blank snapshot, the first boot would have nothing to roll back to.
postCreateHook configuration
{
postCreateHook = ''
MNTPOINT=$(mktemp -d)
mount -o subvol=/ /dev/mapper/cryptroot "$MNTPOINT"
trap 'umount "$MNTPOINT"; rmdir "$MNTPOINT"' EXIT
btrfs subvolume snapshot -r "$MNTPOINT/@root" "$MNTPOINT/@root-blank"
'';
}
Applying the layout
Boot into a NixOS installer (live USB), then run:
# Install disko
nix-shell -p disko
# Apply the config (WARNING: destroys all data on the disk)
sudo disko --mode disko /path/to/disko-config.nix
# Verify mounts
mount | grep /mnt
After disko finishes, I’ll have /mnt ready for nixos-install. All partitions created, encrypted, formatted, and mounted according to the config.
Reference
Full disko-config
# disko-config.nix
{
disko.devices = {
disk.main = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME"; # ls -l /dev/disk/by-id/
content = {
type = "gpt";
partitions = {
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "cryptroot";
settings = {
allowDiscards = true;
crypttabExtraOpts = ["fido2-device=auto" "token-timeout=10"];
};
content = {
type = "btrfs";
extraArgs = [ "-L" "nixos" "-f" ];
extraOpenArgs = ["--perf-no_read_workqueue" "--perf-no_write_workqueue"];
postCreateHook = ''
MNTPOINT=$(mktemp -d)
mount -o subvol=/ /dev/mapper/cryptroot "$MNTPOINT"
trap 'umount "$MNTPOINT"; rmdir "$MNTPOINT"' EXIT
btrfs subvolume snapshot -r "$MNTPOINT/@root" "$MNTPOINT/@root-blank"
'';
subvolumes = {
"@root" = {
mountpoint = "/";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@persist" = {
mountpoint = "/persist";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@nix" = {
mountpoint = "/nix";
mountOptions = [ "compress=zstd" "noatime" ];
};
"@swap" = {
mountpoint = "/swap";
mountOptions = [ "nodatacow" "noatime" ];
swap.swapfile.size = "8G";
};
};
};
};
};
};
};
};
};
}