Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This is the flake behind two workstations: a laptop and a desktop running the same NixOS on nixos-unstable, with LVM-on-LUKS disks, immutable users whose passwords come from sops, Hyprland with the caelestia shell, and Limine.

The user layer lives here too. dotfiles/ and nvim/ are symlinked into $HOME by home-manager, so there is no stow and no second repo to bump: a config change and the system change it needs are one commit.

The machines

MachineWhat it is
hutao-desktopRyzen 5 3600X, RX 5600 XT; the main workstation
hutao-laptopRyzen 3 7320U; the same desktop, and a third monitor
vpsits own repo; on the tailnet, and publishes this book
hutao-vmnot a machine: the desktop layer under QEMU, for testing

How they talk to each other, all over the tailnet:

FromToWhat
laptopdesktopMoonlight: the desktop streams its third monitor to the laptop
desktoplaptopdeploy-rs over Tailscale SSH
desktopvpsdeploy-rs over the VPS’s own sshd on port 2222, from the vps repo
desktop, laptop, vps, a phoneone anotherSyncthing, sharing ~/syncthing

Both deploys run from the desktop because that is where you usually sit. deploy-rs builds locally and activates remotely, so any machine on the tailnet with Nix can drive them. The desktop itself has no deploy node: it is rebuilt in place. See Rebuilding and deploying.

hutao-vm boots the same desktop layer without an install, for testing anything above the disk. See Hosts and layers.

Where to start

If you want toRead
know what every host gets, and whyHosts and layers
change a dotfileThe user layer
change a colourColours
add or rotate a secretSecrets
use the laptop as a screenThe laptop as a third monitor
ship a changeRebuilding and deploying
install a machine from nothingInstalling a machine
try a desktop change without a rebootThe VM and the install rehearsal
know why a thing is the way it isthe comment at the top of the module that does it

That last row is the real index. This book says where things are and how they fit; the reasoning lives next to the code it constrains, where it cannot rot separately from it.

Conventions

  • A gotcha has a date when it was learned the hard way, so a live constraint can be told from a superstition.
  • Diagrams are mermaid. They render here, in the Forgejo web UI, and in a pull request that changes one.

Hosts and layers

Every configuration in flake.nix is a stack of the same layers, and the layers are the answer to “where does this go”. desktopModules goes into all three configurations; hosts/common and the hardware modules (the nixos-hardware profiles, disko and sops-nix) go only into the laptop and the desktop, which is why hutao-vm boots without an install.

LayerWhat it holdsWho gets it
desktopModulesmodules/system.nix, the desktop environment in modules/desktop/, and home/every configuration
hosts/commonbootloader, kernel, firmware, graphics, and every module that needs a real installthe two real machines
hosts/<name>hardware config, disk sizes, monitors.lua, and what is physically attachedthat machine

modules/desktop/ is the desktop environment shared by every host. It is not hutao-desktop, whatever the name suggests.

New things go in a shared layer unless they belong to one machine’s hardware. The two machines are meant to feel identical, so a package added to one host is usually a package the other is missing.

The machines

HostHardware
hutao-laptopLenovo IdeaPad 1 15AMN7: Ryzen 3 7320U, Radeon 610M, 8 GB, NVMe
hutao-desktopRyzen 5 3600X, RX 5600 XT, 16 GB, NVMe + a 2 TB HDD
hutao-vmthe desktop layer under QEMU; see The VM

What only the desktop has, all in hosts/hutao-desktop/default.nix:

  • The HDD, LUKS-encrypted ext4, unlocked in the initrd with the same prompt as the root disk and mounted nofail. It is deliberately not in modules/disk-layout.nix, so a reinstall cannot wipe it. NTFS support stays for removable media only.
  • The Brother DCP-1512E on USB: CUPS with brlaser, a declared queue, and brscan4 for the scanner.
  • Sunshine, which streams a headless monitor to the laptop. See The laptop as a third monitor.
  • teams-for-linux and evolution with EWS, for work mail.

What only the laptop has is one quirk: amdgpu loads after the LUKS unlock rather than in the initrd, because early KMS resets the console in the middle of the passphrase prompt. It also carries moonlight-qt.

The installer

hosts/installer is a fourth configuration that is not a desktop at all: the ISO install.sh runs from. See Installing a machine.

The user layer

home/ replays stow --dotfiles without stow. It walks dotfiles/, honours dotfiles/.stow-local-ignore, renames dot-foo to .foo, and symlinks the result out of the store. Nothing is listed by hand, so a new dotfile needs no edit here. Each app that needs more than a link has its own file under home/apps/.

On top of the plain tree

home/apps/dotfiles.nix builds the tree as one derivation and changes it in five ways.

NixOS patches, each a substituteInPlace --replace-fail, so a patch that upstream makes unnecessary is a failed build rather than a silent no-op:

FilePatch
dot-profile.d/utils.sh/usr/bin/nvim → command nvim
hypr/modules/programs.luathe hard-coded FHS XDG_DATA_DIRS that empties every launcher
hypr/modules/autostart.luathe /usr/lib polkit and geoclue agent paths
dot-gitconfig/usr/bin/gh → the store’s gh; gpg credential store → secretservice
dot-tmux.confthe /usr/share resurrect and continuum plugin paths
hypr/modules/programs.luacursor theme and size, from the system’s stylix settings

Path rewrites. Configs that hard-coded $HOME/dotfiles/... under stow are repointed by repath at the ~/.config, ~/.local and ~/.profile.d the walk already links, so nothing needs a second copy of the tree in $HOME.

