Right, so this happened.
You may have read my last post about the nix-config — the one where I described three machines, one flake, and a server that existed only in theory. Well, since then I took a long hard look at what I'd built, decided most of it was held together with optimism and duct tape, and did what any reasonable person would do.
I rewrote the whole thing.
Not because it was broken, exactly. It mostly worked. But the longer I spent in the codebase, the more I noticed a particular pattern: every time I wanted to change a setting — say, my username, or a package list, or even which services to run — I was touching four or five different files. The configuration wasn't declarative so much as it was distributed. Values lived in settings/config/packages.nix and settings/config/shell.nix and settings/config/darwin.nix and somewhere else I'd probably forgotten about. It was a perfectly coherent system when I'd written it, and an absolute puzzle box when I came back to it a week later.
This is fine when you write it. It is less fine three weeks later when you're hunting through a dozen files trying to remember where you put the SSH port number.
The Reading That Changed Things
Before I get into what I actually changed, I should credit the thing that made me realise I needed to change it in the first place.
Isabel's blog post, "I'm not mad, I'm disappointed", is exactly what it sounds like: a calm, slightly weary walkthrough of common mistakes in Nix configs. The kind of post that makes you nod along right up until you recognise one of the mistakes as something you did. Then you stop nodding and start scrolling back through your own flake.
I did this a few times.
The one that really landed was the section on specialArgs. I'd been using them heavily — passing pkgs, lib, isDarwin, and various bits of my custom config around through specialArgs because, well, I needed them everywhere and that was the only way I knew to get them there. Isabel's post is fairly direct on this: specialArgs are great but often overused for things that the module system was designed to do better. The module system exists precisely so you don't have to smuggle values through function arguments. I was smuggling. A lot.
The other thing that hit home was the note on passing system to lib.nixosSystem. I was doing that too, explicitly setting system = "x86_64-linux" in the flake. You shouldn't; nixpkgs.hostPlatform is the correct way to declare the target system, and it's set for you automatically by the hardware configuration anyway. I had a whole flake doing unnecessary work, and I only found out because someone wrote a blog post essentially titled "please stop doing this."
So. The rewrite.
The Old Architecture (A Brief Eulogy)
Before the rewrite, I had a custom abstraction layer I was rather proud of. It lived in settings/config.nix and was imported everywhere as cfgLib, which let me do things like:
let
cfg = cfgLib.cfg;
in {
users.users.${cfg.user.username} = { ... };
}This felt elegant when I wrote it. In retrospect, it was me reinventing the NixOS module system, but worse, and without the documentation. Every module had to import cfgLib explicitly. Every change to the config schema required hunting down all the callers. The whole thing was one step away from being a hand-rolled dependency injection framework in a language that already has a better one built in.
And at some point — multiple points, actually — I introduced circular dependencies that caused infinite recursion errors I only half-understood. The commit messages tell the story:
"fix: resolve NixOS 25.11 compatibility issues and infinite recursion"
"fix: pass pkgs and lib to home.nix imports in flake"
"fix: please for the love of the gods is this fixed?"
That last one was a real commit message. I was not having a good evening.
The cfgLib approach also meant that specialArgs were threaded everywhere. I was passing isDarwin into modules as a function argument to avoid circular imports, which is the kind of thing that works until it doesn't, and then you spend an hour staring at a traceback wondering why everything is recursive.
Looking at it now, the whole thing was a system built around working around Nix rather than with it. Isabel's post gave me the vocabulary to recognise that. The module system is the correct tool. I just wasn't using it.
The New Architecture (Modules, Properly)
The rewrite centres on a single file: modules/options.nix.
Instead of a custom abstraction, I'm now using the standard NixOS module system the way it was intended. options.nix declares all my custom options — things like myConfig.user.username, myConfig.isDesktop, myConfig.server.pds.enable — and everything else in the config just reads those through config.myConfig.*. No custom import helpers. No cfgLib. No specialArgs being passed around like contraband.
Just the module system. Which is, it turns out, exactly what it's there for.
The hostPlatform issue got fixed at the same time. Each host now declares nixpkgs.hostPlatform properly rather than passing system as a flake argument — which, as Isabel points out, was being quietly ignored anyway. The mkNixOS and mkDarwin helper functions in the flake are much cleaner now because they're not trying to ferry the system string around manually.
The main practical benefit of all this is that changes actually propagate. If I update myConfig.user.username in options.nix, it works everywhere. No hunting. No missed files. The module system handles it because that's the entire point of the module system. I don't know why it took me this long to use it properly. (I do know. It's because when you're learning Nix, specialArgs feels like the obvious escape hatch, and nobody stops you using it long past the point where you should have switched.)
The flake is also considerably leaner now. I had a mkNixOS function that was doing quite a lot of manual wiring — explicitly passing pkgs, lib, isDarwin, settings, self — and most of that was unnecessary once the module system was doing the job. The resulting flake evaluates cleanly under nix flake check, which was not always the case before. Not remotely always.
Secrets: From ragenix to sops-nix
The other big change: I switched secrets managers.
The old setup used ragenix, which is a lovely tool and I have no complaints about it. The workflow was: generate SSH keys per host, write out a secrets.nix file that mapped which public keys could decrypt which secrets, then use ragenix --edit to create and update individual .age files. Automated with a setup.sh script. It worked well.
The reason I moved away from it is less about ragenix being bad and more about the management overhead as I added more hosts. Every time a new machine came in — and a few appeared during the chaos of this rewrite — I had to rekey all the secrets. The setup script handled most of it, but "most of it" is not the same as "all of it," and the gaps became apparent at inconvenient moments.
sops-nix with a .sops.yaml rules file centralises all of that. The rules file describes which keys can decrypt which secrets, and sops handles the rest when you run sops --rotate. Adding a new host is a matter of adding its age key to .sops.yaml and re-encrypting; there's no separate secrets.nix to maintain in parallel.
The encrypted files are still committed to the repo. The actual secret values are not. The threat model is unchanged; the maintenance burden is lower. That's a good trade.
One side effect of the move: I cleaned out a lot of secrets that didn't need to be secrets. I'd been encrypting some things — GNOME dconf settings, macOS defaults — that were never sensitive to begin with. They ended up that way because I'd originally set up an automated export-and-encrypt workflow that encrypted everything indiscriminately. Removing the automation meant I could actually look at what was in the secrets directory and ask whether it needed to be there. Mostly it didn't.
The Laptop: Now Running KDE Plasma
This one is a departure.
I was on GNOME for a while, and I spent a frankly unreasonable amount of time trying to get it configured nicely through dconf settings. There's a whole automated export script in the repo's history that would dump the dconf database, convert it to Nix, and re-import it at build time. It sort of worked. It was also extremely fragile, dependent on the exact version of GNOME installed, and generated file names like org-gnome-terminal-legacy-profiles-----b1dcc9dd-5262-4d8d-a863-c897e6d979b9.nix, which is not a path anyone should have to look at.
I switched to KDE Plasma, and specifically to plasma-manager, which is the sensible alternative. It lets you declare the entire desktop state — panel layout, widgets, shortcuts, application settings — in Nix, and it applies cleanly on each rebuild. No export scripts. No fragile dconf paths. Just a settings/plasma/default.nix that describes what I want and gets it.
I've configured it to look roughly like macOS (unified top bar, dock at the bottom, similar icon sizing), which is either a reasonable aesthetic choice or a sign that I should have just bought a second Mac. I'm choosing to believe it's the former.
The wallpaper situation, I will admit, took longer than it should have. There were several commits with messages like "fix: the wallpaper should now apply correctly" and "fix: please." It applies correctly now. That's all I'm saying about it.
The Server: No Longer Purely Theoretical
Previously I described the server config as "Schrödinger's server" — the config existed, the hardware didn't, and you couldn't be entirely sure which state it was in until you actually deployed it.
The hardware still doesn't exist. But the config has expanded considerably.
It now describes a full self-hosted stack:
AT Protocol PDS — a Personal Data Server for the Bluesky/AT Protocol ecosystem. I've been on Bluesky since fairly early, my account currently lives on selfhosted.social (run by Bailey Townsend, not me), and eventually I'd like to host my own rather than relying on someone else's. The module handles the container setup, persistent storage under /srv/pds, and a setup script for the initial configuration.
Matrix/Synapse — a Matrix homeserver. I had a brief go at Matrix a while back and it didn't really stick, but with Discord having been increasingly difficult to trust lately, I've been thinking about it again. The other option in that space is roomy.space, which is an AT Protocol-based alternative — and given that I'm already deep into the AT Protocol rabbit hole with the PDS, that's arguably the more natural fit. Either way, the Synapse module is there for when I decide. The module sets up Synapse with PostgreSQL backing and an admin account.
Forgejo — self-hosted Git. This is probably the most immediately useful one; I already use Tangled for some things but having a private instance for work-in-progress repos is appealing.
Cockpit — a web-based management dashboard, restricted to the Tailscale interface so it's not publicly accessible. Nice for checking on things without SSHing in.
All of these are fronted by Caddy and exposed via Cloudflare Tunnel, which means no open ports, no port-forwarding, and no need to trust that my home router is configured correctly. The tunnel handles external access; Tailscale handles internal access; everything else is firewall-blocked by default.
There are a couple of other services I'm thinking about adding down the line — Bitwarden (or rather Vaultwarden, the self-hosted compatible server), Nextcloud, and Jellyfin are all on the list. Maybe. The "maybe" is doing a lot of work there. Nextcloud in particular has a reputation for being one of those things that's great until you have to maintain it, at which point it becomes a part-time job. Jellyfin I'm more straightforwardly excited about — self-hosted media server, no subscription, no sending my watch history to a corporation. But it's all on the list.
Whether any of this actually works on real hardware remains to be seen. The configuration evaluates correctly. The modules are written. The setup scripts exist. But there's a meaningful gap between "this is correctly specified" and "this runs without incident on a machine I've never touched," and I haven't closed that gap yet.
I'll let you know.
Tailscale SSH and the Update Everything Script
One thing I did get working across all three machines: Tailscale-routed SSH.
The SSH config now uses tailscale nc as a ProxyCommand, which routes connections through the Tailscale mesh network rather than whatever IP address the host happens to have at the time. This means I can SSH from the Mac Mini to the laptop, or from the laptop to the server, using a consistent hostname — laptop, macmini, server — regardless of where either device physically is or what network it's on.
The macOS side required a bit of care because ProxyCommand runs with a minimal environment and doesn't inherit the standard PATH. This meant the Tailscale binary had to be specified as an absolute path (/Applications/Tailscale.app/Contents/MacOS/Tailscale) rather than relying on Homebrew having set things up correctly. This is the kind of thing you discover at 11pm when everything was working five minutes ago and nothing has obviously changed.
There's also an update-everything script that will — in theory — SSH into each online Tailscale host in turn and run a rebuild. On Linux it runs sudo nixos-rebuild switch. On macOS it runs darwin-rebuild switch. It detects which hosts are reachable before trying to connect, so it fails gracefully rather than hanging.
In practice I've only tested this with one host available, because that's all I have accessible at once. The script runs correctly in that case. I'm choosing to extrapolate that it will also run correctly with three hosts. This is probably fine.
The Mac Mini: A Few Additions
The Mac Mini configuration got a few additions during all this.
Time Machine is now configured — or rather, the config declares a Time Machine destination by UUID, pointing at the external Micron Crucial X9 2TB drive I use for backups and general external storage. The nix-darwin activation script mounts and registers the volume. In practice there's a manual step involved because Time Machine requires Full Disk Access that you can't grant to an activation script. I've documented the one-time setup in docs/time-machine.md, which at least means I'll know what to do when I inevitably forget.
The reason it's on the Crucial and not the 2TB WD Elements HDD I was using before: the WD's SMART readings are not great. Not occasional use, either — it was running hourly Time Machine backups on a Mac I rarely turn off, which is more or less the worst possible workload for a spinning HDD. I'd just sort of forgotten that drives wear down faster when you never give them a rest. Turns out they do. Considerably.
So I've dropped the Time Machine schedule to weekly, switched to the Crucial, and I'm planning on doing proper full power cycles on the Mac rather than leaving it running indefinitely. The WD still works, technically, but I'm not trusting it with anything I care about. The plan is to destroy it properly and take it to a recycler — the Apple Store will apparently accept electronics for recycling free of charge, which is convenient. Better than it sitting in a drawer slowly becoming a data liability. It helps that I've been doing Cybersecurity as an exam unit in IT at college this year; disposing of storage media correctly is exactly the sort of thing that gets drilled into you, and it turns out the instinct sticks. (The curriculum probably had something more clinical in mind than "hit it with a hammer until it stops looking like a hard drive," but the outcome is the same.)
This is, honestly, a sign that I need proper infrastructure. A dedicated server and probably a NAS — something with redundancy, something that isn't a single external drive that could quietly give up at any point. The Jellyfin and Nextcloud ambitions don't really make sense without somewhere sensible to store the data either. It's all the same problem: I've built a config for a server I don't have yet, and the gap between the config and the hardware is becoming increasingly obvious.
I also moved more GUI apps to Homebrew casks rather than Nix packages, which is the pragmatic call on macOS. Spotlight indexing, dock integration, app signing — these all work better with Homebrew casks than with Nix-managed apps on macOS. Heavy development packages (ollama, dotnet-sdk, the JDK) were moved out entirely; they were pulling in a lot of build time for things I don't actually use on the Mac.
And there's now a cleanup step that removes stale Homebrew download lock files before brew bundle runs on each rebuild. This is incredibly mundane but also it was causing silent failures when a previous rebuild had been interrupted mid-download, so I'm pleased it's there.
The Bit Where I Reflect
There's a version of this post where I say "I rebuilt everything and now it's perfect and I fully understand all of it." This is not that version.
What I actually have is a configuration that's meaningfully better than the one before it. The module system is being used correctly. The secrets management is cleaner. The flake evaluates properly. The server config is ambitious but at least coherent. I understand what the code is doing more often than I didn't before, which is an improvement.
The commit that kicked all this off is labelled refactor: REWRITE, with a combined impact of 5,283 lines changed across about 70 files. That's not a refactor. That's a rewrite. I lied in the commit message. This happens.
But the thing is — and this is the thing Isabel's post made me realise, more than any specific technical point — writing Nix correctly isn't about finding clever abstractions. It's about using the tools that are already there. The module system is the abstraction. nixpkgs.hostPlatform is the right way to set the system. legacyPackages is the right way to reference packages. The answers are already in the documentation and in other people's configs; the hard part is knowing enough to recognise them.
I'm not there yet. But I'm closer than I was, and I have a blog post by someone significantly better at Nix than me to thank for a chunk of it.
The repo is public: ewanc26/nix. It's at v0.3.1 now, which is a version number I added mostly because it felt more serious than "I think this works." It almost certainly isn't the last rewrite. But for now, it works.
That's enough.