feat: desktop host with /home on a second disk

Adds hosts/desktop: 512G system disk, 1T disk for /home, each in its own
LUKS container. modules/disk.nix gains moonarch.disk.home, which moves the
/home subvolume to the second disk and drops it from the first.

New modules/user.nix declares moonarch.user without a default, so the login
name exists once per host instead of once per reference. Both hosts set an
initialPassword: swaylock authenticates through pam_unix and cannot unlock a
screen for an account without one.
This commit is contained in:
2026-08-20 12:09:07 +02:00
parent 656d1c70bb
commit 205e3eb60f
7 changed files with 267 additions and 57 deletions
Generated
+4 -4
View File
@@ -24,11 +24,11 @@
"moonarch": {
"flake": false,
"locked": {
"lastModified": 1786722426,
"narHash": "sha256-zJyJC/7GWLj5DOblOVEsJ/Ki+bZBh+2Uws07pA6r4No=",
"lastModified": 1787067355,
"narHash": "sha256-2n1XcruV9FxUHOh05HdETpep6IIk1PNpcl58MjMcrhA=",
"ref": "refs/heads/main",
"rev": "fe552fc7a5160597b9b73e5ec39b4fce4f63bcd1",
"revCount": 162,
"rev": "e21c046e3251317703e9dd2f2d4a76f3a5211329",
"revCount": 166,
"type": "git",
"url": "https://gitea.moonarch.de/nevaforget/moonarch.git"
},
+10 -2
View File
@@ -75,6 +75,7 @@
nixosModules.moonarch = { ... }: {
imports = [
inputs.disko.nixosModules.disko
./modules/user.nix
./modules/disk.nix
./modules/desktop.nix
./modules/greetd.nix
@@ -98,9 +99,16 @@
];
};
# Desktop: 512G system disk, 1T disk for /home.
desktop = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
./hosts/desktop
self.nixosModules.moonarch
];
};
# ThinkPad T14 Gen 3 (AMD Ryzen 7 PRO 6850U).
# Needs hosts/thinkpad/hardware-configuration.nix from the installer
# before it evaluates — see README.
thinkpad = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
+118
View File
@@ -0,0 +1,118 @@
# ABOUTME: Host configuration for the desktop: 512G system disk, 1T disk for /home.
# ABOUTME: Disk layout comes from modules/disk.nix, nothing is generated on the machine.
{ config, lib, pkgs, ... }:
{
# Both device paths are placeholders. disko-install overrides them with
# `--disk main <path>` and `--disk home <path>`, so the real paths are read
# from lsblk on the machine and never travel into this repository. The values
# here are deliberately invalid: without the flags the install fails instead
# of erasing whatever happens to be first in the enumeration.
moonarch.disk = {
enable = true;
device = "/dev/disk/by-id/SET-VIA-disko-install--disk-main";
home = {
enable = true;
device = "/dev/disk/by-id/SET-VIA-disko-install--disk-home";
};
};
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
boot.kernelPackages = pkgs.linuxPackages_zen;
boot.kernelParams = [ "quiet" ];
networking.hostName = "desktop";
networking.networkmanager.enable = true;
time.timeZone = "Europe/Berlin";
i18n.defaultLocale = "de_DE.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "de_DE.UTF-8";
LC_IDENTIFICATION = "de_DE.UTF-8";
LC_MEASUREMENT = "de_DE.UTF-8";
LC_MONETARY = "de_DE.UTF-8";
LC_NAME = "de_DE.UTF-8";
LC_NUMERIC = "de_DE.UTF-8";
LC_PAPER = "de_DE.UTF-8";
LC_TELEPHONE = "de_DE.UTF-8";
LC_TIME = "de_DE.UTF-8";
};
console.keyMap = "de";
services.xserver.xkb.layout = "de";
moonarch.user = "dkressler";
users.users.${config.moonarch.user} = {
isNormalUser = true;
extraGroups = [ "networkmanager" "wheel" "video" "input" "plugdev" "docker" ];
shell = pkgs.zsh;
# Autologin gets past the greeter without a password, but swaylock
# authenticates through pam_unix and cannot unlock a screen for an account
# that has none. `mutableUsers` is at its default, so this applies when the
# account is created and `passwd` overrides it afterwards. Change it after
# the first boot — this value is in the repository.
initialPassword = "moonarch";
};
# LUKS already asked for a passphrase at boot, so the greeter would only ask
# for a second one. initial_session runs once per boot — the greeter comes
# back on logout, which is also where a session gets switched. greetd's
# restart option flips itself off when this is set.
services.greetd.settings.initial_session = {
command = "${pkgs.niri}/bin/niri-session";
user = config.moonarch.user;
};
# Snapshots of root and home. snap-pac has no counterpart here: NixOS keeps
# its own generations, so pre/post package snapshots are redundant. /home is
# its own btrfs on the second disk, which snapper does not care about — a
# subvolume is a subvolume.
services.snapper = {
configs = {
root = {
SUBVOLUME = "/";
ALLOW_USERS = [ config.moonarch.user ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
};
home = {
SUBVOLUME = "/home";
ALLOW_USERS = [ config.moonarch.user ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
};
};
snapshotInterval = "hourly";
cleanupInterval = "1d";
};
# No nixos-hardware profile applies to a self-built desktop, so the firmware
# this machine needs is enabled here instead.
hardware.enableRedistributableFirmware = true;
# The nixos-hardware profile derives this from enableRedistributableFirmware
# on the ThinkPad; here nothing does, so without this line the CPU runs on the
# microcode in its BIOS.
hardware.cpu.amd.updateMicrocode = true;
# The GPU is AMD, so amdgpu and Mesa cover it and there is nothing to declare:
# the kernel module is in the default initrd and Mesa comes with the graphics
# stack. An NVIDIA card would have needed videoDrivers and hardware.nvidia.
services.fstrim.enable = true;
# modules/services.nix turns fwupd on for every host. Lenovo publishes to the
# LVFS, so it earns its place on the ThinkPad; desktop mainboard vendors
# mostly do not, which leaves a daemon with nothing to update. mkForce because
# the shared module sets it unconditionally.
services.fwupd.enable = lib.mkForce false;
nix.settings.experimental-features = [ "nix-command" "flakes" ];
nixpkgs.config.allowUnfree = true;
# The release this machine is first installed with. It stays at this value
# for the life of the installation and is never raised by an update.
system.stateVersion = "26.11";
}
+9 -7
View File
@@ -1,7 +1,7 @@
# ABOUTME: Host configuration for the QEMU/KVM test VM.
# ABOUTME: Launcher script lives outside this repo at ~/VMs/moonix-vm.sh.
{ pkgs, modulesPath, ... }:
{ config, pkgs, modulesPath, ... }:
{
# virtio_blk and virtio_pci are not in boot.initrd.availableKernelModules by
@@ -32,23 +32,25 @@
i18n.defaultLocale = "de_DE.UTF-8";
console.keyMap = "de";
users.users.kresdo = {
moonarch.user = "moon";
users.users.${config.moonarch.user} = {
isNormalUser = true;
description = "Dominik Kressler";
extraGroups = [ "networkmanager" "wheel" "video" "input" ];
shell = pkgs.zsh;
# Throwaway password for a throwaway guest. `mutableUsers` is at its
# default, so this applies when the account is created and `passwd`
# overrides it afterwards.
initialPassword = "moonarch";
};
# LUKS already asked for a passphrase at boot, so the greeter would only ask
# for a second one. initial_session runs once per boot — the greeter comes
# back on logout, which is also where a session gets switched. greetd's
# restart option flips itself off when this is set.
#
# The account still needs a password: swaylock authenticates through
# pam_unix, and without a /etc/shadow entry the screen cannot be unlocked.
services.greetd.settings.initial_session = {
command = "${pkgs.niri}/bin/niri-session";
user = "kresdo";
user = config.moonarch.user;
};
# Development access from the host via the QEMU port forward on 127.0.0.1:2222.
+22 -15
View File
@@ -1,7 +1,7 @@
# ABOUTME: Host configuration for the ThinkPad T14 Gen 3 (AMD).
# ABOUTME: Disk layout comes from modules/disk.nix, nothing is generated on the machine.
{ pkgs, ... }:
{ config, pkgs, ... }:
{
# Partitioning, LUKS and the btrfs subvolumes. The device path is the only
@@ -35,20 +35,18 @@
console.keyMap = "de";
services.xserver.xkb.layout = "de";
users.users.dkressler = {
moonarch.user = "dkressler";
users.users.${config.moonarch.user} = {
isNormalUser = true;
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. Autologin gets past the greeter
# without one, but swaylock then cannot unlock the screen, so the password
# is still 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$...";
# Autologin gets past the greeter without a password, but swaylock
# authenticates through pam_unix and cannot unlock a screen for an account
# that has none. `mutableUsers` is at its default, so this applies when the
# account is created and `passwd` overrides it afterwards. Change it after
# the first boot — this value is in the repository.
initialPassword = "moonarch";
};
# LUKS already asked for a passphrase at boot, so the greeter would only ask
@@ -57,7 +55,7 @@
# restart option flips itself off when this is set.
services.greetd.settings.initial_session = {
command = "${pkgs.niri}/bin/niri-session";
user = "dkressler";
user = config.moonarch.user;
};
# Snapshots of root and home. snap-pac has no counterpart here: NixOS keeps
@@ -66,13 +64,13 @@
configs = {
root = {
SUBVOLUME = "/";
ALLOW_USERS = [ "dkressler" ];
ALLOW_USERS = [ config.moonarch.user ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
};
home = {
SUBVOLUME = "/home";
ALLOW_USERS = [ "dkressler" ];
ALLOW_USERS = [ config.moonarch.user ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
};
@@ -81,6 +79,15 @@
cleanupInterval = "1d";
};
# The nixos-hardware profile only sets hardware.cpu.amd.updateMicrocode as a
# default derived from this option, so without it there is neither a microcode
# update nor the redistributable firmware the WLAN card and amdgpu load.
hardware.enableRedistributableFirmware = true;
# Fingerprint reader. The NixOS module wires pam_fprintd into the PAM stacks
# it owns; enrol a finger with `fprintd-enroll` after installing.
services.fprintd.enable = true;
services.fstrim.enable = true;
services.fwupd.enable = true;
+84 -29
View File
@@ -8,12 +8,15 @@ let
mountOptions = [ "compress=zstd" "noatime" ];
subvolumes = {
systemSubvolumes = {
"/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.home.enable) {
# Only here when there is no second disk. With one, /home lives over there
# and the main disk carries the system alone.
"/home" = { mountpoint = "/home"; 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.
@@ -23,7 +26,11 @@ let
};
};
btrfs = {
homeSubvolumes = {
"/home" = { mountpoint = "/home"; inherit mountOptions; };
};
btrfs = subvolumes: {
type = "btrfs";
# Overwrite an existing signature instead of asking. The device is erased
# by this point either way.
@@ -34,14 +41,24 @@ let
# 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 = {
#
# With a second disk each container is asked for separately while formatting.
# Give both the same passphrase: systemd stage 1 caches the first one and
# tries it on the second container, so the boot prompts once. If it ever asks
# twice, boot.initrd.systemd.enable = false falls back to the script initrd,
# whose boot.initrd.luks.reusePassphrases does the same thing explicitly.
luks = name: content: {
type = "luks";
name = "crypted";
inherit name;
# 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;
inherit content;
};
# Wraps a filesystem in LUKS unless the host opted out of encryption.
maybeEncrypted = name: subvolumes:
if cfg.encrypt then luks name (btrfs subvolumes) else btrfs subvolumes;
in
{
options.moonarch.disk = {
@@ -63,6 +80,26 @@ in
'';
};
home = {
enable = lib.mkEnableOption "a second disk carrying /home";
device = lib.mkOption {
type = lib.types.str;
example = "/dev/disk/by-id/nvme-eui.fedcba9876543210";
description = ''
Second whole disk, erased and partitioned like the first one. It takes
the /home subvolume, which then no longer exists on the main disk.
Encryption follows moonarch.disk.encrypt, so both disks are either
encrypted or neither is. The container is named crypted-home to keep
it apart from the system one.
disko-install addresses it as `--disk home <path>`, the same way the
main disk is `--disk main <path>`.
'';
};
};
encrypt = lib.mkOption {
type = lib.types.bool;
default = true;
@@ -97,31 +134,49 @@ in
};
};
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" ];
config = lib.mkIf cfg.enable (lib.mkMerge [
{
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 = maybeEncrypted "crypted" systemSubvolumes;
};
};
# 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;
};
};
};
};
};
}
# Second disk. No ESP on it: the firmware boots from the main disk, and a
# second one would only be another thing to keep in sync.
(lib.mkIf cfg.home.enable {
disko.devices.disk.home = {
type = "disk";
device = cfg.home.device;
content = {
type = "gpt";
partitions.home = {
size = "100%";
content = maybeEncrypted "crypted-home" homeSubvolumes;
};
};
};
})
]);
}
+20
View File
@@ -0,0 +1,20 @@
# ABOUTME: Declares the moonarch.user option; every host sets its own login name.
# ABOUTME: Autologin, group membership and snapper read moonarch.user from here.
{ lib, ... }:
{
options.moonarch.user = lib.mkOption {
type = lib.types.str;
example = "moon";
description = ''
Login name of the primary account, and therefore the directory name under
/home. Autologin, the group memberships and snapper's ALLOW_USERS all read
this, so the name is declared once instead of once per reference.
There is no default: the name belongs to the machine, so every host sets
it. Changing it on an installed system does not rename the home directory
that stays behind under the old name.
'';
};
}