monitors.lua, copied in from hosts/<name>/. hyprland.lua requires it, and upstream gitignores it because it is per machine.

The wallpapers, copied into dot-config/hypr/backgrounds/ from the private third-party-assets input, since they are fan art this public repo cannot carry. That is still the path hyprpaper, hyprlock and the caelestia seed read.

Colours, substituted with caelestia template fields rather than hex. Each file with a colour in it becomes a caelestia template, and its path in $HOME links to what caelestia renders from it, so the desktop recolours on a scheme switch without a rebuild. See Colours.

What stays writable

Symlinks into the store are read-only, so anything an app rewrites has to be handled on purpose:

  • lazy-lock.json, lazyvim.json, caelestia’s active scheme and ~/Pictures/Wallpapers are seeded once from the tracked copy and then left alone, so :Lazy update and caelestia scheme set keep working.
  • The colour-bearing configs (kitty, waybar, mako, starship, …) link out of the store into $XDG_STATE_HOME/caelestia/theme/, which caelestia rewrites on every scheme switch and activation renders on every rebuild.
  • ~/.config/nvim and ~/.claude are linked entry by entry, not whole, so lazy.nvim and Claude Code get a real directory to write into. Link nvim whole and lazy.nvim’s first write fails, aborting init.lua on every first boot.
  • Spotify’s Apps is a writable copy in ~/.local/share/spotify-spicetify, and spicetify’s config-xpui.ini is edited rather than linked. See Spotify and spicetify.
  • Everything else in ~/.config is read-only. An app that saves settings there (fcitx5 does) cannot. To hack on the dotfiles in place, point src in home/ at mkOutOfStoreSymlink.

Neovim

mason is disabled: its prebuilt binaries cannot run on NixOS, so home/apps/neovim.nix provides the language servers instead. Add a server there and to nvim/lua/plugins/lspconfig.lua’s servers table, or it never attaches and nothing says so. home/nvim-nixos.lua silences the warnings LazyVim’s language extras raise for packages mason did not install.

It also points markdown-preview.nvim (<leader>cp) at nixpkgs’ build. The lang.markdown extra’s own build downloads a prebuilt server that cannot run here either, and lazy.nvim’s git checkout has no node_modules for the node fallback, so the preview died on Cannot find module 'tslib' (2026-09-29). nixpkgs ships the app with its modules built; it runs with the node from home/apps/packages.nix. The store path goes in when home/apps/neovim.nix copies the file, in place of @markdownPreview@.

The preview takes caelestia’s colours: nvim/lua/plugins/markdown-preview.lua writes ~/.local/state/markdown-preview.nvim.css, the plugin’s stock markdown.css with its colour variables set from the scheme, at startup and on every switch. An open preview shows a switch on its next reload.

Hyprland reloads on rebuild

home.activation.hyprlandReload runs hyprctl reload and hyprctl setcursor after home-manager links the new generation. Hyprland’s own file watcher never fires here: a rebuild points ~/.config/hypr at a new store path rather than changing the file the watcher holds, so without this an edit waits for the next login.

The shell restarts on rebuild

home.activation.caelestiaReload in home/apps/caelestia.nix kills the shell (caelestia shell -k), because a running shell keeps icon lookups cached from the profile it started with. It then has Hyprland start it again over hyprctl eval, with programs.shell from hypr/modules/programs.lua, the command autostart.lua runs too.

Learned the hard way on 2026-09-26: started from the activation service, the shell inherits QT_QPA_PLATFORM=offscreen, finds no display, and exits, leaving no shell until one is started by hand. The eval uses dofile, not require, so it reads the new programs.lua rather than a module cached from before the switch.

Fonts

hutao.uiFont in home/apps/fonts.nix is the one switch for every UI’s font, by default stylix’s monospace, JetBrainsMono Nerd Font. It reaches:

  • GTK through gtk.font, and so Nautilus, Evolution, and the chrome of the browsers and LibreOffice;
  • Qt through caelestia’s qtengine template (home/apps/caelestia.nix), and so KTailctl;
  • the caelestia shell through programs.caelestia.settings.appearance.font;
  • Discord’s --font and fcitx5’s classicui.conf, both literals in the dotfiles that home/apps/dotfiles.nix substitutes (refont).

fontconfig’s defaults stay stylix’s sansSerif and serif, so web pages and LibreOffice documents keep a proportional face: a document asking for Calibri falls back to sans-serif, and that should not be a monospace.

Spotify and spicetify

spicetify patches Spotify’s Apps directory in place, and the store copy is read-only. home/apps/spicetify.nix wraps spotify with --app-directory=~/.local/share/spotify-spicetify/Apps, and spicetify’s spotify_path is that directory: the writable Apps plus a link to the one store file spicetify reads, v8_context_snapshot.bin. The marketplace and caelestia’s user.css are pinned fetches, linked into ~/.config/spicetify.

home.activation.spicetify re-copies Apps from the store, sets the config and runs spicetify backup apply only when an input changes (the store paths in ~/.local/share/spotify-spicetify/stamp). It passes -n, so a running Spotify picks the change up on its next start. spicetify refuses to apply once prefs’ app.last-launched-version stops matching the version the backup was taken at, so activation writes the store’s version there first, and a mismatch makes it re-apply on the next rebuild.

Marketplace installs live in Spotify’s own storage, not in the repo.

