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
| Machine | What it is |
|---|---|
hutao-desktop | Ryzen 5 3600X, RX 5600 XT; the main workstation |
hutao-laptop | Ryzen 3 7320U; the same desktop, and a third monitor |
vps | its own repo; on the tailnet, and publishes this book |
hutao-vm | not a machine: the desktop layer under QEMU, for testing |
How they talk to each other, all over the tailnet:
| From | To | What |
|---|---|---|
| laptop | desktop | Moonlight: the desktop streams its third monitor to the laptop |
| desktop | laptop | deploy-rs over Tailscale SSH |
| desktop | vps | deploy-rs over the VPS’s own sshd on port 2222, from the vps repo |
| desktop, laptop, vps, a phone | one another | Syncthing, 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 to | Read |
|---|---|
| know what every host gets, and why | Hosts and layers |
| change a dotfile | The user layer |
| change a colour | Colours |
| add or rotate a secret | Secrets |
| use the laptop as a screen | The laptop as a third monitor |
| ship a change | Rebuilding and deploying |
| install a machine from nothing | Installing a machine |
| try a desktop change without a reboot | The VM and the install rehearsal |
| know why a thing is the way it is | the 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.
| Layer | What it holds | Who gets it |
|---|---|---|
desktopModules | modules/system.nix, the desktop environment in modules/desktop/, and home/ | every configuration |
hosts/common | bootloader, kernel, firmware, graphics, and every module that needs a real install | the two real machines |
hosts/<name> | hardware config, disk sizes, monitors.lua, and what is physically attached | that 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
| Host | Hardware |
|---|---|
hutao-laptop | Lenovo IdeaPad 1 15AMN7: Ryzen 3 7320U, Radeon 610M, 8 GB, NVMe |
hutao-desktop | Ryzen 5 3600X, RX 5600 XT, 16 GB, NVMe + a 2 TB HDD |
hutao-vm | the 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 inmodules/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, andbrscan4for the scanner. - Sunshine, which streams a headless monitor to the laptop. See The laptop as a third monitor.
teams-for-linuxand 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:
| File | Patch |
|---|---|
dot-profile.d/utils.sh | /usr/bin/nvim → command nvim |
hypr/modules/programs.lua | the hard-coded FHS XDG_DATA_DIRS that empties every launcher |
hypr/modules/autostart.lua | the /usr/lib polkit and geoclue agent paths |
dot-gitconfig | /usr/bin/gh → the store’s gh; gpg credential store → secretservice |
dot-tmux.conf | the /usr/share resurrect and continuum plugin paths |
hypr/modules/programs.lua | cursor 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/Wallpapersare seeded once from the tracked copy and then left alone, so:Lazy updateandcaelestia scheme setkeep 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/nvimand~/.claudeare 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, abortinginit.luaon every first boot.- Spotify’s
Appsis a writable copy in~/.local/share/spotify-spicetify, and spicetify’sconfig-xpui.iniis edited rather than linked. See Spotify and spicetify. - Everything else in
~/.configis read-only. An app that saves settings there (fcitx5 does) cannot. To hack on the dotfiles in place, pointsrcinhome/atmkOutOfStoreSymlink.
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
--fontand fcitx5’sclassicui.conf, both literals in the dotfiles thathome/apps/dotfiles.nixsubstitutes (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) andwozr4wdp.default(Floorp) are the directories each browser made on hutao-desktop. On another machine, home-manager pointsprofiles.iniat an empty directory of that name, and the old profile is still there but unused. Rename the old one to match, or setpathper host. - Floorp’s
configPathis set. home-manager’s default is~/.floorp, butfloorp-binkeeps 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
| When | What | How |
|---|---|---|
| at once | caelestia | its own |
| at once | neovim | utils/colors.lua watches scheme.json and re-applies catppuccin, lualine |
| at once | kitty, waybar, mako, tmux, Hyprland | a template, then the hook’s USR1, USR2, makoctl reload, tmux-apply-colors.sh, hyprctl reload |
| at once | vesktop, starship, ccstatusline | a template; the hook touches the theme links Vencord watches, the rest read per render |
| at once | Floorp, Firefox | CaelestiaFox from AMO, fed by its native app (pkgs/caelestiafox.nix) |
| at once | Zen’s window | caelestia-tab’s Zen mod, reloaded live by the autoconfig lib.wrapZen adds |
| at once | Spotify | caelestia’s enableSpicetify, then the hook’s spicetify refresh; theme.js re-links the CSS |
| preview reload | markdown-preview.nvim | plugins/markdown-preview.lua rewrites its CSS from the same watch, while Neovim runs |
| next time the app starts | GTK and Qt apps | caelestia’s enableGtk, enableQt: gtk.css, ~/.config/qtengine |
| next time the app starts | lazygit, wofi, wlogout, swaylock, MangoHud, KDE apps (kdeglobals) | a template |
| next rebuild | grub, plymouth, the tty, SDDM | palette.nix, off current.json |
| next rebuild | the Limine and SDDM backgrounds, the wallpaper | stylix.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.cssandgtk-4.0/gtk.cssand setsadw-gtk3-darkin dconf. Home Manager keeps the theme package, the font (fromstylix.fonts) and the icons, and sets no GTK 4 theme, because that would make it writegtk-4.0/gtk.csstoo. - Qt:
QT_QPA_PLATFORMTHEME=qtengine, with the Darkly style. caelestia writes~/.config/qtengine/config.jsonand the colours beside it.
Worth knowing
- qtengine is Qt 6 only. Nothing here is Qt 5; see
modules/desktop/fcitx5.nixfor the one thing that was. - The Qt font is set in caelestia’s own template. The CLI override in
home/apps/caelestia.nixpatchesqtengine.jsontostylix.fonts.monospace(JetBrainsMono Nerd Font) at weight 300, Light, and the applications size. caelestia rewritesconfig.jsonon every switch, so editing that file does nothing lasting. - Every switch dirties the checkout when the scheme differs from the
committed one. Commit
current.jsonanddynamic.txtwhen 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’sassets/third-party/Wallpapers/under the same name, thennix 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,term12andterm14to the sameff9b8a. 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-hostswhen 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 thezen-browserflake 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
zenpackage inhome/apps/packages.nixgoes throughcaelestia-tab.lib.wrapZen, which adds the autoconfig script that reloads the mod live. palette.nixholds 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
| Key | What | Consumer |
|---|---|---|
root_password | crypt(3) hash, mkpasswd -m yescrypt | modules/users.nix |
user_password | crypt(3) hash, mkpasswd -m yescrypt | modules/users.nix |
luks_passphrase | the passphrase itself, in the clear | install.sh only, never declared |
tailscale_authkey | reusable, pre-authorized, not ephemeral | modules/tailscale.nix |
syncthing_gui_password | plaintext; syncthing-init bcrypts it | modules/syncthing.nix |
sunshine_password | plaintext; sunshine --creds hashes it | hosts/hutao-desktop |
llm.* (see llmKeys) | provider API keys | rendered into one llm.env |
nix.forgejo_token, nix.github_token | read tokens for private flake inputs | rendered 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_passphraseis the only plaintext credential that must never reach/run/secrets:cryptsetupneeds the passphrase, not a hash of it.install.shdecrypts it, runsluksFormatand shreds its copy, andmodules/sops.nixdeliberately 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
sha512sumdigest is not one, and withusers.mutableUsers = falsea 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:
| Template | Shape | Why |
|---|---|---|
llm.env | VAR=value lines | dot-profile.d/environment.sh sources it into every shell |
git-credentials | git’s credential-store format, root-only | sudo nixos-rebuild fetches private inputs as root |
sunshine-netrc | a curl netrc | the 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 refusedis 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 failstailscaled-autoconnectwithout blocking the boot. --hostnameis set fromnetworking.hostName, because the tailnet name lives in Tailscale’s control plane and never follows a rename on its own.--operator=hutao, sotailscale set(every exit-node switch) works without root.useRoutingFeatures = "both"carries the forwarding sysctls, andcheckReversePath = "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.
overrideDevicesandoverrideFoldersstay 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
| Piece | Where |
|---|---|
| Sunshine, the server | services.sunshine in hosts/hutao-desktop/default.nix |
the LAPTOP monitor rule | hosts/hutao-desktop/monitors.lua |
| the disconnect watcher | systemd.user.services.sunshine-quit-on-disconnect, same file |
| the web UI login | sunshine_password, and the sunshine-netrc template |
| Moonlight, the viewer | hosts/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-cmdcreates 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. TheSelected monitor [DP-1]lines in the log before a stream are encoder probes from before the output exists, and are normal. - No firewall ports.
tailscale0is 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 --credsmerges intosunshine_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.
- 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. - Secrets: every key the host declares, read from the flake’s own
config.sops.secretsrather than a list in the script, plusluks_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 isnixos-install, after disko has wiped the disk. - 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. - 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. - Hardware:
nixos-generate-config --no-filesystems, since disko ownsfileSystems.*. Before the eval, because the flake imports the result. - Dry eval: an eval error here costs a minute; the same error after disko costs the disk.
- disko, then
nixos-install, with the age key seeded to/var/lib/sops-nix/key.txtfirst, for the reason in step 2.
Nothing is prompted for and nothing is written back: every credential comes
out of secrets/secrets.yaml.
| Variable | Meaning |
|---|---|
HOST | which nixosConfigurations entry (default hutao-laptop) |
INSTALL_AGE_KEY | path to the age private key copied in above |
INSTALL_DISK | target disk, required when non-interactive |
INSTALL_NONINTERACTIVE | skip 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.
-
The hardware config, from the live environment, before disko.
--no-filesystemsbecause disko ownsfileSystems.*:nixos-generate-config --no-filesystems --dir /tmp/hw mkdir -p hosts/<name> cp /tmp/hw/hardware-configuration.nix hosts/<name>/ -
Disk sizes and the target device:
cp hosts/hutao-desktop/disk.nix hosts/<name>/ -
hosts/<name>/monitors.lua.hyprland.luarequires it, so an empty file is the minimum. Get real names fromhyprctl monitors alland copyhosts/hutao-desktop/monitors.luafor the shape. -
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, sosecurity.sudo.wheelNeedsPasswordstays true and the credential is the tailnet ACL, revocable from the admin console instead of baked into the host.hostnameis the MagicDNS name, so no address is pinned in the repo.magicRollbackmakes 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.autoRollbackcovers 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 resolvehutao-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. Usenixos-rebuildlocally.
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 changed | Use |
|---|---|
anything in modules/desktop/, home/, or the dotfiles | nix run .#vm |
install.sh, modules/disk-layout.nix, modules/sops.nix, the bootloader | vm/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.
| Workflow | Runs on | Does |
|---|---|---|
.forgejo/workflows/ci.yml | pushes to main, pull requests | pre-commit, gitleaks over full history, installer eval |
.forgejo/workflows/pages.yml | pushes to main | builds this book and uploads it as the pages artifact |
.github/workflows/ci.yml | the GitHub mirror | the 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.