Disko
20 min read
NixOS is a Linux distribution where everything is described as code, with one exception: during installation, the disk partitioning and formatting are manual steps. disko aims to correct this.
You write a declarative device tree — disks, partition tables, RAID sets, volume groups, pools and filesystems — and disko turns it into three shell scripts: destroy, format and mount. The same declaration is also fed back into NixOS as fileSystems, swapDevices and boot.initrd.luks.devices, so you never hand-write a hardware-configuration.nix filesystem section again.
Mental Model
A disko config is a tree of nested content. Every node has a type, and most nodes have a content attribute holding the next node down. The chain always ends in something mountable.
disk (/dev/nvme0n1)
└── content: gpt
├── partition ESP → content: filesystem (vfat, /boot)
└── partition luks → content: luks
└── content: lvm_pv (vg = "pool")
lvm_vg "pool"
├── lv root → content: filesystem (ext4, /)
└── lv home → content: filesystem (ext4, /home)
Two kinds of nodes exist:
• Entry points — the top-level attrsets under disko.devices. These are where disko starts walking.
• Content types — everything reachable via a content attribute.
Nodes that span multiple disks (lvm_vg, zpool, mdadm, bcachefs_filesystems) are declared as entry points and referenced by name from the partitions that feed them. That is why lvm_pv only carries vg = "pool" — the actual volume group lives at the top level.
Entry Points
| Attribute | Contains | Notes |
|---|---|---|
disko.devices.disk.<name> | A physical block device | The usual starting point |
disko.devices.lvm_vg.<name> | LVM volume group | Fed by lvm_pv content |
disko.devices.mdadm.<name> | Linux software RAID array | Fed by mdraid content |
disko.devices.zpool.<name> | ZFS pool | Fed by zfs content |
disko.devices.bcachefs_filesystems.<name> | bcachefs multi-device filesystem | Fed by bcachefs content |
disko.devices.nodev.<mountpoint> | Virtual filesystems (tmpfs, overlay, …) | No backing device |
Content Types
| Type | Valid as | Purpose |
|---|---|---|
gpt | disk / mdadm content | GPT partition table (recommended) |
table | disk content | Legacy parted table, supports msdos. Deprecated — see table-to-gpt.md upstream |
filesystem | partition / lv / zvol content | Plain mkfs.<format> |
swap | partition / lv / zvol content | mkswap, optionally random-encrypted |
luks | partition content | LUKS2 container, itself holding more content |
lvm_pv | partition content | Physical volume, joins a named lvm_vg |
mdraid | partition content | Member of a named mdadm array |
zfs | partition content | vdev member of a named zpool |
btrfs | partition content | Btrfs filesystem with subvolumes |
bcachefs | partition content | Member of a named bcachefs filesystem |
Note that gpt and table are only valid as device content (disk, mdadm), not inside a partition — a partition can't hold another partition table.
Installation
Flakes (recommended)
nix
{
inputs.disko.url = "github:nix-community/disko/latest";
inputs.disko.inputs.nixpkgs.follows = "nixpkgs";
outputs = { self, nixpkgs, disko }: {
nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
disko.nixosModules.disko
./disk-config.nix
];
};
};
}
The /latest in the URL pins to the latest tagged release rather than master.
Without flakes
nix
# npins / niv
{ imports = [ (sources.disko + "/module.nix") ]; }
# fetchTarball (pin the commit + hash in anything you care about)
{ imports = [ "${builtins.fetchTarball "https://github.com/nix-community/disko/archive/master.tar.gz"}/module.nix" ]; }
Usage
Standalone CLI
disko runs perfectly well without importing the module — handy from a rescue ISO:
shell
sudo nix --experimental-features "nix-command flakes" \
run github:nix-community/disko/latest -- \
--mode destroy,format,mount /tmp/disk-config.nix
Or against a flake, resolving .diskoConfigurations.<attr> first and falling back to the disko module of .nixosConfigurations.<attr>:
shell
sudo disko --mode destroy,format,mount --flake 'github:you/nixos-config#myhost'
Modes
| Mode | Effect |
|---|---|
destroy | Unmount filesystems and wipe the partition tables of the selected disks |
format | Create partition tables, pools, VGs, arrays and filesystems that don't exist yet |
mount | Mount the tree under the root mountpoint |
format,mount | The two above, in sequence |
destroy,format,mount | All three. Formerly --mode disko |
format is idempotent — it skips anything blkid already recognises. That makes format,mount safe to re-run when you have added a disk, and mount alone the right way to bring an existing installation back up for repair.
Flags
| Flag | Meaning |
|---|---|
-m, --mode <mode> | One of the modes above |
-f, --flake <uri> | Resolve the config from a flake |
--arg / --argstr <name> <value> | Pass arguments to nix-build, e.g. to parameterise disk names |
--root-mountpoint <path> | Where to mount the tree (default /mnt) |
--dry-run | Print the path to the generated script instead of running it |
--no-deps | Don't pull another dependency closure into an in-memory installer |
--debug | set -x |
--yes-wipe-all-disks | Skip the interactive destruction confirmation — for automation |
As a NixOS module
Importing disko.nixosModules.disko and setting disko.devices does two things at once: it generates the scripts, and it configures NixOS from the same tree.
nix
{
disko.devices.disk.main = {
device = "/dev/disk/by-id/nvme-eui.0123456789abcdef";
type = "disk";
content = {
type = "gpt";
partitions = {
ESP = {
type = "EF00";
size = "1G";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}
Build outputs made available on the system:
config.system.build.<attr> | What it is |
|---|---|
diskoScript | destroy + format + mount, with its dependency closure |
formatScript | format only |
mountScript | mount only |
destroyScript | destroy only |
*ScriptNoDeps | Same, without the closure — for tiny installers |
diskoImagesScript | Builds .raw images in a VM, outside the store |
diskoImages | Same, but inside the nix sandbox |
vmWithDisko | Interactive VM using the real disk layout |
installTest | NixOS VM test that installs and reboots into the layout |
Full install walkthrough
shell
# 1. identify the disk — prefer a stable /dev/disk/by-id path over sdX
lsblk
ls -l /dev/disk/by-id/
# 2. write /tmp/disk-config.nix (see templates below), set `device`
# 3. partition, format, mount to /mnt
sudo nix --experimental-features "nix-command flakes" \
run github:nix-community/disko/latest -- \
--mode destroy,format,mount /tmp/disk-config.nix
mount | grep /mnt # sanity check
# 4. generate the rest of the config WITHOUT filesystem stanzas
nixos-generate-config --no-filesystems --root /mnt
mv /tmp/disk-config.nix /mnt/etc/nixos/
# 5. import ./disk-config.nix + the disko module in configuration.nix, then
nixos-install
reboot
The --no-filesystems flag is the important part: disko owns fileSystems, and letting nixos-generate-config also write them produces conflicting definitions. Consider nixos-facter instead of nixos-generate-config for hardware detection.
For remote or unattended installs, nixos-anywhere drives disko over SSH for you — it kexecs into an installer, runs the disko script, and installs, all from one command.
Configuration Reference
disk
| Option | Default | Description |
|---|---|---|
device | — | Absolute path to the block device. Use /dev/disk/by-id/… |
type | — | Must be "disk" |
content | null | gpt, table, or a bare filesystem/luks/lvm_pv/… |
destroy | true | Set false to protect a disk from the destroy stage while still formatting/mounting others |
imageName | attr name | Output name when building disk images |
imageSize | "2G" | Image size passed to qemu-img create |
gpt
| Option | Default | Description |
|---|---|---|
partitions | {} | Attrset of partitions, keyed by name |
efiGptPartitionFirst | true | Place the protective 0xEE entry first in the MBR — GRUB wants this |
Per partition:
| Option | Default | Description |
|---|---|---|
type | 8300, or 8200 for swap | sgdisk short code (sgdisk -L) or a full type GUID. EF00 = ESP, EF02 = BIOS boot |
size | "0" | sgdisk size, e.g. 512M, 20G, or 100% for the rest of the disk |
start / end | "0" / +size | Explicit placement; end = "-1G" leaves a gap at the disk end |
priority | 1000 / 9001 / 100 | Creation order, lower first. 100% partitions get 9001 (last), EF02 gets 100 (first) |
label | <parent-type>-<parent>-<part> | GPT partition name, max 36 chars — longer defaults get hashed |
uuid | null | Fix the partition GUID (uuidgen -r). Distinct from the filesystem UUID |
attributes | [] | GPT attribute bits: 0 required, 2 legacy-BIOS-bootable, 60 read-only, 62 hidden, 63 no-automount |
alignment | 0, or 1 with sector units | Alignment in sectors |
hybrid | null | { mbrPartitionType; mbrBootableFlag; } — adds an entry to a hybrid MBR |
content | null | Whatever lives inside |
A partition with no content is simply created and left alone, which is exactly what an EF02 BIOS-boot partition wants.
filesystem
| Option | Default | Description |
|---|---|---|
format | — | ext4, vfat, xfs, f2fs, btrfs, … Passed to mkfs.<format> |
mountpoint | null | Where to mount. null formats but never mounts |
mountOptions | [ "defaults" ] | Written into fileSystems.<mp>.options |
extraArgs | [] | Extra mkfs arguments, e.g. [ "-L" "root" ] |
swap
| Option | Default | Description |
|---|---|---|
randomEncryption | false | Re-key the swap with a random key at every boot. Rules out hibernation |
resumeDevice | false | Set boot.resumeDevice — needed for hibernation |
priority | null | 0–32767, higher wins |
discardPolicy | null | once, pages or both — see swapon(8) |
mountOptions | [ "defaults" ] | e.g. [ "nofail" ] |
Set DISKO_SKIP_SWAP=1 in the environment to make the mount stage skip swapon — useful in low-memory or nested-VM situations.
luks
| Option | Default | Description |
|---|---|---|
name | — | Name of the /dev/mapper device |
passwordFile | null | File read only during formatting. Not baked into the system |
settings | {} | Passed straight through to boot.initrd.luks.devices.<name> — keyFile, keyFileSize, keyFileOffset, allowDiscards, fallbackToPassword, … |
askPassword | true if no key/password given | Prompt interactively while formatting |
additionalKeyFiles | [] | Extra keyslots to enroll |
initrdUnlock | true | Emit the boot.initrd.luks.devices entry |
extraFormatArgs | [] | e.g. [ "--pbkdf argon2id" ] for cryptsetup luksFormat |
extraOpenArgs | [] | e.g. [ "--timeout 10" ] for cryptsetup luksOpen |
enrollFido2 | false | Enroll a FIDO2 token via systemd-cryptenroll |
extraFido2EnrollArgs | [] | Extra systemd-cryptenroll arguments |
enrollRecovery | = enrollFido2 | Also enroll a high-entropy recovery passphrase and print it as a QR code |
passwordFile is a formatting-time secret. It must exist on the installer, not on the installed system — which is why nixos-anywhere has --disk-encryption-keys and the image builder has --pre-format-files.
lvm_pv / lvm_vg
lvm_pv has exactly one meaningful option, vg, naming the volume group it belongs to. The group itself:
lvm_vg.<name>.lvs.<lv> | Default | Description |
|---|---|---|
size | — | LVM size string. 100%FREE and friends work |
lvm_type | null | mirror, raid0, raid1, raid4, raid5, raid6, thin-pool, thinlv |
pool | null | For thinlv: the thin-pool LV it lives in |
extraArgs | [] | Extra lvcreate arguments |
content | null | Filesystem, swap, … |
Thin pools are created before regular LVs (priority 501 vs 1000), and anything sized with 100% is pushed to the back.
mdraid / mdadm
mdraid carries only name, pointing at a top-level mdadm array:
mdadm.<name> | Default | Description |
|---|---|---|
level | 1 | RAID level |
metadata | "default" | Superblock version — 1.0 places it at the end, which some bootloaders need |
extraArgs | [] | Extra mdadm --create arguments |
content | null | Usually a gpt table, since an array is a device, not a partition |
btrfs
| Option | Default | Description |
|---|---|---|
subvolumes | {} | Keyed by subvolume name, e.g. "/rootfs" |
subvolumes.<n>.mountpoint | null | Required unless a parent subvolume is already mounted |
subvolumes.<n>.mountOptions | [ "defaults" ] | e.g. [ "compress=zstd" "noatime" ] |
subvolumes.<n>.swap | {} | Swapfiles inside the subvolume: swap.myswap.size = "8G" |
mountpoint | null | Mount the top-level volume itself |
swap | {} | Swapfiles on the top-level volume |
extraArgs | [] | [ "-f" ] forces over an existing signature |
zfs / zpool
zfs content carries only pool. The pool:
zpool.<name> | Default | Description |
|---|---|---|
mode | "" (stripe) | mirror, raidz1…3, or a topology attrset with vdev / special / cache / log / spare / dedup groups |
options | {} | Pool properties, e.g. ashift = "12", cachefile = "none" |
rootFsOptions | {} | Root dataset properties: compression, acltype, xattr, encryption, … |
mountpoint | null | Mount the pool root |
datasets | {} | Nested datasets |
Datasets are either zfs_fs (options, mountOptions, mountpoint) or zfs_volume (adds size, extraArgs and a content chain, so you can put ext4 or swap on a zvol). Note the difference between the ZFS property options.mountpoint and disko's own mountpoint: the former is what ZFS records, the latter is what disko mounts during install. options.mountpoint = "legacy" plus a disko mountpoint gives you fileSystems-managed ZFS.
bcachefs
Multi-device bcachefs is split like LVM: each partition gets type = "bcachefs" with filesystem (which named filesystem it joins), an optional label for device groups (group_a.vdb2), and extraFormatArgs. The filesystem itself lives under bcachefs_filesystems.<name> with subvolumes, mountpoint, passwordFile, uuid and extraFormatArgs.
nodev
For filesystems with no backing device. The attribute name is the mountpoint:
nix
disko.devices.nodev."/tmp" = {
fsType = "tmpfs";
mountOptions = [ "size=200M" "mode=1777" ];
};
Hooks
Every node — entry point or content — accepts six escape hatches, each a block of shell:
preCreateHook, postCreateHook, preMountHook, postMountHook, preUnmountHook, postUnmountHook
The classic use is taking the blank snapshot an impermanence setup rolls back to:
nix
disko.devices.zpool.zroot.postCreateHook = ''
zfs snapshot zroot/root@blank
'';
Module Options
| Option | Default | Description |
|---|---|---|
disko.devices | {} | The device tree |
disko.rootMountPoint | "/mnt" | Where the mount script mounts the tree |
disko.enableConfig | true | Whether to also generate fileSystems / boot / swapDevices. Set false on installer images that merely carry the config |
disko.checkScripts | false | Run shellcheck over the generated scripts |
disko.memSize | 1024 | Memory (MiB) for the image-building VM |
disko.imageBuilder.* | — | Image generation: imageFormat (raw/qcow2), enableBinfmt, pkgs, kernelPackages, qemu, extraRootModules, extraPostVM, extraDependencies, copyNixStore, copyNixStoreThreads |
disko.tests.* | — | bootCommands, efi, enableOCR, extraChecks, extraConfig for system.build.installTest |
Disk Templates
Upstream ships two sets: disko-templates holds curated, best-practice layouts (single-disk-ext4, single-ext4-luks-and-double-zfs-mirror, zfs-impermanence), while the example directory holds ~40 configs that double as the regression test suite — broader coverage, but with test-only quirks (10M partitions, /tmp/secret.key) you must edit out.
shell
nix flake init --template github:nix-community/disko-templates#single-disk-ext4
The layouts below are trimmed for real use. In all of them, replace device with a stable /dev/disk/by-id/… path.
Minimal UEFI + ext4
The safe default. Boots on any UEFI machine.
nix
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME";
content = {
type = "gpt";
partitions = {
ESP = {
type = "EF00";
size = "1G";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}
umask=0077 on the ESP keeps the vfat mount from being world-readable, which otherwise trips systemd's /boot permission warnings.
BIOS + UEFI hybrid
Same disk boots on old and new firmware. Requires GRUB with efiSupport; disko sets boot.loader.grub.device for you.
nix
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME";
content = {
type = "gpt";
partitions = {
boot = {
size = "1M";
type = "EF02"; # BIOS boot partition, holds GRUB core.img
};
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}
nix
boot.loader.grub = {
enable = true;
efiSupport = true;
efiInstallAsRemovable = true;
};
Btrfs with subvolumes
A layout that leaves room for snapshots and keeps /nix out of them.
nix
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME";
content = {
type = "gpt";
partitions = {
ESP = {
type = "EF00";
size = "1G";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "btrfs";
extraArgs = [ "-f" ];
subvolumes = {
"/root" = {
mountpoint = "/";
mountOptions = [ "compress=zstd" "noatime" ];
};
"/home" = {
mountpoint = "/home";
mountOptions = [ "compress=zstd" "noatime" ];
};
"/nix" = {
mountpoint = "/nix";
mountOptions = [ "compress=zstd" "noatime" ];
};
"/persist".mountpoint = "/persist";
"/log" = {
mountpoint = "/var/log";
mountOptions = [ "compress=zstd" "noatime" ];
};
"/swap" = {
mountpoint = "/.swapvol";
swap.swapfile.size = "8G";
};
};
};
};
};
};
};
}
Subvolume names and mountpoints are independent. A nested subvolume whose parent is already mounted needs no mountpoint of its own.
LUKS + LVM
Full-disk encryption with flexible volumes underneath. Everything but /boot is encrypted.
nix
{
disko.devices = {
disk.main = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME";
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted";
settings.allowDiscards = true; # SSD TRIM; leaks used-block info
# passwordFile = "/tmp/secret.key"; # formatting only, no trailing newline
content = {
type = "lvm_pv";
vg = "pool";
};
};
};
};
};
};
lvm_vg.pool = {
type = "lvm_vg";
lvs = {
swap = {
size = "16G";
content = {
type = "swap";
resumeDevice = true;
};
};
root = {
size = "100%FREE";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}
Fixed-size volumes must come before the 100%FREE one — disko already handles this via priority, but it's worth knowing why the ordering is not the attribute order.
LUKS + Btrfs, impermanence style
Root is wiped on every boot; only /nix and /persist survive.
nix
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME";
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "crypted";
content = {
type = "btrfs";
extraArgs = [ "-f" ];
postCreateHook = ''
MNT=$(mktemp -d)
mount -t btrfs /dev/mapper/crypted "$MNT"
trap 'umount "$MNT"; rm -rf "$MNT"' EXIT
btrfs subvolume snapshot -r "$MNT/root" "$MNT/root-blank"
'';
subvolumes = {
"/root" = {
mountpoint = "/";
mountOptions = [ "compress=zstd" "noatime" ];
};
"/nix" = {
mountpoint = "/nix";
mountOptions = [ "compress=zstd" "noatime" ];
};
"/persist" = {
mountpoint = "/persist";
mountOptions = [ "compress=zstd" "noatime" ];
};
};
};
};
};
};
};
};
}
The rollback itself is a boot.initrd.postDeviceCommands (or a systemd initrd service) that restores root-blank over root before the root filesystem is mounted. disko's job stops at making the pristine snapshot exist.
ZFS mirror with native encryption
nix
{
disko.devices = {
disk = {
a = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME-A";
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" "nofail" ];
};
};
zfs = {
size = "100%";
content = {
type = "zfs";
pool = "zroot";
};
};
};
};
};
b = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME-B";
content = {
type = "gpt";
partitions.zfs = {
size = "100%";
content = {
type = "zfs";
pool = "zroot";
};
};
};
};
};
zpool.zroot = {
type = "zpool";
mode = "mirror";
options.ashift = "12";
rootFsOptions = {
mountpoint = "none";
compression = "zstd";
acltype = "posixacl";
xattr = "sa";
"com.sun:auto-snapshot" = "false";
};
datasets = {
"enc" = {
type = "zfs_fs";
options = {
mountpoint = "none";
encryption = "aes-256-gcm";
keyformat = "passphrase";
keylocation = "prompt";
};
};
"enc/root" = {
type = "zfs_fs";
mountpoint = "/";
options."com.sun:auto-snapshot" = "true";
};
"enc/nix" = {
type = "zfs_fs";
mountpoint = "/nix";
options.atime = "off";
};
"enc/home" = {
type = "zfs_fs";
mountpoint = "/home";
options."com.sun:auto-snapshot" = "true";
};
};
};
};
}
ZFS needs networking.hostId set to a unique 8-hex-digit value, and boot.supportedFilesystems = [ "zfs" ]. Encrypting a parent dataset with mountpoint = "none" means one passphrase unlocks every child.
For a more complex pool, mode takes a topology instead of a string:
nix
mode.topology = {
type = "topology";
vdev = [{ mode = "mirror"; members = [ "a" "b" ]; }];
special = [{ mode = "mirror"; members = [ "nvme1" "nvme2" ]; }];
cache = [ "l2arc" ];
};
Members refer to the disk names in disko.devices.disk, not device paths.
mdadm RAID1
The array is a device, so it gets its own partition table.
nix
{
disko.devices = {
disk = builtins.listToAttrs (map (d: {
name = "disk${toString d.i}";
value = {
type = "disk";
device = d.dev;
content = {
type = "gpt";
partitions = {
boot = { size = "1M"; type = "EF02"; };
raid = {
size = "100%";
content = { type = "mdraid"; name = "raid1"; };
};
};
};
};
}) [
{ i = 1; dev = "/dev/disk/by-id/CHANGE-ME-A"; }
{ i = 2; dev = "/dev/disk/by-id/CHANGE-ME-B"; }
]);
mdadm.raid1 = {
type = "mdadm";
level = 1;
content = {
type = "gpt";
partitions.primary = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}
Because a disko config is ordinary Nix, generating the per-disk attrset with map/listToAttrs scales to any number of members. The example/with-lib.nix upstream shows the same trick.
tmpfs /tmp and swap partitions
nix
{
disko.devices = {
disk.main = {
type = "disk";
device = "/dev/disk/by-id/CHANGE-ME";
content = {
type = "gpt";
partitions = {
ESP = {
size = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
end = "-16G"; # leave the tail for swap
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
swap = {
size = "100%";
content = {
type = "swap";
resumeDevice = true;
discardPolicy = "both";
};
};
};
};
};
nodev."/tmp" = {
fsType = "tmpfs";
mountOptions = [ "size=8G" "mode=1777" ];
};
};
}
Swap randomEncryption = true gives you an encrypted scratch swap at the cost of hibernation; the two options are mutually exclusive in practice.
Beyond Installation
disko-install
nixos-install plus partitioning in one step, and it can run from your workstation rather than the target machine — which makes it the neat way to build a bootable USB stick carrying a full NixOS configuration.
shell
sudo nix run 'github:nix-community/disko/latest#disko-install' -- \
--flake '/etc/nixos#mymachine' --disk main /dev/sda
--disk <name> <device> overrides the device written in the config, so the same flake can target whatever is plugged in. Options: --mode format|mount (mount only, to repair an existing install without touching data), --write-efi-boot-entries (off by default, since the target may be portable), --extra-files SRC DEST, --system-config JSON, --mount-point PATH, --dry-run.
It also works fully offline if the installer's closure includes diskoScript, its drvPath and the flake inputs — see the upstream disko-install.md.
Disk images
Build a .raw from a NixOS configuration, without ever touching real hardware:
shell
nix build .#nixosConfigurations.myhost.config.system.build.diskoImagesScript
sudo ./result --build-memory 2048
ls -la main.raw
The script boots a VM, runs disko inside it, installs, and drops the images in the working directory named after disko.devices.disk.<name> (override with imageName, size with imageSize). Two flags handle secrets:
• --pre-format-files <src> <dst> — placed before disko runs. This is how LUKS key files get in.
• --post-format-files <src> <dst> — placed on the finished image, owned by root:root.
system.build.diskoImages does the same inside the nix sandbox: reproducible and cacheable, but slower and with no secure way to inject secrets.
Cross-architecture images work through binfmt:
nix
disko.imageBuilder = {
enableBinfmt = true;
pkgs = inputs.nixpkgs.legacyPackages.x86_64-linux;
kernelPackages = inputs.nixpkgs.legacyPackages.x86_64-linux.linuxPackages_latest;
};
nixpkgs.hostPlatform = "aarch64-linux";
There is no auto-resize yet, so imageSize has to be generous enough for the whole closure.
Interactive VM
Boot your real disk layout in QEMU before committing it to hardware:
shell
nix run -L '.#nixosConfigurations.myhost.config.system.build.vmWithDisko'
Unlike the stock config.system.build.vm, this one actually runs the disko scripts, so LUKS prompts, ZFS pools and subvolume mounts all behave as they will on the real machine. Configure the variant through virtualisation.vmVariantWithDisko; disk sizes come from imageSize, memory from disko.memSize.
Testing
system.build.installTest is a NixOS VM test that installs the configuration onto virtual disks and boots it. disko.tests.bootCommands lets you type an encryption passphrase, disko.tests.extraChecks asserts on the booted system:
nix
disko.tests = {
extraChecks = ''
machine.succeed("test -e /persist/etc/machine-id")
machine.succeed("btrfs subvolume list / | grep -q root-blank")
'';
};
nixos-anywhere exposes the same idea as --vm-test, which is the cheapest way to find out that your layout is wrong before you wipe a remote server.
Partial and repeated runs
Because the three stages are separate scripts, a few non-obvious workflows fall out:
• Adding a disk — extend the config, run --mode format,mount. Existing filesystems are detected and skipped.
• Rescuing a system — boot an ISO, run --mode mount --flake '…#myhost', get the whole tree (including LUKS and ZFS) mounted at /mnt, nixos-enter.
• Protecting data disks — set destroy = false on a disk to keep the destroy stage away from it while the root disk is reinstalled.
• Parameterised layouts — --argstr disk /dev/sdb against a config that takes arguments lets one file serve many machines.
Gotchas
• Dual boot is not supported. disko owns the whole disk; there is no way to describe "keep partitions 1–3".
• Use /dev/disk/by-id/. sda and nvme0n1 are enumeration-order-dependent and will eventually point at the wrong disk.
• nixos-generate-config --no-filesystems. Otherwise you get two competing fileSystems definitions.
• enableConfig = false on installers. An installer image that merely carries a disko config must not try to mount that config as its own root.
• The examples are tests. Partitions of 10M, keys at /tmp/secret.key and extraArgs = [ "-f" ] exist to exercise disko, not to be copied verbatim.
• format is idempotent, destroy is not. Re-running destroy,format,mount on a live system does exactly what it says.
• Password files must have no trailing newline if you also want to use them for interactive login: echo -n "password" > /tmp/secret.key.
• ZFS wants networking.hostId, and the pool import will fail at boot without it.