Two settings are Nix’s, and the rest of Settings stays the GUI’s. Activation sets streaming and download quality to very high (audio.*bitrate_enumeration=4) in each profile’s ~/.config/spotify/Users/*/prefs, touching only those keys. “Show the now-playing panel on click of play” lives in Spotify’s local storage rather than a prefs file, so home/spicetify-settings.js, a spicetify extension, turns it off on every launch.

Firefox and Floorp userChrome

Both come from home-manager’s programs.firefox and programs.floorp in home/apps/packages.nix, not home.packages, so one userChrome string styles both (today: it hides the sidebar’s #sidebar-panel-header). home-manager then writes profiles.ini, user.js (which turns on toolkit.legacyUserProfileCustomizations.stylesheets) and chrome/userChrome.css. The first rebuild moves the old profiles.ini and any user.js aside as *.hm-bak. Restart the browser to pick up a change.

  • The profile paths are the desktop’s. iz7vhs3z.default (Firefox) and wozr4wdp.default (Floorp) are the directories each browser made on hutao-desktop. On another machine, home-manager points profiles.ini at an empty directory of that name, and the old profile is still there but unused. Rename the old one to match, or set path per host.
  • Floorp’s configPath is set. home-manager’s default is ~/.floorp, but floorp-bin keeps its profiles in ~/.config/floorp.

Not linted

dotfiles/ is exempt from markdownlint, shellcheck and shfmt, because that tree’s own linter configs stayed in the repo it came from. The whitespace fixers and gitleaks still cover it; see the closing note in .pre-commit-config.yaml.

Colours

caelestia picks the scheme. Whatever you choose in the shell’s scheme picker, dynamic included, recolours the desktop on the spot. A rebuild then brings the parts only Nix can reach (the boot screens, the greeter and the tty) up to the same scheme.

flowchart TB
    pick["caelestia scheme set<br/>(the shell's picker)"]
    state["scheme.json<br/>$XDG_STATE_HOME/caelestia"]
    render["caelestia renders<br/>~/.config/caelestia/templates"]
    hook["caelestia-theme-hook<br/>(theme.postHook)"]
    repo["current.json · dynamic.txt<br/>in the checkout"]
    palette["palette.nix"]

    pick --> state
    pick --> render
    pick --> hook
    hook --> repo
    repo -->|nixos-rebuild| palette

    state --> l_apps["caelestia · neovim · tmux · Floorp, Firefox, Zen<br/>gtk · qt · Spotify (caelestia's own appliers)"]
    render --> t_apps["kitty · hypr · waybar · wofi · wlogout · swaylock<br/>mako · lazygit · vesktop · MangoHud · starship · ccstatusline · KDE apps"]
    palette --> b_apps["stylix: grub · plymouth · tty<br/>SDDM · the seeded scheme.json"]

Choosing a scheme

The picker, or caelestia scheme set -n <name> -f <flavour> -m <mode>. Ours is hu-tao, flavour red, mode dark: dotfiles/caelestia/schemes/hu-tao/red/dark.txt, 110 keys in caelestia’s own format. dynamic generates its colours from the wallpaper. Every scheme caelestia ships works too, since they all carry the same 110 keys.

The CLI lists schemes only out of its own package (data/schemes), never from ~/.config. So home/apps/caelestia.nix overrides the CLI to copy dotfiles/caelestia/schemes/ in beside upstream’s. A new scheme is a new <name>/<flavour>/<mode>.txt there and a rebuild.

<mode> has to be dark or light: caelestia feeds it to GTK’s prefer-<mode> and to its qt<mode>.colors template. A variant goes in the flavour, which is why ours is hu-tao/red/dark and not hu-tao/default/dark-red.

What follows a switch, and when

WhenWhatHow
at oncecaelestiaits own
at onceneovimutils/colors.lua watches scheme.json and re-applies catppuccin, lualine
at oncekitty, waybar, mako, tmux, Hyprlanda template, then the hook’s USR1, USR2, makoctl reload, tmux-apply-colors.sh, hyprctl reload
at oncevesktop, starship, ccstatuslinea template; the hook touches the theme links Vencord watches, the rest read per render
at onceFloorp, FirefoxCaelestiaFox from AMO, fed by its native app (pkgs/caelestiafox.nix)
at onceZen’s windowcaelestia-tab’s Zen mod, reloaded live by the autoconfig lib.wrapZen adds
at onceSpotifycaelestia’s enableSpicetify, then the hook’s spicetify refresh; theme.js re-links the CSS
preview reloadmarkdown-preview.nvimplugins/markdown-preview.lua rewrites its CSS from the same watch, while Neovim runs
next time the app startsGTK and Qt appscaelestia’s enableGtk, enableQt: gtk.css, ~/.config/qtengine
next time the app startslazygit, wofi, wlogout, swaylock, MangoHud, KDE apps (kdeglobals)a template
next rebuildgrub, plymouth, the tty, SDDMpalette.nix, off current.json
next rebuildthe Limine and SDDM backgrounds, the wallpaperstylix.image, off current.json’s wallpaper, looked up in third-party-assets

Templates

A template is a config file whose colours are caelestia fields, {{ primary.hex }} for a bare rrggbb, {{ primary.red }} and friends for a channel. caelestia renders every file in ~/.config/caelestia/templates/ into $XDG_STATE_HOME/caelestia/theme/ of the same name, on every switch, and the app’s own path is a link to the rendered file.

hutao.caelestiaTemplates (in home/apps/caelestia.nix) is the one list of them: a name, a source, and the path to link. home/apps/dotfiles.nix feeds it from recolour, the table of every colour literal in the dotfiles by the scheme key it means, substituted with palette.template’s fields instead of hex. The literals stay in the files, so the configs remain valid when ~/.config points at the raw tree, and --replace-fail still turns a moved literal into a failed build. theme.nix adds kdeglobals, and ccstatusline.nix the status line’s settings.

A rebuild can change a template without the scheme changing, and caelestia only renders on a switch, so activation renders them too (caelestia-render-templates, the same fields) and runs the hook’s reloads.

Hyprland is the odd one: ~/.config/hypr is linked whole, so look_and_feel.lua dofile()s a rendered hypr-colours.lua instead of being a template itself. It falls back to its own literals, which the build still substitutes with the scheme of that build.

Off in cli.json, because something here does the job: enableTerm (kitty reloads mocha.conf, where caelestia’s escape sequences would paint its own terminal mapping over it), enableHypr and enableDiscord (both templates here).

Recording the pick

caelestia-theme-hook runs after every switch. It writes the scheme’s name, flavour, mode and variant to dotfiles/caelestia/current.json, and on dynamic the generated colours to dotfiles/caelestia/dynamic.txt, in caelestia’s format. current.json also gets the wallpaper’s file name, read off caelestia’s wallpaper/path.txt: caelestia wallpaper runs the same hook. modules/desktop/stylix.nix looks that name up in third-party-assets’ Wallpapers/ for stylix.image, which is Limine’s background through stylix’s limine target and SDDM’s through pkgs/sddm-hu-tao.nix. Both are tracked, so the next rebuild builds the scheme you are looking at and the pick is in git once you commit it. A flake reads a tracked file’s working-tree contents, so the rebuild does not wait for the commit.

The hook finds the checkout through $XDG_STATE_HOME/hutao/flake-path, which activation writes from hutao.flakePath (default ~/Projects/nixos-dotfiles). A clone elsewhere sets that option, or edits the file for a quick fix. With no checkout there it records nothing and says so in a notification. The path cannot be worked out: a flake is evaluated from its copy in the store.

GTK and Qt

caelestia owns both. Stylix’s gtk and qt targets are off, so there is one writer per file:

  • GTK: caelestia writes gtk-3.0/gtk.css and gtk-4.0/gtk.css and sets adw-gtk3-dark in dconf. Home Manager keeps the theme package, the font (from stylix.fonts) and the icons, and sets no GTK 4 theme, because that would make it write gtk-4.0/gtk.css too.
  • Qt: QT_QPA_PLATFORMTHEME=qtengine, with the Darkly style. caelestia writes ~/.config/qtengine/config.json and the colours beside it.

Worth knowing

  • qtengine is Qt 6 only. Nothing here is Qt 5; see modules/desktop/fcitx5.nix for the one thing that was.
  • The Qt font is set in caelestia’s own template. The CLI override in home/apps/caelestia.nix patches qtengine.json to stylix.fonts.monospace (JetBrainsMono Nerd Font) at weight 300, Light, and the applications size. caelestia rewrites config.json on every switch, so editing that file does nothing lasting.
  • Every switch dirties the checkout when the scheme differs from the committed one. Commit current.json and dynamic.txt when you want the pick kept.
  • A wallpaper that is not in third-party-assets fails the rebuild. A flake cannot read ~/Pictures/Wallpapers, so a picked wallpaper has to be added to that repo’s assets/third-party/Wallpapers/ under the same name, then nix flake update third-party-assets. The error names the file.
  • ANSI green, blue, cyan and magenta are one colour in hu-tao. The scheme sets term2, term4, term6, term10, term12 and term14 to the same ff9b8a. Retune those keys to tell them apart in the terminal.
  • CaelestiaFox is two halves, and only one is Nix’s. Install the extension by hand from AMO. Each browser’s wrapper (home/apps/packages.nix) links the native app’s manifest into ~/.mozilla/native-messaging-hosts when the browser starts, so restart it after the first rebuild that brings it. All three read that directory: Floorp though its profile is in ~/.config/floorp, and Zen (from the zen-browser flake input, not nixpkgs) though its profile is in ~/.zen — confirmed for Zen 1.22.3b on 2026-09-27.
  • Zen’s window isn’t CaelestiaFox’s. Zen ignores the extension theme API, so CaelestiaFox recolours Firefox and Floorp but not Zen. caelestia-tab’s helper writes a Zen mod instead (see its handbook), and the zen package in home/apps/packages.nix goes through caelestia-tab.lib.wrapZen, which adds the autoconfig script that reloads the mod live.
  • palette.nix holds no hex of its own. A missing colour is a key to add to the scheme, not a constant to inline.

Secrets

One personal age key: ~/.sops-nix/key.txt on a workstation and /var/lib/sops-nix/key.txt on each host. The recipients are in .sops.yaml, committed on purpose because they are public keys. The encrypted values are in secrets/secrets.yaml, also committed, which is the point of sops.

SOPS_AGE_KEY_FILE=~/.sops-nix/key.txt nix develop -c sops secrets/secrets.yaml

secrets/secrets.example.yaml shows the decrypted shape, with a comment per key. modules/sops.nix declares every key and is the one place to add one.

The keys

KeyWhatConsumer
root_passwordcrypt(3) hash, mkpasswd -m yescryptmodules/users.nix
user_passwordcrypt(3) hash, mkpasswd -m yescryptmodules/users.nix
luks_passphrasethe passphrase itself, in the clearinstall.sh only, never declared
tailscale_authkeyreusable, pre-authorized, not ephemeralmodules/tailscale.nix
syncthing_gui_passwordplaintext; syncthing-init bcrypts itmodules/syncthing.nix
sunshine_passwordplaintext; sunshine --creds hashes ithosts/hutao-desktop
llm.* (see llmKeys)provider API keysrendered into one llm.env
nix.forgejo_token, nix.github_tokenread tokens for private flake inputsrendered into root’s git credential store

Every declared key must exist, on every host, before anything builds. sops-nix checks the manifest at build time, and a missing key is a failed build that names it:

secret sunshine_password in …-secrets.yaml is not valid: the key 'sunshine_password' cannot be found

install.sh reads the same list out of the flake and checks every key before it touches a disk, so an install stops at the preflight instead of failing inside nixos-install on a wiped disk.

Values with quirks

  • YAML quoting. A value starting with *, &, !, %, @ or a backtick is YAML syntax, and sops refuses to save the file. Single-quote it. Double quotes work too but treat \ as an escape, so check what sops shows after it rewrites the value in its own style. (2026-09-25)
  • luks_passphrase is the only plaintext credential that must never reach /run/secrets: cryptsetup needs the passphrase, not a hash of it. install.sh decrypts it, runs luksFormat and shreds its copy, and modules/sops.nix deliberately does not declare it. You still type it at every boot; nothing on the machine can open its own disk.
  • The password hashes must be crypt(3) hashes. A sha512sum digest is not one, and with users.mutableUsers = false a host that cannot use its hashes is a host nobody can log into.

Templates

modules/sops.nix also renders three files out of those keys, because the consumers want a file of a particular shape rather than a bare value:

TemplateShapeWhy
llm.envVAR=value linesdot-profile.d/environment.sh sources it into every shell
git-credentialsgit’s credential-store format, root-onlysudo nixos-rebuild fetches private inputs as root
sunshine-netrca curl netrcthe desktop’s disconnect watcher, without argv exposure

Keys and recipients

Adding a recipient does not grant access to what is already encrypted; rerun sops updatekeys secrets/secrets.yaml. Lose every private key in .sops.yaml and the values are gone, by design.

Getting the private key onto a new machine is the one unavoidable manual step of an install. Nothing bootstraps a decryption key from nothing.

Network and the tailnet

The rule is short: the physical network gets one open port, and the tailnet gets everything. Anything that serves another machine of mine listens only where tailscale0 can reach it.

flowchart TB
    lan["LAN / internet"]
    ts["tailscale0<br/>trusted interface"]
    fw["nftables input chain<br/>policy drop"]
    host["the host"]

    lan -- "UDP 41641 only<br/>WireGuard underlay" --> fw
    ts -- "everything" --> host
    fw --> host

The firewall

modules/firewall.nix is the NixOS firewall module on nftables, not a hand-written ruleset. (The vps repo writes its own because docker leaves it no choice; doing that here would silently void trustedInterfaces, checkReversePath and every module’s openFirewall.)

  • Inbound: one UDP port, Tailscale’s own, so peers connect directly instead of through a DERP relay. WireGuard drops anything not from a known node key.
  • SSH is not a firewall port. Tailscale SSH serves the tailnet itself and never touches sshd, so tcp 22 is closed on every physical interface.
  • IPv4 ping is off. ICMPv6 stays on, because dropping it breaks path MTU and neighbour discovery.
  • Egress is unfiltered. A drop policy there would have to list every port a browser, game or updater picks, and each miss would look like a network fault.
  • Refused connections are logged: journalctl -k | grep refused is how to tell a firewall drop from a broken service.

Tailscale

modules/tailscale.nix, imported by hosts/common, so the real machines join and hutao-vm does not.

  • It joins on first boot from tailscale_authkey, with --ssh. A used-up or expired key fails tailscaled-autoconnect without blocking the boot.
  • --hostname is set from networking.hostName, because the tailnet name lives in Tailscale’s control plane and never follows a rename on its own.
  • --operator=hutao, so tailscale set (every exit-node switch) works without root.
  • useRoutingFeatures = "both" carries the forwarding sysctls, and checkReversePath = "loose" keeps strict rpfilter from dropping Tailscale’s replies and degrading direct connections to DERP.

MagicDNS needs services.resolved. --accept-dns=true only tells tailscaled to want the tailnet’s DNS; without resolved it has nowhere to install it, every Tailscale-side check reports healthy, and only name lookups are dead. It cost a deploy: deploy-rs addresses hosts by MagicDNS name and died on Could not resolve hostname. The full story is in the module’s comment.

Syncthing

modules/syncthing.nix shares one folder, ~/syncthing, between the laptop, the desktop, the VPS and a phone, over the tailnet only: nothing opens 8384 or 22000 on a physical interface.

  • Peers are declared, each machine declaring the others, so no first connection has to be accepted by hand. The phone never imports the module; its key is its MagicDNS name. Device IDs are public (a hash of the node’s certificate) and committed.
  • overrideDevices and overrideFolders stay false. True makes the lists the whole truth and deletes anything added in the GUI on every activation.
  • The GUI password is syncthing_gui_password; see Secrets.

The laptop as a third monitor

The laptop can be the desktop’s third screen, right of DP-1, with the desktop’s own mouse and keyboard moving onto it and windows dragging across. A laptop’s HDMI port only outputs, so there is no cable that does this. Instead the desktop draws a monitor that does not physically exist, and streams it.

sequenceDiagram
    participant M as Moonlight (hutao-laptop)
    participant S as Sunshine (hutao-desktop)
    participant H as Hyprland (hutao-desktop)
    participant W as disconnect watcher

    M->>S: start "Laptop screen"
    S->>H: hyprctl output create headless LAPTOP
    Note over H: LAPTOP at 1920x0, 1920x1080@60
    S-->>M: wlr capture of LAPTOP, HEVC over the tailnet
    M--xS: disconnect (lid shut, wifi drop, window closed)
    S->>W: log line: CLIENT DISCONNECTED
    W->>S: POST /api/apps/close
    S->>H: hyprctl output remove LAPTOP
PieceWhere
Sunshine, the serverservices.sunshine in hosts/hutao-desktop/default.nix
the LAPTOP monitor rulehosts/hutao-desktop/monitors.lua
the disconnect watchersystemd.user.services.sunshine-quit-on-disconnect, same file
the web UI loginsunshine_password, and the sunshine-netrc template
Moonlight, the viewerhosts/hutao-laptop/default.nix

Using it

On the laptop: Moonlight → hutao-desktop → Laptop screen. To stop, close the stream or the lid; the screen goes away on its own.

Pairing is once per laptop, and it survives restarts. If it is ever lost (a reinstall, or ~/.config/sunshine wiped), pair from the laptop with a PIN of your choosing and give Sunshine the same PIN:

moonlight pair hutao-desktop --pin 1234     # on the laptop; waits

Then enter the PIN at https://localhost:47990 on the desktop, logging in as hutao with sunshine_password.

Moonlight’s defaults look bad for a desktop. Its default bitrate is tuned for games, and text smears at it. In Moonlight’s settings use 1080p, 60 FPS, about 40 Mbps and HEVC; the two machines have a direct LAN path over the tailnet. These live in Moonlight’s own settings file, which it rewrites, so they are not declared here. (2026-09-25)

Why it is built this way

  • The monitor exists only while it is streamed. A permanent headless output would be an invisible screen the pointer wanders onto whenever the laptop is shut. Sunshine’s prep-cmd creates it on connect and removes it on quit.
  • wlr capture, not KMS. A headless output has no CRTC, so there is nothing for KMS capture to read; output_name = "LAPTOP" picks it by name. The Selected monitor [DP-1] lines in the log before a stream are encoder probes from before the output exists, and are normal.
  • No firewall ports. tailscale0 is trusted, so Moonlight reaches Sunshine over the tailnet and nowhere else. Avahi, which the Sunshine module turns on, is turned back off: mDNS never crosses the tailnet, so Moonlight adds the host by name.
  • Audio stays on the desktop (stream_audio = disabled).
  • The login is seeded from sops on every start. sunshine --creds merges into sunshine_state.json, so paired clients survive the re-seed.

The disconnect watcher

Sunshine only runs the undo half of a prep-cmd when the app quits. A plain disconnect keeps the app running so the client can resume, and that includes closing the lid, which leaves LAPTOP behind as the invisible screen this design exists to avoid. Sunshine has no disconnect hook. (2026-09-25)

So a user service bound to sunshine.service follows Sunshine’s journal for CLIENT DISCONNECTED and quits the app through Sunshine’s own API. Sunshine then runs its normal undo, its state stays consistent, and the next connect recreates the monitor. curl sends no Origin header, so the API wants basic auth (from the netrc) but no CSRF token.

It is keyed on that literal log line. If a Sunshine update rewords it, the watcher silently stops firing, and the symptom is an invisible screen right of DP-1 after the laptop disconnects. Check with:

journalctl --user -u sunshine | grep -E 'CLIENT DISCONNECTED|Executing Undo'

Each disconnect should be followed within a second by an Executing Undo Cmd. Until the watcher is fixed, hyprctl output remove LAPTOP clears the screen by hand.

A new user unit does not start on the switch that adds it. Sunshine is started by graphical-session.target, which is already up when you rebuild, and the watcher by Sunshine. The first time, run systemctl --user start sunshine sunshine-quit-on-disconnect, or log in again.

Installing a machine

Needs UEFI, your age key on the machine, and every key in secrets/secrets.yaml already authored. See Secrets.

nix build .#installer-iso
sudo dd if=result/iso/*.iso of=/dev/sdX bs=4M status=progress oflag=sync

Add your SSH key to vm/authorized_keys before building the ISO, or you cannot reach the installer over the network. Boot it, then from your workstation:

scp ~/.sops-nix/key.txt nixos@<ip>:/tmp/age.key
ssh nixos@<ip>

On a host this repo already knows

sudo -i
git clone https://git.hu-tao.dev/hutao/nixos-dotfiles && cd nixos-dotfiles
INSTALL_AGE_KEY=/tmp/age.key HOST=hutao-desktop ./install.sh

install.sh is about 120 lines of shell, and its order is the design: everything that can refuse happens before anything is destroyed.

  1. Preflight: root, UEFI, the tools, and that the age key is a real age key and a recipient in .sops.yaml. A non-recipient key installs cleanly and then cannot decrypt its own passwords.
  2. Secrets: every key the host declares, read from the flake’s own config.sops.secrets rather than a list in the script, plus luks_passphrase, all present and non-empty, and the two password hashes crypt(3) rather than digests. Checked before the disk gate, because sops-nix fails the build on a missing key and in an install that build is nixos-install, after disko has wiped the disk.
  3. Disk: lists /dev/disk/by-id/ paths and makes you type the disk’s size back. Never /dev/nvme0n1: enumeration order is not stable, and disko wipes whatever the name resolves to.
  4. Pin: writes that choice into hosts/<host>/disk.nix, asking first if the file names a different disk. disko reads the device out of the flake.
  5. Hardware: nixos-generate-config --no-filesystems, since disko owns fileSystems.*. Before the eval, because the flake imports the result.
  6. Dry eval: an eval error here costs a minute; the same error after disko costs the disk.
  7. disko, then nixos-install, with the age key seeded to /var/lib/sops-nix/key.txt first, for the reason in step 2.

Nothing is prompted for and nothing is written back: every credential comes out of secrets/secrets.yaml.

VariableMeaning
HOSTwhich nixosConfigurations entry (default hutao-laptop)
INSTALL_AGE_KEYpath to the age private key copied in above
INSTALL_DISKtarget disk, required when non-interactive
INSTALL_NONINTERACTIVEskip both confirmations; what vm/install-test.sh sets

The disko CLI is nix run .#disko, this repo’s pinned input, so the tool that partitions is the same version as the module describing the layout.

The disk layout

modules/disk-layout.nix, LVM-on-LUKS so one passphrase unlocks everything:

p1  ESP 2G, unencrypted       -> /boot
p2  LUKS2 "cryptroot" -> LVM  -> vg "pool" -> swap / root / home

Sizes and the target device come from hosts/<name>/disk.nix. Only the OS disk is described: disko describes what to create, and the desktop’s HDD has to survive a reinstall. There is one LUKS keyslot and no recovery key; see Known hazards.

Why not disko-install or nixos-anywhere

disko-install would collapse steps 4 and 7 and the key seeding into one command. But it runs disko with DISKO_SKIP_SWAP=1, and on the 8 GB laptop nixos-install needs the swap LV that plain disko activates. Without it the build runs out of memory deep into the install.

nixos-anywhere would replace the script outright, but it installs to a target over SSH from a second machine. This runs on the machine being installed, from its own ISO, which is what recovery assumes: the laptop is what you reach for when something else is broken.

On new hardware

Four files, then the same install.sh.

  1. The hardware config, from the live environment, before disko. --no-filesystems because disko owns fileSystems.*:

    nixos-generate-config --no-filesystems --dir /tmp/hw
    mkdir -p hosts/<name>
    cp /tmp/hw/hardware-configuration.nix hosts/<name>/
    
  2. Disk sizes and the target device:

    cp hosts/hutao-desktop/disk.nix hosts/<name>/
    
  3. hosts/<name>/monitors.lua. hyprland.lua requires it, so an empty file is the minimum. Get real names from hyprctl monitors all and copy hosts/hutao-desktop/monitors.lua for the shape.

  4. hosts/<name>/default.nix:

    _: {
      imports = [
        ../common
        (import ../../modules/disk-layout.nix (import ./disk.nix))
        ./hardware-configuration.nix
      ];
      networking.hostName = "<name>";
      system.stateVersion = "26.05"; # never change after install
    }
    

Then add it to flake.nix beside hutao-desktop, swapping the nixos-hardware profiles for the CPU and GPU you have, and add its Syncthing device ID to modules/syncthing.nix after first boot.

Rebuilding and deploying

At the machine

sudo nixos-rebuild switch --flake .#hutao-desktop

This is the everyday path and needs nothing else. sudo evaluates the flake as root, which has no keyring, so private flake inputs authenticate through the root-only git credential store modules/sops.nix renders; see Secrets.

A rebuild reloads Hyprland for you (see The user layer), but it does not start user units that did not exist before. A new systemd.user.services entry needs systemctl --user start once, or a new login.

To another machine, with rollback

deploy.nodes in flake.nix drives deploy-rs over Tailscale SSH. It declares one node, hutao-laptop, which any machine on the tailnet with Nix can deploy to; in practice that is the desktop. hutao-desktop has no node, so it is rebuilt in place rather than deployed to. A deploy.nodes.hutao-desktop shaped like the laptop’s would make it work in both directions.

nix develop -c deploy .#hutao-laptop                  # build here, activate there
nix develop -c deploy --dry-activate .#hutao-laptop
  • sshUser = "root" rather than sudo, so security.sudo.wheelNeedsPassword stays true and the credential is the tailnet ACL, revocable from the admin console instead of baked into the host.
  • hostname is the MagicDNS name, so no address is pinned in the repo.
  • magicRollback makes the target confirm itself over the tailnet after activating, and roll back if it cannot. That is the case that matters when the link you deploy over is the one you might break. autoRollback covers a failed activation.

It needs three things, each learned the hard way:

  • Nix on the deploying machine. deploy-rs builds locally and activates remotely; a stock macOS shell cannot drive it.
  • MagicDNS on the deploying machine (tailscale set --accept-dns=true, and on NixOS, services.resolved). Without it the copy step cannot resolve hutao-laptop. The tailnet IP works as a fallback.
  • A different machine. A host cannot deploy to itself over Tailscale SSH: the connection goes over loopback, tailscaled never intercepts it, and it lands on real sshd, which PermitRootLogin = "no" refuses. Use nixos-rebuild locally.

Also keep the tailnet ACL from forcing an interactive SSH re-auth, or magicRollback’s confirmation times out and rolls back a good activation.

nixos-rebuild --target-host does not work as hutao: only root is in nix.settings.trusted-users, so the target rejects the unsigned paths built here. Use deploy-rs, or rebuild on the machine itself.

The VPS

The VPS is deploy-rs too, but from its own repo, and by a different path: the tailnet name vps, then the host’s own sshd on port 2222 as hutao, not Tailscale SSH. Tailscale SSH is off there, and port 22 belongs to Forgejo’s git SSH. The laptop’s rules above still apply (Nix and MagicDNS on the deploying machine), and the details are in the vps handbook’s Deploying chapter.

Updating inputs

nix flake update                       # everything
nix flake update third-party-assets    # just the private art repo

The cursor and folder-icon packs come from the private third-party-assets input, so a new pack there is invisible until that input is updated.

The VM and the install rehearsal

Two ways to test without touching a real machine, for two different layers.

You changedUse
anything in modules/desktop/, home/, or the dotfilesnix run .#vm
install.sh, modules/disk-layout.nix, modules/sops.nix, the bootloadervm/install-test.sh

nix run .#vm

Builds hosts/hutao-vm: the real desktop layer and home/, under QEMU, with no install. Log in as hutao / vm; sshd is forwarded to port 2223.

It does not import hosts/common, so there is no LUKS, no sops, no Tailscale and no Syncthing. Throwaway accounts stand in for the sops-backed ones. It stops at the greeter on purpose, since that is usually what you booted it to see.

vm/install-test.sh

install.sh end to end against a blank virtual disk: disko, LUKS, LVM, the sops bootstrap, Limine and first boot. Much slower, and only for the install path.

vm/install-test.sh all          # the whole rehearsal
vm/install-test.sh up           # or one stage at a time:
                                # build up install boot unlock shot ssh down clean

It reads the real secrets/secrets.yaml with your real age key, because proving that key works is the point. Its only state is $WORK (~/.cache/nixos-vm-test by default); SEED=0 skips seeding the guest store from the host.

CI and pages

Two workflows on the Forgejo instance, and a CI-only copy on the GitHub mirror.

WorkflowRuns onDoes
.forgejo/workflows/ci.ymlpushes to main, pull requestspre-commit, gitleaks over full history, installer eval
.forgejo/workflows/pages.ymlpushes to mainbuilds this book and uploads it as the pages artifact
.github/workflows/ci.ymlthe GitHub mirrorthe same checks as ci.yml, on GitHub’s runners

CI

The checks are .pre-commit-config.yaml, the same file the local commit hook runs, so a green CI means a clean pre-commit run --all-files and vice versa:

nix develop -c pre-commit run --all-files

gitleaks runs separately over the whole history, which is why checkout fetches everything: against a shallow clone it would scan one commit and report clean.

The evaluate job evaluates only the installer. The real machines pull their cursor theme from the private third-party-assets repo, and evaluating them would mean handing a runner a credential for it. They are rebuilt on the machines themselves several times a day, which is a stronger check than an eval.

Both Forgejo workflows run on the nix-node label, an image with both nix and node, declared in the vps repo’s modules/runner/ci-image.nix. Node is what lets uses: actions run at all.

Pages

This book is built on every push to main and published at https://pages.hu-tao.dev/hutao/nixos-dotfiles/docs/.

The workflow publishes nothing itself. It uploads an artifact named pages, and the VPS’s modules/pages-pull.nix discovers every public repo holding one and unpacks it, every few minutes. There is no list on the VPS to add this repo to; uploading the artifact is what makes it a pages repo. The artifact’s layout is the URL below /<owner>/<repo>/, which is why the book is staged under _site/docs/. The pull side is documented in the vps handbook’s Pages chapter.

To write or preview the book locally:

nix run .#docs          # mdbook-mermaid install, then mdbook serve with live reload

The pages workflow only runs on main, so the book is also built by a pre-commit hook, which means on every commit that touches docs/ and in CI on every pull request. It fails on anything mdbook rejects, including a SUMMARY.md chapter with no file (book.toml sets create-missing = false). It does not check links inside a page: a dead one builds cleanly.

Known hazards

Things that are not bugs today but will hurt if forgotten.

One LUKS keyslot, no recovery key

Lose luks_passphrase and the disk is gone: there is no second slot and no escrow. LUKS2 has eight slots, so adding a second passphrase after first boot is cheap insurance:

sudo cryptsetup luksAddKey /dev/disk/by-partlabel/<the cryptroot partition>

Rotating is cryptsetup luksChangeKey. Both are imperative, which is why neither lives in a module.

Every declared secret is required

A key declared in modules/sops.nix but missing from secrets/secrets.yaml fails the build, on every host, so adding a declaration means adding the value in the same change. install.sh checks the full list before touching a disk; see Secrets.

Suspend and resume on the laptop

The known hazard on that hardware. It was fixed by DMI quirks in Linux 6.6, so the current kernel is fine, but start there if it misbehaves.

Tailscale auth key expiry

A used-up or expired tailscale_authkey fails tailscaled-autoconnect at boot without blocking anything else. It only matters for a fresh install or a node that has been removed from the tailnet; systemctl status tailscaled-autoconnect says so.

The invisible monitor

If the Sunshine disconnect watcher ever stops matching Sunshine’s log line, a disconnected laptop leaves LAPTOP behind right of DP-1 and the pointer can get lost on it. hyprctl output remove LAPTOP clears it. See The laptop as a third monitor.

Third-party art is not ours to publish

The cursor packs (including Hutao-Cursor, EbiEbiBeam’s artwork: free but not redistributable), the Hutao-Folders icons, the wallpapers and the greeter background live in the private third-party-assets repo, not here, and were filtered out of this repo’s history when they moved. That is why CI evaluates only the installer, and why the real machines need a Forgejo token to build. Keep it that way: this repo is public, so new art goes in that repo and is read from the input.

A credential section starts with an empty helper

dot-gitconfig’s [credential] sets helper = cache --timeout=3600 for every host, and git asks every helper in the list, that one first. A host section that adds its own helper (GCM for git.hu-tao.dev, gh for GitHub) has to start with an empty helper = to drop the cache, or the cache hands back an expired token: the first push fails, git erases it, and only the retry reaches the real helper. Learned 2026-09-30, from Forgejo pushes that always failed once.