Declare the disk layout with disko

Installing used to mean six manual steps through Calamares, one of which
copied hardware-configuration.nix into the repo — a step that fails
silently unless the file is also git added, since Nix only sees tracked
files. It also did not scale past one machine.

modules/disk.nix defines moonarch.disk.{enable,device,encrypt,espSize,
swapSize} and builds disko.devices from it, so another machine is a
device path rather than a new layout. Installing is a single
disko-install run from the ISO.

hardware-configuration.nix is no longer needed: nvme, ahci, sd_mod and
the USB HID modules are in the initrd via
boot.initrd.includeDefaultModules, and nixpkgs.hostPlatform comes from
nixosSystem. The testvm copy described a qcow2 that no longer exists and
is deleted; that host now uses the module against /dev/vda.

The user password no longer arrives with the installer, so it is set
once via nixos-enter after installing instead of committing a hash.
This commit is contained in:
nevaforget committed 2026-08-11 09:50:55 +02:00
1 parent 9988dc1e7b
commit 1ec12da9ff
7 files changed
+279 -81

No files matched your search

+41
View File
@@ -1,5 +1,46 @@
# Decisions
## 2026-08-11 – Disk layout via disko after all, as a shared module
- **Who**: Dominik, ClaudeCode
- **Why**: The Calamares route decided below turned out to be six manual steps,
one of which — copying `hardware-configuration.nix` into the repo — fails
silently unless the file is also `git add`ed, because Nix only sees tracked
files in a git repository. It also does not scale: there will be more
machines than this one, and each would repeat the whole procedure.
- **Tradeoffs**: The argument against disko was that no target machine existed
and device paths would have to be guessed. That has expired. Keeping
Calamares would avoid writing a layout module, but leaves partitioning,
encryption and subvolumes outside version control, where they cannot be
reviewed or repeated.
- **How**: `modules/disk.nix` defines `moonarch.disk.{enable,device,encrypt,
espSize,swapSize}` and builds `disko.devices` from it, so a new machine is a
device path rather than a new layout. Installation is a single
`disko-install --flake … --disk main <device>` from the ISO.
`hardware-configuration.nix` is not needed: `nvme`, `ahci`, `sd_mod` and the
USB HID modules are in the initrd by default via
`boot.initrd.includeDefaultModules`, and `nixpkgs.hostPlatform` comes from
`nixosSystem`. `hosts/testvm` uses the same module against `/dev/vda`; its
generated file was deleted, having described the UUIDs of a qcow2 that no
longer exists.
- **Consequence**: The user password no longer arrives with the installer.
`users.users.dkressler` declares none, so it is set once via `nixos-enter`
after installing rather than committing a hash to the repository.
## 2026-08-11 – Swapfile without hibernation
- **Who**: ClaudeCode, on Dominik's request for a recommendation
- **Why**: The layout module needs a swap default.
- **Tradeoffs**: Hibernation needs swap at least the size of RAM plus a
`resume_offset` kernel parameter. On btrfs that offset changes whenever the
swapfile is recreated, which is a standing source of breakage. Avoiding it
means LUKS → LVM → btrfs LV plus a swap LV, one layer more. A swap partition
cannot be resized later without repartitioning; a swapfile can.
- **How**: 8G swapfile in a `/.swapvol` subvolume, no hibernation, no LVM.
Sized as a safety valve against memory pressure, not as a replacement for the
30 GiB of RAM. Revisit by growing the swapfile and adding `resume_offset` —
neither needs a reinstall.
## 2026-08-07 – Separate repo instead of Nix files in the application repos
- **Who**: Dominik, ClaudeCode
+79 -35
View File
@@ -24,6 +24,7 @@ pkgs/ Build recipes
sweet-cursors.nix cursor theme
moonarch-scripts.nix battery and camera helpers
modules/
disk.nix partitioning: GPT, optional LUKS2, btrfs subvolumes
desktop.nix /etc deployment, package set, theme
greetd.nix greetd + moongreet
services.nix systemd system and user services
@@ -57,58 +58,85 @@ sudo nixos-rebuild switch --flake .#testvm
## Installing on a machine
The disk layout is not declared here. The graphical installer handles
partitioning, encryption and hardware detection, and produces the one file that
cannot be written in advance: `hardware-configuration.nix` with the machine's
UUIDs, LUKS devices and kernel modules.
The disk layout is declared in `modules/disk.nix`, so nothing has to be
generated on the target machine and no file travels back into this repo. Boot
the NixOS ISO and run one command.
1. Boot the NixOS ISO and run the installer. Choose `dkressler` as the user
name — that is what `hosts/thinkpad/default.nix` declares. Pick btrfs,
snapper expects it.
2. Reboot into the fresh system. It runs on the generated configuration,
without this repo.
3. Enable flakes and install git — neither is available yet:
> **This erases the disk given to `--disk`.** Check the path against `lsblk` on
> the machine you are standing in front of. Do not copy the path below without
> looking — it is this ThinkPad's SSD.
1. Partition and install in one go:
```bash
sudo nano /etc/nixos/configuration.nix
# nix.settings.experimental-features = [ "nix-command" "flakes" ];
# environment.systemPackages = with pkgs; [ git ];
sudo nixos-rebuild switch
sudo nix --experimental-features "nix-command flakes" run \
'github:nix-community/disko/latest#disko-install' -- \
--flake 'git+https://gitea.moonarch.de/nevaforget/moonix.git#thinkpad' \
--disk main /dev/disk/by-id/nvme-SKHynix_HFS001TEJ9X102N_SDB7N7740101A8708
```
4. Clone this repo and take over the hardware file:
The LUKS passphrase is asked for twice while formatting. The repo is fetched
from the flake reference, so the ISO needs no `git` and nothing is cloned.
2. Set the user password. Nothing has created one yet, and greetd is the only
way in:
```bash
git clone https://gitea.moonarch.de/nevaforget/moonix.git
cp /etc/nixos/hardware-configuration.nix moonix/hosts/thinkpad/
sudo nixos-enter --root /mnt -c 'passwd dkressler'
```
5. Add the import to `hosts/thinkpad/default.nix`:
3. Reboot.
To skip step 2, put a `mkpasswd -m sha-512` hash into
`users.users.dkressler.initialHashedPassword` — the commented-out line in
`hosts/thinkpad/default.nix` shows where. That commits a password hash to the
repository.
`hardware-configuration.nix` is not used and not needed. NixOS puts `nvme`,
`ahci`, `sd_mod` and the USB HID modules into the initrd by default
(`boot.initrd.includeDefaultModules`), `nixpkgs.hostPlatform` comes from the
flake, and the `nixos-hardware` profile for the T14 Gen 3 (AMD) covers
firmware, microcode, power management and graphics quirks.
The generated `fileSystems` entries point at `/dev/disk/by-partlabel/`, so the
booted system does not care whether the disk enumerates the same way next time.
Only the partitioning step reads `moonarch.disk.device`.
### Adding another machine
1. New directory under `hosts/`, with a `default.nix` holding the machine
specific settings — hostname, users, locale, bootloader.
2. Point the shared layout at its disk:
```nix
imports = [ ./hardware-configuration.nix ];
moonarch.disk = {
enable = true;
device = "/dev/disk/by-id/..."; # check against lsblk
};
```
6. Switch and reboot:
`encrypt = false` drops the LUKS layer, `swapSize = ""` the swapfile, and
`espSize` resizes the ESP. Everything else is shared.
3. Add a `nixosConfigurations.<name>` entry in `flake.nix`.
4. Install with the same `disko-install` command, with `#<name>` and that
machine's disk.
```bash
sudo nixos-rebuild switch --flake ~/moonix#thinkpad
```
Without a matching `nixos-hardware` profile, set
`hardware.cpu.intel.updateMicrocode` or `hardware.cpu.amd.updateMicrocode` in
the host file — the profile is what provides it for the ThinkPad.
If step 6 breaks the system, pick the previous generation in the boot menu —
the installer's configuration stays available as generation 1.
### Disk layout
The `nixos-hardware` profile for the T14 Gen 3 (AMD) is already wired up:
firmware, power management and graphics quirks come with it.
| Partition / subvolume | Mountpoint | Notes |
|---|---|---|
| ESP, `espSize` (1G) | `/boot` | vfat, `umask=0077` |
| `root` | `/` | btrfs, `compress=zstd`, `noatime` |
| `home` | `/home` | same |
| `nix` | `/nix` | same; separate to keep the store out of snapshots |
| `swap` | `/.swapvol` | swapfile, `swapSize` (8G) |
Until step 5 is done, `nixosConfigurations.thinkpad` does not evaluate:
```
error: Failed assertions:
- The 'fileSystems' option does not specify your root file system.
```
That is expected — the filesystems live in `hardware-configuration.nix`.
With `encrypt = true` everything below the ESP sits in a LUKS2 container named
`crypted`. Hibernation is not set up — see `DECISIONS.md`.
## Version bumps
@@ -143,6 +171,22 @@ overwrites later edits.
`hosts/testvm` targets a QEMU guest. The launcher script lives outside this
repo at `~/VMs/moonix-vm.sh`.
It uses the same layout module as real hardware, pointed at `/dev/vda` — the
launcher attaches the qcow2 as `if=virtio` and there is no `by-id` path for it.
LUKS stays enabled and the swapfile shrinks to 2G. Installing the guest is
therefore also the test for `modules/disk.nix`.
Recreating the disk from scratch:
```bash
qemu-img create -f qcow2 ~/VMs/moonix.qcow2 60G
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd ~/VMs/moonix-vars.fd
~/VMs/moonix-vm.sh ~/Downloads/nixos-graphical-*.iso
```
Then run the `disko-install` command from above inside the live session, with
`#testvm` and `--disk main /dev/vda`.
It deliberately contains no testing shortcuts — no passwordless sudo, no SSH
password authentication. Set those at runtime when needed, for example through
a drop-in under `/run/systemd/system/`, so they do not end up in version
+7
View File
@@ -8,6 +8,11 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixos-hardware.url = "github:NixOS/nixos-hardware";
# Declarative partitioning. Consumed by modules/disk.nix.
disko = {
url = "github:nix-community/disko/latest";
inputs.nixpkgs.follows = "nixpkgs";
};
# Project sources. These repos are not flakes themselves, they are
# consumed as plain source trees pinned to a release tag.
@@ -82,6 +87,8 @@
# module without threading arguments through by hand.
nixosModules.moonarch = { ... }: {
imports = [
inputs.disko.nixosModules.disko
./modules/disk.nix
./modules/desktop.nix
./modules/greetd.nix
./modules/services.nix
+9 -1
View File
@@ -4,7 +4,15 @@
{ pkgs, ... }:
{
imports = [ ./hardware-configuration.nix ];
# The launcher attaches the qcow2 as if=virtio, so the guest sees /dev/vda.
# There is no by-id path for it — the disk only exists inside the VM.
# Encryption stays on: this is where the LUKS layer gets exercised before it
# runs on real hardware.
moonarch.disk = {
enable = true;
device = "/dev/vda";
swapSize = "2G";
};
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
-44
View File
@@ -1,44 +0,0 @@
# Do not modify this file! It was generated by ‘nixos-generate-config’
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }:
{
imports =
[ (modulesPath + "/profiles/qemu-guest.nix")
];
boot.initrd.availableKernelModules = [ "ata_piix" "virtio_pci" "floppy" "sr_mod" "virtio_blk" ];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-amd" ];
boot.extraModulePackages = [ ];
fileSystems."/" =
{ device = "/dev/disk/by-uuid/b88b75df-3999-4110-9e37-b8b76bf8206d";
fsType = "btrfs";
};
fileSystems."/home" =
{ device = "/dev/disk/by-uuid/b88b75df-3999-4110-9e37-b8b76bf8206d";
fsType = "btrfs";
options = [ "subvol=home" ];
};
fileSystems."/nix" =
{ device = "/dev/disk/by-uuid/b88b75df-3999-4110-9e37-b8b76bf8206d";
fsType = "btrfs";
options = [ "subvol=nix" ];
};
fileSystems."/boot" =
{ device = "/dev/disk/by-uuid/77FC-6FE3";
fsType = "vfat";
options = [ "fmask=0077" "dmask=0077" ];
};
swapDevices =
[ { device = "/dev/disk/by-uuid/47747ffb-51b7-44c5-a2f3-6db564497c5c"; }
];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}
+16 -1
View File
@@ -1,9 +1,16 @@
# ABOUTME: Host configuration for the ThinkPad T14 Gen 3 (AMD).
# ABOUTME: hardware-configuration.nix is added during installation, see README.
# ABOUTME: Disk layout comes from modules/disk.nix, nothing is generated on the machine.
{ pkgs, ... }:
{
# Partitioning, LUKS and the btrfs subvolumes. The device path is the only
# machine specific value; the layout is shared with every other host.
moonarch.disk = {
enable = true;
device = "/dev/disk/by-id/nvme-SKHynix_HFS001TEJ9X102N_SDB7N7740101A8708";
};
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
boot.kernelPackages = pkgs.linuxPackages_zen;
@@ -33,6 +40,14 @@
description = "Dominik Kressler";
extraGroups = [ "networkmanager" "wheel" "video" "input" "plugdev" "docker" ];
shell = pkgs.zsh;
# No password is declared here, so a freshly installed system has no
# /etc/shadow entry for this account and greetd cannot let anyone in. The
# password is set once after installing, see README.
#
# To make it declarative instead, put the output of `mkpasswd -m sha-512`
# in initialHashedPassword. That commits a hash to the repository, which is
# offline attackable if the repository ever leaks:
# initialHashedPassword = "$6$...";
};
# Snapshots of root and home. snap-pac has no counterpart here: NixOS keeps
+127
View File
@@ -0,0 +1,127 @@
# ABOUTME: Declarative disk layout for every host: GPT, optional LUKS2, btrfs.
# ABOUTME: A host sets moonarch.disk.device; the layout itself is shared.
{ config, lib, ... }:
let
cfg = config.moonarch.disk;
mountOptions = [ "compress=zstd" "noatime" ];
subvolumes = {
"/root" = { mountpoint = "/"; inherit mountOptions; };
"/home" = { mountpoint = "/home"; inherit mountOptions; };
# Separate so the store stays out of the snapper snapshots of /. It is
# reproducible from the flake and would only inflate them.
"/nix" = { mountpoint = "/nix"; inherit mountOptions; };
} // lib.optionalAttrs (cfg.swapSize != "") {
# No compression or noatime here: disko creates the swapfile inside this
# subvolume, and btrfs requires it to be nodatacow, which it sets itself.
"/swap" = {
mountpoint = "/.swapvol";
swap.swapfile.size = cfg.swapSize;
};
};
btrfs = {
type = "btrfs";
# Overwrite an existing signature instead of asking. The device is erased
# by this point either way.
extraArgs = [ "-f" ];
inherit subvolumes;
};
# Neither passwordFile nor settings.keyFile is set, which makes disko's
# askPassword default to true: it prompts for the passphrase twice while
# formatting and never puts it on a command line.
luks = {
type = "luks";
name = "crypted";
# Lets TRIM reach the SSD through the container. The tradeoff is that the
# pattern of used blocks becomes visible on the raw device.
settings.allowDiscards = true;
content = btrfs;
};
in
{
options.moonarch.disk = {
enable = lib.mkEnableOption "the declarative disk layout";
device = lib.mkOption {
type = lib.types.str;
example = "/dev/disk/by-id/nvme-eui.0123456789abcdef";
description = ''
Whole disk to partition, as a /dev/disk/by-id/ path — that one survives
enumeration changes, unlike /dev/nvme0n1.
This value decides which disk gets erased. Check it against lsblk on the
target machine before installing.
Only the partitioning reads it. The generated fileSystems entries go
through /dev/disk/by-partlabel/, so a booted system does not depend on
the path staying the same.
'';
};
encrypt = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Put the filesystem inside a LUKS2 container. The layout is otherwise
identical, so a machine that does not need encryption — a throwaway VM —
gets the same subvolumes.
'';
};
espSize = lib.mkOption {
type = lib.types.str;
default = "1G";
description = ''
Size of the EFI system partition. Every boot entry keeps its kernel and
initrd here, so this scales with the number of generations kept.
'';
};
swapSize = lib.mkOption {
type = lib.types.str;
default = "8G";
example = "";
description = ''
Size of the swapfile in the /.swapvol subvolume. Empty string creates no
swap at all.
Sized as a safety valve, not as a replacement for RAM. Hibernation needs
at least as much swap as RAM plus a resume_offset kernel parameter, which
is not set up here — see DECISIONS.md.
'';
};
};
config = lib.mkIf cfg.enable {
disko.devices.disk.main = {
type = "disk";
device = cfg.device;
content = {
type = "gpt";
partitions = {
ESP = {
size = cfg.espSize;
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
# Named the same whether or not it is encrypted, so the partlabel the
# fileSystems entries point at does not depend on cfg.encrypt.
root = {
size = "100%";
content = if cfg.encrypt then luks else btrfs;
};
};
};
};
};
}