My NixOS configuration, built on the den framework + Home Manager + Flakes. This framework provides libraries that make implementing the dendritic pattern a breeze. I publish this repo to help others, as I found other people's repos extremely helpful when learning Nix/NixOS. Hopefully I can return the favour.
Note: This is my personal config. Any branch other than
mainshould be considered a work in progress. Hardware configs, hostnames, secrets and user attributes are unique to me - you'll need to bring your own.
This is the fourth iteration of my NixOS configuration. I've been daily driving NixOS for nearly 2 years. Most recently, I have dabbled with the dendritic pattern and with the den framework, I have found a suitable home for my configs.
You can find my other configs archived in named branches for review if you want to check out other management styles, like megadots-classic and megadots-dendritic.
I'm not a developer. I'm a tinkerer with a consultancy job in a technical field who got curious about declarative system management and fell down the NixOS rabbit hole. This project has genuinely brought some fun back into computing for me.
- ๐ฅ๏ธ NixOS aspects for multiple hosts.
- ๐ Home Manager as a NixOS module.
- ๐ป sops-nix for secrets management, with dedicated age key support for hosts and users.
- ๐ FIDO2 hardware keys (Token2 PIN+) for SSH, with an optional extra LUKS unlock keyslot alongside the passphrase.
- ๐ธ Impermanence on an ephemeral btrfs root -
/is restored from a blank snapshot on every boot, and state is opt-in per aspect. - ๐ฎ Secure Boot via lanzaboote with automatic key generation and enrollment.
- โ๏ธ Flake with the den framework for modular, composable host and user aspects.
- ๐พ Disko for declarative disk partitioning.
- ๐ข CachyOS kernel for a gaming optimised kernel (opt-in per host).
- ๐จ Stylix for consistent base16 theming at both scopes - the login screen, TTY
and plymouth as well as the user session - driven by a single
themequirk. - ๐ nixos-anywhere for bare metal remote deployment.
| Host | Machine | Desktop | Bootloader | Kernel | Aspects |
|---|---|---|---|---|---|
endgame |
AMD desktop (zen4) | GNOME | lanzaboote (Secure Boot) | CachyOS lto-znver4 |
base, workstation, gaming, dev |
flatmate |
Microsoft Surface Pro (Intel) | GNOME | systemd-boot | nixpkgs default | base, workstation, dev |
Both run an ephemeral btrfs root on LUKS, the same user and the same desktop. / is its
own subvolume, deleted and restored from a read-only root-blank snapshot by an initrd
service on every boot; /nix, /persist and swap are separate subvolumes that survive.
/home deliberately sits inside the rolled-back root, so user state is opt-in through
persist.home in the aspect that owns it, exactly as system state is opt-in through
persist.system. Touch dont-wipe at the top of the btrfs volume to skip the wipe for a
boot.
The desktop is a host's choice, not the workstation aspect's - the same way the
bootloader is. That is what let endgame run COSMIC for a while and go back to GNOME
without anything shared changing.
If you have not met den before, four terms explain almost everything in this repo:
- aspect - a named, self-contained feature. It can carry a
nixosblock, ahomeManagerblock, or both. Aspects are never split by class, only by concern: bluetooth owns its NixOS options and the state it needs persisted, in one file. includes- how a host, a user or another aspect opts in. roles/base.nix is nothing but a list of aspects every host takes.provides.to-users- a host-scope aspect handing configuration to every user on that host. Needed because a barehomeManagerblock on a host-scope aspect is silently dropped; gnome uses it to deliver dconf settings.- quirk + policy - a quirk is a named data channel an aspect writes to; a policy routes it. All of both live in den/quirks.nix.
The shortest path through the repo, in reading order:
- den/defaults.nix - what every host and user gets, unasked.
- den/hosts.nix - the roster: per-host facts, and nothing else.
- hosts/endgame/default.nix - a host as a readable manifest of the aspects it takes.
- aspects/roles/base.nix - a role is just an aspect
that is all
includes. - aspects/core/networking.nix - a real aspect, and the single consumer of the firewall quirk.
- den/quirks.nix - every channel that crosses a scope.
Everything lives under modules/, discovered automatically by
import-tree - there are no manual import lists;
dropping a file in is enough. flake.nix is generated by
flake-file (nix run .#write-flake), so each module
declares the flake inputs it needs right next to the code that uses them via
flake-file.inputs.
modules/
โโโ aspects/ # every aspect, filed by what it is
โ โโโ roles/ # base, workstation, gaming, dev
โ โโโ core/ # the baseline: boot, disks, nix, networking, secrets
โ โโโ desktop/ # cosmic, gnome, stylix, fonts, networkmanager
โ โโโ hardware/ # opt-in support, and per-model profiles
โ โโโ virtualisation/ # libvirt
โ โโโ apps/ # every user-facing app, one file each
โโโ den/ # defaults, the host roster, the schema, the quirks
โโโ flake/ # flake plumbing: inputs, treefmt, checks, devShell, tasks
โโโ hosts/ # one directory per host: its aspects, and its _hardware.nix
โ โโโ endgame/
โ โโโ flatmate/
โโโ users/tomwrw/ # the user, itself just another aspect
Aspect names are short and flat: modules/aspects/core/sops.nix declares
den.aspects.sops, and modules/aspects/apps/signal.nix declares den.aspects.signal.
The directories are filing, not namespace - an includes list reads as a list of names
rather than a list of paths. Host-specific hardware is not an aspect: each host imports
its own _hardware.nix directly, and the _ prefix is what stops
import-tree picking it up as a module of its own.
Roles are aspects too. base, workstation, gaming and dev are ordinary aspects that
happen to be mostly includes, so there is one concept to learn rather than two.
An aspect says what it needs; something else decides how to apply it. All five channels, and every policy that routes one, live in den/quirks.nix.
# aspects/apps/sunshine.nix says only this...
firewall.tcp = [ 47984 47989 47990 48010 ];
# ...and aspects/core/networking.nix is the single place that turns every such
# declaration into interface-scoped rules on host.network.lanInterface.| Quirk | Carries |
|---|---|
persist |
system paths for environment.persistence, home paths for home.persistence |
firewall |
LAN-scoped ports, aggregated onto the host's interface |
theme |
the base16 scheme and wallpaper, read by Stylix at both scopes |
syncthing-peer |
one device in the mesh, produced per host and consumed per user |
terminal |
how to run a command in a terminal window, so no aspect names one |
Note the trap: a quirk emitted from a user-scope aspect only reaches the host if an
expose policy is registered for it in den.schema.user.includes - without one it is
discarded silently, with no error. syncthing is included at user scope, so its firewall
ports depend on exactly that. Expose copies rather than moves, which is why one
persist quirk can carry both halves: the home entries stay readable in the user scope
they are consumed in.
The Syncthing mesh is the same machinery at fleet scale, and is built without the aspect
knowing the fleet exists: a producer on den.schema.host.includes makes every host
announce its own id, a pipe.collectAll policy gathers all of them into the user scope,
and apps/syncthing.nix reads the pool it is handed.
Peers that aren't den hosts - the NAS - are appended to the same pipe from
fleet.externalPeers in den/hosts.nix, so they arrive
indistinguishable from a fleet host.
nix flake check builds both hosts, runs treefmt, and greps the tree for plaintext key
material. That is all of it - there are no fleet invariants. A broken one shows up when
the machine boots, deploys are atomic, and nixos-rebuild builds before it switches.
The secrets scan is the exception worth keeping: committing a private key to a public repo is the one mistake that cannot be taken back. It is in flake/checks.nix, and it assembles its own search strings from fragments so that the file does not match itself.
core/nix.nix sets two options worth calling out, both security trade-offs:
nix.settings.trusted-users = [ "root" "@wheel" ]- lets anywheelmember build/substitute arbitrary derivations and push closures vianixos-rebuild --target-host. A single-admin-LAN trade-off: fine as the sole admin of this fleet, not something to carry into a multi-user or shared-admin setup without thought.nix.settings.allow-import-from-derivation = true- required because Stylix's base16 scheme reader does an IFD (readFiles a YAML out of thebase16-schemesderivation at eval time). Without it, evaluation fails outright.
Things that are deliberate rather than missed, so you can judge whether they suit you:
- The COSMIC aspect is carried, not used. No host has run COSMIC since
endgamemoved back to GNOME, and nothing evaluates it any more - so treat desktop/cosmic.nix as last-known-good rather than known-to-work. It stays because switching back is a one-line change. - COSMIC is configured by hand, not declaratively. home-manager has no COSMIC modules
at all, so there is no
programs.cosmic-*and no dconf equivalent. Every panel, theme and shortcut choice is made in the UI and written to~/.config/cosmicas RON, which makes thepersist.homeentry in that aspect the entire mechanism by which the desktop survives a boot - the opposite of gnome.nix, wheredconf.settingscarries the decisions that matter. - Stylix does not theme COSMIC. There is no cosmic target in the Stylix version this flake pins. GTK applications still follow the palette, but COSMIC's own shell uses its own theme system and is set by hand. It costs nothing today, with both hosts on GNOME.
- Stylix's own Home Manager auto-import is switched off. Stylix is applied at both
scopes, each half reading the same
themequirk. LeavinghomeManagerIntegration.autoImportat its default has Stylix push its Home Manager module intohome-manager.sharedModulesas well; the module system keys that differently from den's import, evaluates it twice, and dies onstylix.base16being read-only and set twice. - LAN-scoped firewall rules are weaker on a laptop. Every port is opened on
host.network.lanInterfacerather than globally, which is a real improvement on a desktop. Onflatmatethat interface is the Wi-Fi adapter, so it is the same interface at home and in a cafe - SSH and Syncthing are reachable on any network it joins. Source subnet matching (which needs the nftables backend) is the actual fix. trusted-usersincludes@wheel, on a host that signs its own boot chain. A trusted user can get arbitrary content into the store, andendgameis the machine that then signs whatever it boots with its Secure Boot key.- A third-party binary cache supplies that host's kernel.
core/nyx-cache.nix trusts
chaotic-cx/nyx for prebuilt CachyOS kernels, and for
proton-cachyos in apps/steam.nix. It composes with the
point above: a compromised cache could hand
endgamea kernel its own Secure Boot chain would then sign and boot without complaint. Accepted so that a znver4 LTO kernel does not have to be compiled locally on every bump. Only hosts that take a package from nyx trust it - the aspect is included by the two that do, andflatmatehas neither. - Real hardware serials are in the roster.
disk.idin den/hosts.nix carries the NVMe serial of each machine./dev/disk/by-id/is the correct stable identifier - disko partitions on it - so it cannot come from sops, which is decrypted far too late to place a partition. A serial is an identifier, not a credential; the realistic cost is that it fingerprints the hardware.
This configuration has multiple system entry points. At the moment, I am a single user (tomwrw) managing multiple machines.
- Nix with flakes enabled (
experimental-features = nix-command flakes). - Nothing else.
nix developprovidessops,age,nixfmt,nvdandssh-to-age, and every task below - see devshell.nix. - An age keypair per host and per user, and a FIDO2 token if you want the extra LUKS
keyslot.
deployexpects these on a removable drive atusbDefaultin tasks.nix, overridable withMEGADOTS_USB.
The repo's tasks are flake apps, defined in
tasks.nix. nix flake show lists them with a
description each.
nix flake show # list the tasks
nix run .#build endgame # build a host's closure locally (no activation)
nix run .#diff-host endgame # build, then nvd diff against the running system
nix run .#rebuild endgame # switch a remote host (pushes a locally-built closure)
nix run .#deploy endgame # bare-metal install via nixos-anywhere (formats disks)
nix run .#enroll-fido2 endgame # add the inserted token to the LUKS header
nix run .#luks-device endgame # print the host's LUKS device path
nix run .#secrets-updatekeys # re-sync sops recipients after editing .sops.yamlInside nix develop the same tasks are on $PATH as bare commands, so the
above is build endgame, diff-host endgame, and so on. It is diff-host and
not diff because that name belongs to diffutils, and shadowing it inside the
shell would be a nasty surprise.
A positional host passes straight through, but a flag for the task needs --
first, or nix consumes it: nix run .#rebuild -- endgame --flag.
An unknown or missing host is refused up front, with the valid list:
$ nix run .#deploy typo
no such host: typo
valid hosts: endgame flatmateThese run from the checkout and can drive either host. For operating on the
machine you are sitting at, from any directory, use the n* aliases in
core/nix.nix - nr, nb, nd, nu, ncheck,
nfmt, nclean.
There is no wrapper for the things nix already does. nix flake check builds
both hosts, checks formatting and greps for plaintext key material; nix fmt
runs nixfmt, deadnix and statix; nix flake update updates the inputs.
Building a host is the check that matters: it catches anything that has stopped
evaluating or building.
CI does not build the closures. It runs the same three checks, then instantiates every
host - forcing toplevel.drvPath evaluates the whole module system, and every assertions
entry in the tree with it, without realising a single output. The host list is derived from
nixosConfigurations, so a new host is covered without touching
check.yml.
Four things have to be in place before nix run .#deploy <name> will produce a working
machine.
-
USB key material. The USB mirrors the destination: whatever is under
<usb>/users/<username>/is copied to that user's home, at the same relative path. There is no manifest anywhere in this repo, so adding a key is a copy on the USB and no config change at all.<usb>/hosts/<hostname>/age.txt # -> /persist/var/lib/sops-nix/key.txt <usb>/users/<username>/.config/sops/age/keys.txt # -> ~/.config/sops/age/keys.txt <usb>/users/<username>/.ssh/id_ed25519{,.pub} <usb>/users/<username>/.ssh/id_ed25519_sk_primary{,.pub} # FIDO2 handles; useless <usb>/users/<username>/.ssh/id_ed25519_sk_backup{,.pub} # without the physical tokenModes are copied from the USB with
cp -a, so a private key has to be0600there. Ownership is handled by nixos-anywhere's--chown, which runs during the install ---extra-filescopies as root.The USB path itself is
usbDefaultat the top of tasks.nix, overridable withMEGADOTS_USB. -
A
creation_rulesblock for the new host in .sops.yaml - one per secrets file, listing its recipients. Adding a key to the recipient list is not enough; without its own rule the host's secrets are encrypted to nobody. Then runnix run .#secrets-updatekeys. -
secrets/hosts/<name>.yamlwith at leastusers/<user>/password. Evaluation interpolates this filename from the hostname, so a missing file fails the build. -
syncthing/<name>/{key,cert,guiPassword}insecrets/users/<user>.yaml- the syncthing secrets are keyed by host but live in the user file (apps/syncthing.nix). Miss these and the host builds fine, then Home Manager activation fails on the new machine. -
The roster entry in modules/den/hosts.nix: disk id (a stable
/dev/disk/by-id/path), swap size, LAN interface name fromip -br link, and the Syncthing device id. -
modules/hosts/<name>/with adefault.nixlisting the aspects it takes and a_hardware.nixfromnixos-generate-config.
Then boot the target from a NixOS installer ISO, set a password for the nixos user so
SSH works, and run nix run .#deploy <name>. nixos-anywhere partitions with disko, copies the
USB tree into /persist, chowns it to the user, and installs. The machine comes up with its
secrets decryptable and its keys in place - no second pass, nothing to do by hand.
Fork it, then: replace modules/users/ and modules/hosts/ with your own, empty the
roster in modules/den/hosts.nix, regenerate .sops.yaml with your own age keys, and
replace assets/ (the wallpapers are not covered by this repo's licence - see
LICENSE). The parts worth keeping are modules/aspects/ and
modules/den/quirks.nix - no aspect there names a host or a user, so they port across
unchanged.
I learn by doing. None of this would be possible without the copious amounts of developers and repos that share their content freely for others like me to dissect and study. There are many, but to name a few - shout outs go to:
ryan4yin for their awesome book on NixOS (if you haven't started here, then give it a whirl - it really was great) and the i3 Kickstarter repo. Both excellent resources to help me understand the power of NixOS.
The majority of my config structure was heavily influenced by the awesome Misterio77. Not only did his Nix Starter Configs help guide me early on, but his own Nix Config repo was a great inspiration on how to construct and model a modular Nix configuration.
- Nvim - https://github.com/Nvim (https://github.com/Nvim/snowfall)
- mtrsk - https://github.com/mtrsk (https://github.com/mtrsk/nixos-config)
- runarsf - https://github.com/runarsf (https://github.com/runarsf/dotfiles)
- librephoenix - https://github.com/librephoenix (https://github.com/librephoenix/nixos-config)
- frost-phoenix - https://github.com/Frost-Phoenix (https://github.com/Frost-Phoenix/nixos-config)
