<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Nixos | Bas Nijholt</title><link>https://www.nijho.lt/tag/nixos/</link><atom:link href="https://www.nijho.lt/tag/nixos/index.xml" rel="self" type="application/rss+xml"/><description>Nixos</description><generator>Hugo</generator><language>en-us</language><copyright>© 2026</copyright><lastBuildDate>Mon, 28 Sep 2026 00:00:00 +0000</lastBuildDate><image><url>https://www.nijho.lt/media/icon_hu_408a8f6cef43e327.png</url><title>Nixos</title><link>https://www.nijho.lt/tag/nixos/</link></image><item><title>Friend-to-friend ZFS backups, version two</title><link>https://www.nijho.lt/post/zfs-tenant/</link><pubDate>Mon, 28 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/zfs-tenant/</guid><description>&lt;p&gt;Two years ago I wrote about &lt;a href="https://www.nijho.lt/post/truenas-remote-backups/"&gt;friend-to-friend backups with TrueNAS&lt;/a&gt;.&#10;A friend and I store each other&amp;rsquo;s ZFS snapshots, which is the cheapest off-site backup there is.&#10;TrueNAS&amp;rsquo;s replication tasks wanted root on the receiving system, and neither of us wanted to give the other root on the machine that holds our family photos.&#10;So each of us ran a TrueNAS VM on a hypervisor, backed by an iSCSI zvol on the real NAS, and gave the other person root inside that VM instead.&#10;Sounds complicated?&#10;It was.&#10;It worked, but it meant running ZFS on a zvol on ZFS, inside a VM that each of us had to keep updated.&lt;/p&gt;&#10;&lt;p&gt;Since then I &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;replaced TrueNAS with NixOS&lt;/a&gt;, and my friend plans to move his machines to NixOS too.&#10;This time we looked for the simplest solution, built from standard Linux and ZFS features wherever possible.&#10;He wanted plain &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt;, with his data still encrypted and his keys never on my server.&#10;I agreed, on one condition: neither of us should be able to destroy the other&amp;rsquo;s datasets.&lt;/p&gt;&#10;&lt;p&gt;The result is &lt;a href="https://github.com/basnijholt/zfs-tenant" target="_blank" rel="noopener"&gt;zfs-tenant&lt;/a&gt;, which is now &lt;a href="https://pypi.org/project/zfs-tenant/" target="_blank" rel="noopener"&gt;on PyPI&lt;/a&gt; and has &lt;a href="https://zfs-tenant.nijho.lt" target="_blank" rel="noopener"&gt;documentation&lt;/a&gt;.&lt;/p&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/zfs-tenant/logo.svg" width="220" alt="The zfs-tenant logo: a storage pool with two dim compartments for the host and one locked amber compartment for a friend, filled up to a dashed quota line" loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#why-zfs"&gt;Why ZFS&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-we-wanted"&gt;What we wanted&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#zfs-already-had-most-of-it"&gt;ZFS already had most of it&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#rehearsing-the-permissions-in-a-vm"&gt;Rehearsing the permissions in a VM&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#a-gate-that-speaks-syncoid"&gt;A gate that speaks syncoid&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#zones-a-second-wall-in-the-kernel"&gt;Zones: a second wall in the kernel&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#who-stops-what"&gt;Who stops what&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-it-cannot-hide"&gt;What it cannot hide&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#setting-it-up"&gt;Setting it up&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#knowing-when-it-stops-working"&gt;Knowing when it stops working&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#restoring"&gt;Restoring&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-first-real-push"&gt;The first real push&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#tested-like-everything-else"&gt;Tested like everything else&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#ai-scope-creep-or-useful"&gt;AI scope creep or useful?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="why-zfs"&gt;Why ZFS &lt;a class="anchor" href="#why-zfs" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://openzfs.org/" target="_blank" rel="noopener"&gt;OpenZFS&lt;/a&gt; is a filesystem and a volume manager in one.&#10;You add disks to a pool, and it behaves like one large partition: every dataset you create draws from the same free space, and each can still have its own quota and encryption.&#10;ZFS checksums every block it writes, so it notices when a disk hands back something else, and with a mirror or RAIDZ it repairs the block from a good copy.&#10;That protection costs disk space: a mirror of two disks holds one disk&amp;rsquo;s worth of data.&lt;/p&gt;&#10;&lt;p&gt;For me, snapshots are one of the coolest parts.&#10;ZFS never overwrites data in place, so a snapshot only has to hold on to the blocks that existed at that moment.&#10;Taking one is instant, and it costs no space until the data changes.&#10;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-send.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs send&lt;/code&gt;&lt;/a&gt; turns a snapshot, or the difference between two snapshots, into a stream that &lt;code&gt;zfs receive&lt;/code&gt; writes into a pool on another machine, usually piped through SSH.&#10;It moves blocks, not files: an incremental send contains only the blocks that changed, and ZFS knows which ones those are without visiting a single file.&#10;A bajillion one-kilobyte files do not slow it down, because nothing has to look at them one by one.&#10;For comparison, restic on my PC needed &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;an hour and a half per run&lt;/a&gt; just to check close to a hundred million files for changes.&lt;/p&gt;&#10;&lt;p&gt;Two tools from the same project automate this.&#10;&lt;a href="https://github.com/jimsalterjrs/sanoid" target="_blank" rel="noopener"&gt;sanoid&lt;/a&gt; takes snapshots on a schedule and prunes them by policy, for example hourly ones for a day and daily ones for a month.&#10;syncoid wraps &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt; over SSH: it finds the newest snapshot both sides share and sends everything after it.&lt;/p&gt;&#10;&lt;h2 id="what-we-wanted"&gt;What we wanted &lt;a class="anchor" href="#what-we-wanted" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;We wrote down the rules before anything else:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;He cannot see any of my data, including metadata like dataset names and sizes.&lt;/li&gt;&#10;&lt;li&gt;He cannot change or destroy anything of mine.&lt;/li&gt;&#10;&lt;li&gt;I cannot read his data, because his keys stay at his house.&lt;/li&gt;&#10;&lt;li&gt;He gets one dataset where he can create and remove nested datasets as he likes.&lt;/li&gt;&#10;&lt;li&gt;That dataset has a size limit.&lt;/li&gt;&#10;&lt;li&gt;No new replication tool on either side: plain &lt;code&gt;zfs send&lt;/code&gt; or syncoid.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Until then his NAS runs TrueNAS, so whatever I built also had to work on a machine without NixOS.&lt;/p&gt;&#10;&lt;h2 id="zfs-already-had-most-of-it"&gt;ZFS already had most of it &lt;a class="anchor" href="#zfs-already-had-most-of-it" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;OpenZFS has a permission system that most people never touch: &lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-allow.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs allow&lt;/code&gt;&lt;/a&gt;.&#10;It delegates individual operations like &lt;code&gt;receive&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, and &lt;code&gt;destroy&lt;/code&gt; on one dataset to an unprivileged user.&#10;Put a root-owned &lt;code&gt;quota&lt;/code&gt; on that dataset and the user cannot store more than you agreed.&#10;Raw sends (&lt;code&gt;zfs send -w&lt;/code&gt;) ship the encrypted blocks as they are, so the receiving side never needs the key.&lt;/p&gt;&#10;&lt;p&gt;On paper, that covers five of the six rules.&#10;The gap is visibility.&#10;Any local user can run &lt;code&gt;zfs list&lt;/code&gt; and see every dataset on the machine, with names, sizes, and properties.&#10;And an SSH account for replication is, by default, also a shell.&lt;/p&gt;&#10;&lt;p&gt;There are tools that would sidestep some of this.&#10;&lt;a href="https://zrepl.github.io/" target="_blank" rel="noopener"&gt;zrepl&lt;/a&gt; has a sink mode with a subtree per client, but it replaces sanoid and syncoid on both sides and runs as root on the receiver.&#10;restic or borg to a friend&amp;rsquo;s box would also work, but everything else on my network is ZFS: my machines &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/replication.nix#L40-L49" target="_blank" rel="noopener"&gt;replicate to the NAS with syncoid&lt;/a&gt;, and a restore is &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt;.&#10;Adding another backup format means one more thing to understand when something breaks.&#10;My current off-site copy goes to &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/docker-lxc/rclone-b2-backup.nix" target="_blank" rel="noopener"&gt;Backblaze B2 with rclone&lt;/a&gt;.&#10;Ideally that would be ZFS as well, and some hosting services accept &lt;code&gt;zfs send&lt;/code&gt;, but they cost far more than object storage.&#10;A friend with a ZFS box costs nothing.&lt;/p&gt;&#10;&lt;h2 id="rehearsing-the-permissions-in-a-vm"&gt;Rehearsing the permissions in a VM &lt;a class="anchor" href="#rehearsing-the-permissions-in-a-vm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Before writing any code, I had an agent build a throwaway NixOS VM test with a real pool, a delegated user, and a list of probes.&#10;Each probe tried one thing the friend should not be able to do and recorded what ZFS answered.&lt;/p&gt;&#10;&lt;p&gt;The first surprise came from the most obvious setup.&#10;With a plain &lt;code&gt;zfs allow -u joe create,receive,destroy,... tank/friends/joe&lt;/code&gt;, the delegated user could run &lt;code&gt;zfs destroy -r tank/friends/joe&lt;/code&gt; and delete the dataset I had created for him, quota included.&#10;Delegated rights apply to the dataset itself as well as everything below it, unless you say otherwise.&#10;The fix is to split them: on the root itself only &lt;code&gt;create,mount,receive&lt;/code&gt; with &lt;code&gt;zfs allow -l&lt;/code&gt;, and the full set only for descendants with &lt;code&gt;zfs allow -d&lt;/code&gt;.&#10;After that change, destroying, snapshotting, re-delegating, or changing the quota of the root all failed with &lt;code&gt;permission denied&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;A send stream can carry properties too, such as a hostile &lt;code&gt;mountpoint=/etc&lt;/code&gt;.&#10;ZFS applies them with the receiving user&amp;rsquo;s rights, so in the VM that property failed with &lt;code&gt;permission denied&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The probes also confirmed the gap: the delegated user saw my &lt;code&gt;tank/host&lt;/code&gt; dataset in &lt;code&gt;zfs list&lt;/code&gt;, and &lt;code&gt;zfs get used tank/host&lt;/code&gt; returned its size.&lt;/p&gt;&#10;&lt;h2 id="a-gate-that-speaks-syncoid"&gt;A gate that speaks syncoid &lt;a class="anchor" href="#a-gate-that-speaks-syncoid" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The shell part has a standard answer.&#10;An &lt;code&gt;authorized_keys&lt;/code&gt; line with &lt;code&gt;restrict,command=&amp;quot;...&amp;quot;&lt;/code&gt; runs one fixed program for every login, and passes whatever the client asked for in &lt;code&gt;SSH_ORIGINAL_COMMAND&lt;/code&gt;.&#10;&lt;code&gt;zfs-tenant gate&lt;/code&gt; is that program.&#10;It tokenizes the request, accepts only the handful of command shapes a backup needs, checks that every dataset name is inside the friend&amp;rsquo;s dataset, and runs &lt;code&gt;zfs&lt;/code&gt; with an argument list it builds itself.&#10;It never starts a shell, so there is nothing to inject into.&lt;/p&gt;&#10;&lt;p&gt;The hard part was knowing exactly which commands syncoid sends, so an agent read &lt;a href="https://github.com/jimsalterjrs/sanoid/blob/v2.3.0/syncoid" target="_blank" rel="noopener"&gt;syncoid&amp;rsquo;s Perl&lt;/a&gt; and logged every remote command it ran.&#10;Most are the &lt;code&gt;zfs&lt;/code&gt; commands you would expect.&#10;One surprise: before each receive, syncoid runs &lt;code&gt;ps -Ao args=&lt;/code&gt; on the receiving machine, to check that no other transfer is already writing to the same dataset.&#10;That command prints every running process with its full command line.&#10;On my NAS, it would show my friend everything I run, so the gate answers with an empty list.&lt;/p&gt;&#10;&lt;p&gt;Reading syncoid that closely also turned up a remote code execution (RCE) bug in version 2.3.0, outside zfs-tenant itself.&#10;In push mode, syncoid asks the receiving host for a ZFS resume token.&#10;It passed that answer unescaped into shell commands for estimating and resuming the send, which run on the source machine.&#10;A malicious or compromised backup host could therefore return shell syntax instead of a token and execute arbitrary commands on the sender with the privileges of the syncoid process.&lt;/p&gt;&#10;&lt;p&gt;I reproduced this in two isolated NixOS VMs using real ZFS and SSH: a harmless command embedded in the receiver&amp;rsquo;s answer created a marker file on the sender.&#10;I reported it to the maintainer and opened &lt;a href="https://github.com/jimsalterjrs/sanoid/pull/1114" target="_blank" rel="noopener"&gt;sanoid PR #1114&lt;/a&gt; with a small fix that shell-quotes the token at both uses.&#10;Even with that fix, the sending side should run syncoid as an unprivileged user that may only &lt;code&gt;send&lt;/code&gt; and &lt;code&gt;hold&lt;/code&gt;.&#10;nixpkgs&amp;rsquo; &lt;code&gt;services.syncoid&lt;/code&gt; already works that way, so my friend needs nothing from zfs-tenant at all.&lt;/p&gt;&#10;&lt;h2 id="zones-a-second-wall-in-the-kernel"&gt;Zones: a second wall in the kernel &lt;a class="anchor" href="#zones-a-second-wall-in-the-kernel" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;At that point the gate was the only thing hiding my datasets.&#10;Then my friend sent me a message: ZFS has a feature called zones, which restricts a dataset tree to a Linux user namespace.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-zone.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs zone&lt;/code&gt;&lt;/a&gt; attaches a dataset to one user namespace, and inside that namespace the ZFS kernel module answers &lt;code&gt;dataset does not exist&lt;/code&gt; for everything that is not attached.&#10;Even if my gate had a bug that let arbitrary &lt;code&gt;zfs&lt;/code&gt; commands through, &lt;code&gt;tank/host&lt;/code&gt; would stay invisible.&#10;The zone does not replace the gate, though: it only filters ZFS, and it only applies once the gate has put the SSH session inside it.&#10;&lt;a href="#who-stops-what"&gt;Who stops what&lt;/a&gt; shows how the two divide the work.&lt;/p&gt;&#10;&lt;p&gt;A user namespace can give its processes different user IDs than they have outside.&#10;Containers usually use that to make the user root inside the namespace, while it stays an ordinary user outside.&#10;ZFS treats root inside a zone as the zone&amp;rsquo;s administrator, allowed to do anything to the attached datasets, whatever &lt;code&gt;zfs allow&lt;/code&gt; says.&#10;In the VM test, that let the friend destroy the dataset I had created for him.&#10;So zfs-tenant keeps him as his own ordinary user inside the namespace: he has no special rights there, &lt;code&gt;zfs allow&lt;/code&gt; decides what he may do, and the kernel still hides everything else.&lt;/p&gt;&#10;&lt;p&gt;Root inside a user namespace also turned up in a real OpenZFS bug in August: before 2.4.4, 2.3.9, and 2.2.11, several pool operations, such as destroying a pool, accepted it as host root (&lt;a href="https://github.com/openzfs/zfs/security/advisories/GHSA-mhf5-q8gw-qg9v" target="_blank" rel="noopener"&gt;CVE-2026-79619&lt;/a&gt;).&#10;Joe cannot reach that through the gate, but zfs-tenant needs unprivileged user namespaces, so a host running it should load a patched module: &lt;code&gt;cat /sys/module/zfs/version&lt;/code&gt; shows the loaded one, which only changes after a reboot.&lt;/p&gt;&#10;&lt;p&gt;A zone needs a running namespace to attach to, so zfs-tenant runs a small service per friend that holds one.&#10;The gate joins that namespace before it looks at the command, drops the capabilities that joining grants, and refuses to run if the service is down.&lt;/p&gt;&#10;&lt;h2 id="who-stops-what"&gt;Who stops what &lt;a class="anchor" href="#who-stops-what" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;When Joe and I talked it through, I found it hard to say clearly which part protects against what, so I made this diagram.&lt;/p&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/zfs-tenant/layers.svg" alt="Diagram of seven things Joe&amp;#39;s key tries and the layer that stops each one: the network, the SSH gate, the zone, and zfs allow with the quota. Only his raw encrypted backup reaches my pool, where it stays unreadable to me." loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;p&gt;Each column answers one question: who can connect, what he can run, what ZFS shows him, and what he can change.&#10;For the network, our tailnet only lets his router reach port 22 on my NAS, and &lt;code&gt;from=&lt;/code&gt; in &lt;code&gt;authorized_keys&lt;/code&gt; only accepts his key from that address.&#10;Raw sends then keep whatever reaches my pool unreadable to me.&lt;/p&gt;&#10;&lt;p&gt;The gate and the zone overlap in one job: hiding my other datasets.&#10;The gate does it first, by refusing any name outside his root before &lt;code&gt;zfs&lt;/code&gt; runs.&#10;The zone repeats that check in the kernel, so hiding my datasets would take a bug in both.&#10;Everything else the gate does, the zone cannot:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Without the gate, his key would be a normal account on my NAS. The zone only filters ZFS; it would not stop him from reading &lt;code&gt;/etc&lt;/code&gt; or running programs.&lt;/li&gt;&#10;&lt;li&gt;An SSH login does not start inside a user namespace. The gate joins the zone before it runs anything, so without the gate the zone would never apply.&lt;/li&gt;&#10;&lt;li&gt;The zone leaves some things visible: &lt;code&gt;zpool status&lt;/code&gt; still shows my disks and how full the pool is, and the datasets above his root still show their sizes. User namespaces do not hide processes either, hence the empty answer to &lt;code&gt;ps&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;It deletes any unencrypted dataset he pushes by mistake, so I never keep his plaintext.&lt;/li&gt;&#10;&lt;li&gt;It logs every command, which is how I saw Joe try &lt;code&gt;bash&lt;/code&gt; below.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The &lt;a href="https://zfs-tenant.nijho.lt/security/" target="_blank" rel="noopener"&gt;security model&lt;/a&gt; in the documentation lists every promise with the mechanism behind it.&lt;/p&gt;&#10;&lt;h2 id="what-it-cannot-hide"&gt;What it cannot hide &lt;a class="anchor" href="#what-it-cannot-hide" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;ZFS encryption protects file contents, not structure.&#10;In the words of &lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-load-key.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-load-key(8)&lt;/code&gt;&lt;/a&gt;, ZFS &amp;ldquo;will not encrypt metadata related to the pool structure, including dataset and snapshot names, dataset hierarchy, properties, file size, file holes, and deduplication tables.&amp;rdquo;&#10;So I can see the names, sizes, and snapshot times of whatever my friend sends me.&#10;Boring dataset names help, and sending without &lt;code&gt;-p&lt;/code&gt; keeps properties out of the stream.&lt;/p&gt;&#10;&lt;p&gt;I can also always delete his copy, because I am root on my own machine.&#10;What I can never do is read it.&#10;My &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/storage.nix#L71-L81" target="_blank" rel="noopener"&gt;monthly scrubs&lt;/a&gt; still verify his data without his key, because ZFS checksums the encrypted blocks.&lt;/p&gt;&#10;&lt;p&gt;What we protect against is a dead machine: a failed pool, a fire, a flood.&#10;His key may destroy anything below his root, which is what lets syncoid mirror his snapshot retention, so someone who steals that key can also delete his backups on my NAS.&#10;Covering that would take holds that I place as root and release on a schedule.&#10;We left them out on purpose, although I am &lt;a href="#ai-scope-creep-or-useful"&gt;reconsidering&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="setting-it-up"&gt;Setting it up &lt;a class="anchor" href="#setting-it-up" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;On a NixOS host, giving a friend space takes this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zfs-tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;tenants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;joe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tank/friends/joe&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;quota&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2T&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;authorizedKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... joe-nas&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;allowedFrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;100.64.0.12&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;# his tailnet address&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That creates the user, pins the key to the gate, applies the dataset, properties, and delegation on every boot, and runs the zone service.&#10;My own setup is in &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L43-L50" target="_blank" rel="noopener"&gt;&lt;code&gt;friend-backups.nix&lt;/code&gt;&lt;/a&gt;, with his real key and address.&#10;If your sanoid snapshots the whole pool, &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L52-L59" target="_blank" rel="noopener"&gt;exclude the tenant tree&lt;/a&gt;: your sanoid must neither snapshot nor prune it, or his next incremental push finds snapshots on the target that he never sent.&#10;Once my friend is on NixOS, he can push with nixpkgs&amp;rsquo; own module:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;programs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;knownHosts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bas-nas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publicKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;syncoid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sshKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/var/lib/syncoid/id_ed25519&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;localSourceAllow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;send&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;hold&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;commonArgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--no-sync-snap&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--compress=none&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--delete-target-snapshots&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--sshoption=StrictHostKeyChecking=yes&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;tank/offsite&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;zfs-tenant-joe@bas-nas:tank/friends/joe/offsite&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;recursive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sendOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;w&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My NAS will push to his with the &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L66-L91" target="_blank" rel="noopener"&gt;same configuration&lt;/a&gt; once he hosts a root for me.&lt;/p&gt;&#10;&lt;p&gt;For his TrueNAS box, every &lt;a href="https://github.com/basnijholt/zfs-tenant/releases" target="_blank" rel="noopener"&gt;release&lt;/a&gt; has a single &lt;code&gt;zfs-tenant.pyz&lt;/code&gt; that runs with the Python TrueNAS already ships, and the &lt;a href="https://zfs-tenant.nijho.lt/getting-started/" target="_blank" rel="noopener"&gt;getting started guide&lt;/a&gt; covers both setups.&#10;Hosting on TrueNAS has only run in VMs so far (&lt;a href="https://github.com/basnijholt/zfs-tenant/issues/4" target="_blank" rel="noopener"&gt;issue #4&lt;/a&gt;).&#10;Sending from TrueNAS is where its built-in tools stop working: replication tasks wrap every remote command in &lt;code&gt;sh -c&lt;/code&gt;, which the gate refuses, so he pushes with &lt;code&gt;zfs send&lt;/code&gt; or syncoid instead.&lt;/p&gt;&#10;&lt;h2 id="knowing-when-it-stops-working"&gt;Knowing when it stops working &lt;a class="anchor" href="#knowing-when-it-stops-working" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;last btrfs machine&lt;/a&gt; taught me a fifth question for any backup: how do I find out when it stops working?&#10;A failed push shows up in &lt;code&gt;systemctl status&lt;/code&gt; on the sending side, which nobody reads.&#10;The check belongs on the receiving side, and it has to look at every pushed dataset separately, because one healthy dataset can hide another that stopped replicating.&#10;That is how I watch my own machines: the NAS &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/replication.nix#L332-L352" target="_blank" rel="noopener"&gt;checks the newest snapshot of every dataset it replicates&lt;/a&gt; every hour and alerts my phone when one is too old.&#10;zfs-tenant does not alert yet; &lt;a href="https://github.com/basnijholt/zfs-tenant/issues/3" target="_blank" rel="noopener"&gt;issue #3&lt;/a&gt; tracks it.&lt;/p&gt;&#10;&lt;h2 id="restoring"&gt;Restoring &lt;a class="anchor" href="#restoring" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The gate also allows &lt;code&gt;zfs send&lt;/code&gt; of his own snapshots, so getting data back is one pipe, run at his house:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh zfs-tenant-joe@bas-nas zfs send -w tank/friends/joe/offsite/photos@autosnap_2026-09-25_00:00:01_daily &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; zfs receive -u tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zfs load-key tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zfs mount tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My photos are a child dataset that inherits its key from an encryption root, and I only push the child.&#10;If my NAS dies and I pull the child back, can I still unlock it without the root?&#10;A throwaway VM says yes: a raw send carries the dataset&amp;rsquo;s own wrapped key, so the child arrives as its own encryption root and opens with the same passphrase.&#10;The one catch: after a &lt;code&gt;zfs change-key&lt;/code&gt;, the next push carries the new wrapping, so the passphrase that matters is the one at the time of the last push, and it has to live somewhere other than the NAS.&#10;Mine does: &lt;a href="https://github.com/basnijholt/zfs-unlock" target="_blank" rel="noopener"&gt;zfs-unlock&lt;/a&gt; keeps the passphrases on a separate device and &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/zfs-unlock.nix" target="_blank" rel="noopener"&gt;unlocks the NAS&lt;/a&gt; after every boot.&lt;/p&gt;&#10;&lt;h2 id="the-first-real-push"&gt;The first real push &lt;a class="anchor" href="#the-first-real-push" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Once &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix" target="_blank" rel="noopener"&gt;my side was deployed&lt;/a&gt;, Joe tried it from his TrueNAS box.&#10;His first move was to see what else his key could do.&#10;The gate logged every attempt on my NAS:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied interactive session&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;ls&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;/usr/bin/bash&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;/bin/bash&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe allowed &amp;#39;zfs receive -s -u tank/friends/joe/zt-test&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The last line is a small encrypted test dataset, sent with &lt;code&gt;zfs send -w&lt;/code&gt; piped into &lt;code&gt;ssh&lt;/code&gt;, without syncoid.&#10;It arrived with &lt;code&gt;keystatus&lt;/code&gt; set to &lt;code&gt;unavailable&lt;/code&gt;.&#10;Then I tried to read it as root on my own machine.&#10;&lt;code&gt;zfs mount&lt;/code&gt; answered &lt;code&gt;cannot mount 'tank/friends/joe/zt-test': dataset is exported to a local zone&lt;/code&gt;, and &lt;code&gt;zfs load-key&lt;/code&gt; with a guessed passphrase gave &lt;code&gt;Key load error: Incorrect key provided&lt;/code&gt;.&#10;The only readable text in the raw stream was ZFS property names and the snapshot name, which is the metadata ZFS leaves unencrypted, as described above.&lt;/p&gt;&#10;&lt;h2 id="tested-like-everything-else"&gt;Tested like everything else &lt;a class="anchor" href="#tested-like-everything-else" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Every security claim in this post is also checked by a VM test: two NixOS machines back each other up, the way my friend and I do, with real sanoid and syncoid 2.3.0, through real sshd and the gate, into real OpenZFS 2.4.4.&#10;It also checks what happens over time, such as snapshot retention and both machines rebooting, and it runs on every push in GitHub Actions.&lt;/p&gt;&#10;&lt;p&gt;The route to the &lt;a href="https://github.com/jimsalterjrs/sanoid/pull/1114" target="_blank" rel="noopener"&gt;remote code execution bug in syncoid&lt;/a&gt; was slightly absurd.&#10;I built the initial version of zfs-tenant through coding agents, literally from the bathtub, and then asked several frontier models to review the security boundary independently.&#10;One of them followed receiver-controlled data beyond my new code and into syncoid.&#10;A model&amp;rsquo;s suspicion is not evidence, so I turned it into the two-VM reproduction above: it failed on unpatched syncoid 2.3.0 and passed after the seven-line fix.&#10;The most consequential result of reviewing my small new project was a command-injection bug in the mature tool next to it.&lt;/p&gt;&#10;&lt;p&gt;The whole thing is about 930 lines of Python, not counting comments and docstrings, with no dependencies.&#10;The kernel does the actual enforcing: delegation, the quota, and the zone.&#10;The gate only removes the shell, and it is small enough to read in one sitting.&lt;/p&gt;&#10;&lt;h2 id="ai-scope-creep-or-useful"&gt;AI scope creep or useful? &lt;a class="anchor" href="#ai-scope-creep-or-useful" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The first version had &lt;em&gt;grace holds&lt;/em&gt;: every day, the host placed a hold on the newest snapshot of each of my friend&amp;rsquo;s datasets and released it 14 days later.&#10;ZFS will not destroy a held snapshot until the hold is released, and only the host can release it.&#10;The agent built it without me asking for it, so I removed it right away as scope creep.&lt;/p&gt;&#10;&lt;p&gt;Then every model I asked to review the project suggested them again, each in its own context window: Opus 5.5, GPT-6 Astra, and even the one that reviewed a draft of this post.&#10;Joe and I had set out to protect against a machine dying (a fire or a flood), not against one of our own machines being hacked.&#10;But I am tempted to add the holds back.&#10;They cover the case where malware or an attacker gets onto my machine and uses my key to delete my backups on his NAS, or to replace them with garbage.&#10;Adding the feature without asking was still wrong; the feature itself might not be.&lt;/p&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/zfs-tenant" target="_blank" rel="noopener"&gt;zfs-tenant on GitHub&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://zfs-tenant.nijho.lt" target="_blank" rel="noopener"&gt;zfs-tenant documentation&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/truenas-remote-backups/"&gt;Friend-to-friend TrueNAS backups without the cloud&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;Migrating from TrueNAS to NixOS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;The last btrfs machine: migrating my PC to ZFS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-allow.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-allow(8)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-zone.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-zone(8)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/jimsalterjrs/sanoid" target="_blank" rel="noopener"&gt;Sanoid and syncoid&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>My declarative multi-machine homelab, explained</title><link>https://www.nijho.lt/post/homelab-explained/</link><pubDate>Mon, 28 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/homelab-explained/</guid><description>&lt;p&gt;I open &lt;code&gt;https://mealie.lab.nijho.lt&lt;/code&gt; on my phone to look up a recipe.&#10;It works at home, on a train, or on hotel Wi-Fi in another country, over an encrypted HTTPS connection.&#10;If a stranger on the internet tries the same address, they get nothing.&#10;Nearly everything I run works this way: about 150 containers on four machines, used every day by seven people besides me.&#10;How that works is the thing friends actually ask me about, and it is what my &lt;a href="https://www.nijho.lt/post/homelab/"&gt;2024 homelab post&lt;/a&gt; never explained.&lt;/p&gt;&#10;&lt;p&gt;That post was a story about hardware: a NUC, an HP EliteDesk, a &lt;a href="https://www.truenas.com/" target="_blank" rel="noopener"&gt;TrueNAS&lt;/a&gt; box, and a lot of trial and error.&#10;Almost everything in it has since been replaced.&#10;&lt;a href="https://www.proxmox.com/" target="_blank" rel="noopener"&gt;Proxmox&lt;/a&gt; and TrueNAS are gone, and every machine runs &lt;a href="https://nixos.org/" target="_blank" rel="noopener"&gt;NixOS&lt;/a&gt;, &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;including the NAS&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The part I think is the coolest is the balance I found: everything is declarative, but with as little machinery as possible.&#10;&lt;strong&gt;Declarative&lt;/strong&gt; means I describe the end result in text files, and a tool makes reality match it.&#10;One extreme is what I had before, clicking through web UIs and running one-off install scripts.&#10;The other is &lt;a href="https://kubernetes.io/" target="_blank" rel="noopener"&gt;Kubernetes&lt;/a&gt;, which many self-hosted projects don&amp;rsquo;t support and which is a lot to babysit at home, or running every app as a NixOS module, which often lags behind upstream.&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;&#10;I landed in between: NixOS declares the machines, each project&amp;rsquo;s own &lt;a href="https://docs.docker.com/compose/" target="_blank" rel="noopener"&gt;Compose&lt;/a&gt; file declares its app, and &lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt;, a thin tool I wrote, decides which machine runs what, which is all the multi-host orchestration I need.&#10;Every layer is a text file in &lt;a href="https://git-scm.com/" target="_blank" rel="noopener"&gt;git&lt;/a&gt;, each uses the simplest tool that keeps it that way, and I still get new app releases as soon as upstream ships them.&#10;The price is that nothing fails over automatically, but because every machine sees the same files and data, moving services to another machine is a one-line change and one command.&lt;/p&gt;&#10;&lt;p&gt;It took me several years and many iterations to get here.&#10;This is the first version I would recommend to friends, because its declarative configuration makes changes auditable, reproducible, and reversible.&#10;That is what makes me comfortable managing it with AI: I can review the diff, rebuild the same configuration, and revert a change if needed.&lt;/p&gt;&#10;&lt;p&gt;This post tries to explain it all.&#10;I wrote it for people who have never set up a reverse proxy or a VPN, so every piece gets a short explanation before I show how I configured it.&#10;I deliberately made it comprehensive, with enough detail to build something similar yourself.&#10;That also makes it long, so read the parts you find interesting and skip the rest.&lt;/p&gt;&#10;&lt;p&gt;If it is too long, send it to your AI agent, discuss it, and figure out together which parts make sense for your own network.&lt;/p&gt;&#10;&lt;p&gt;If you have a single machine that already runs NixOS, you probably don&amp;rsquo;t need &lt;a href="https://www.docker.com/" target="_blank" rel="noopener"&gt;Docker&lt;/a&gt; or compose-farm at all: adding your services as NixOS modules is simpler, and that is what I recommend to friends in that situation.&#10;If you would rather use Docker, which is usually the path a project officially supports, I recommend compose-farm: it works just as well on a single host and lets you fan out later.&#10;The multi-machine part is where my setup really pays off, but the rest of this post, from the reverse proxy to the VPNs, is just as useful on one machine.&lt;/p&gt;&#10;&lt;p&gt;Throughout the post I use a few services as running examples: &lt;a href="https://mealie.io/" target="_blank" rel="noopener"&gt;Mealie&lt;/a&gt; for recipes, &lt;a href="https://ntfy.sh/" target="_blank" rel="noopener"&gt;ntfy&lt;/a&gt; for push notifications, and my git server at &lt;code&gt;git.nijho.lt&lt;/code&gt;.&lt;/p&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#the-big-picture"&gt;The big picture&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-machines-and-nixos"&gt;The machines, and NixOS&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-nixos-brings"&gt;What NixOS brings&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#deploying-nixos-with-comin"&gt;Deploying NixOS with comin&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-runs-on-it"&gt;What runs on it&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#containers-and-compose-farm"&gt;Containers and compose-farm&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-a-container-is"&gt;What a container is&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#upstreams-compose-file-my-folders"&gt;Upstream&amp;rsquo;s Compose file, my folders&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-i-wrote-compose-farm"&gt;Why I wrote compose-farm&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-front-door-a-reverse-proxy"&gt;The front door: a reverse proxy&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-a-reverse-proxy-brings"&gt;What a reverse proxy brings&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#services-announce-themselves"&gt;Services announce themselves&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#services-on-other-machines"&gt;Services on other machines&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#https-and-certificates"&gt;HTTPS and certificates&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-a-certificate-is"&gt;What a certificate is&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#proving-i-own-the-domain-without-opening-my-network"&gt;Proving I own the domain without opening my network&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#dns-the-phone-book"&gt;DNS: the phone book&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-dns-does"&gt;What DNS does&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#three-answers-for-the-same-name"&gt;Three answers for the same name&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#four-ways-in"&gt;Four ways in&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#1-the-home-network"&gt;1. The home network&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-wireguard-on-the-router"&gt;2. WireGuard on the router&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-tailscale-via-my-own-headscale"&gt;3. Tailscale, via my own Headscale&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-both"&gt;Why both?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-tailscale-dns-problem"&gt;The Tailscale DNS problem&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-the-internet"&gt;4. The internet&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#keeping-it-safe-one-allowlist"&gt;Keeping it safe: one allowlist&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#dns-is-not-a-lock"&gt;DNS is not a lock&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#who-is-knocking"&gt;Who is knocking&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#private-by-default"&gt;Private by default&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#trusting-my-own-network"&gt;Trusting my own network&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#sharing-with-friends-and-family"&gt;Sharing with friends and family&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-not-cloudflare-tunnel-or-tailscales-own-server"&gt;Why not Cloudflare Tunnel or Tailscale&amp;rsquo;s own server?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#declarative-everything"&gt;Declarative everything&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#why-it-matters"&gt;Why it matters&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#putting-it-together-adding-a-service"&gt;Putting it together: adding a service&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#lessons-learned"&gt;Lessons learned&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="the-big-picture"&gt;The big picture &lt;a class="anchor" href="#the-big-picture" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;To reach my services away from home, I use a &lt;strong&gt;VPN&lt;/strong&gt; (virtual private network): an encrypted connection that lets my devices communicate across the internet as part of a private network.&#10;Three projects fit together here:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.wireguard.com/" target="_blank" rel="noopener"&gt;WireGuard&lt;/a&gt;&lt;/strong&gt; provides the encrypted tunnels. I use it directly between my devices and my home router.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://tailscale.com/" target="_blank" rel="noopener"&gt;Tailscale&lt;/a&gt;&lt;/strong&gt; builds a private network using WireGuard tunnels. Its apps handle finding and connecting to the other devices.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://headscale.net/" target="_blank" rel="noopener"&gt;Headscale&lt;/a&gt;&lt;/strong&gt; replaces the coordination server that Tailscale normally hosts. I run that server myself, while my devices run the normal Tailscale apps. Headscale distributes device information and access rules; the apps carry the traffic.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;That gives me two VPN options: WireGuard to my router for access to my home network, or Tailscale coordinated by Headscale for sharing selected services and connecting from networks that block direct WireGuard.&#10;Both use WireGuard encryption.&lt;/p&gt;&#10;&lt;p&gt;This is the whole setup in one diagram.&#10;Every box gets its own section below.&lt;/p&gt;&#10;&lt;div class="mermaid"&gt;flowchart LR&#10; home["At home&lt;br/&gt;(home network)"]&#10; wg["Away&lt;br/&gt;(WireGuard)"]&#10; ts["Away&lt;br/&gt;(Tailscale)"]&#10; friends["Friends and family&lt;br/&gt;(Tailscale)"]&#10; net["Anyone&lt;br/&gt;(internet)"]&#10;&#10; subgraph router["Home router"]&#10; wgs["WireGuard server"]&#10; fwd["Port 443 forward"]&#10; end&#10;&#10; home --&gt; traefik&#10; wg --&gt; wgs --&gt; traefik&#10; ts -- "encrypted tunnel&lt;br/&gt;straight to the NAS" --&gt; traefik&#10; net --&gt; fwd -- "wan entrypoint:&lt;br/&gt;public routes only" --&gt; traefik&#10;&#10; traefik{{"Traefik&lt;br/&gt;reverse proxy&lt;br/&gt;+ IP allowlist"}}&#10; traefik --&gt; nas["Containers on nas"]&#10; traefik --&gt; nuc["Containers on nuc"]&#10; traefik --&gt; hp["Containers on hp"]&#10; traefik --&gt; pc["Containers on pc"]&#10;&#10; friends -. "ACL: only the ports&lt;br/&gt;shared with them" .-&gt; nas&#10; friends -.-&gt; pc&lt;/div&gt;&#10;&lt;p&gt;In short:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Four machines&lt;/strong&gt; run all my services as containers.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;compose-farm&lt;/strong&gt; decides which service runs on which machine.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One reverse proxy, &lt;a href="https://traefik.io/traefik/" target="_blank" rel="noopener"&gt;Traefik&lt;/a&gt;,&lt;/strong&gt; is the front door for every service on every machine.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://letsencrypt.org/" target="_blank" rel="noopener"&gt;Let&amp;rsquo;s Encrypt&lt;/a&gt; certificates&lt;/strong&gt; give every service real HTTPS, including the private ones.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;DNS&lt;/strong&gt; turns names like &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; into addresses, and gives a &lt;em&gt;different&lt;/em&gt; answer depending on where I am.&lt;/li&gt;&#10;&lt;li&gt;There are &lt;strong&gt;four ways in&lt;/strong&gt;: my home network, WireGuard on my router, Tailscale coordinated by my own Headscale server, and the open internet.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One allowlist&lt;/strong&gt; in Traefik limits every service to the first three by default. A service reaches the internet only if its config asks for it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Headscale ACLs&lt;/strong&gt; decide which of my friends and family can reach which service.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;NixOS, Compose files, and &lt;a href="https://www.terraform.io/" target="_blank" rel="noopener"&gt;Terraform&lt;/a&gt;&lt;/strong&gt; describe nearly all of it in git, so I rarely click a button, and AI agents can work on it the same way I do.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;If that list looks overwhelming, I get it.&#10;A reverse proxy, Let&amp;rsquo;s Encrypt, DNS, an allowlist, ACLs, NixOS, compose-farm, Terraform: that is a lot of moving parts for something that serves recipes.&#10;What lets me sleep at night is the last point (9).&#10;Apart from a handful of router settings, every piece of configuration is declarative and lives in git, so the whole setup is reproducible.&#10;If I break something, or more likely, an AI agent breaks something, &lt;code&gt;git log&lt;/code&gt; tells me what changed and &lt;code&gt;git revert&lt;/code&gt; undoes it.&#10;And the NAS takes a &lt;a href="https://openzfs.org/" target="_blank" rel="noopener"&gt;ZFS&lt;/a&gt; snapshot of all app data every 10 minutes, so even if something goes badly wrong, I lose at most ten minutes of data.&#10;You don&amp;rsquo;t need to understand every piece at once, either; each one is a file you can read when you get to it.&#10;I come back to this in &lt;a href="#declarative-everything"&gt;Declarative everything&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;To make the list concrete, this is what happens when I open Mealie in three situations:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;At home,&lt;/strong&gt; my phone asks my home DNS server for &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; and gets &lt;code&gt;192.168.1.6&lt;/code&gt;, the NAS. Traefik sees a request from &lt;code&gt;192.168.1.x&lt;/code&gt;, which is on the allowlist, and passes it to the Mealie container.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;On hotel Wi-Fi that blocks WireGuard,&lt;/strong&gt; my laptop uses Tailscale instead. It asks Headscale&amp;rsquo;s DNS for the same name and gets &lt;code&gt;100.64.0.28&lt;/code&gt;, the address of the NAS &lt;em&gt;inside&lt;/em&gt; my Tailscale network. The request travels through an encrypted tunnel straight to the NAS. Traefik sees a &lt;code&gt;100.64.0.x&lt;/code&gt; address, also on the allowlist, and passes it on.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A stranger on the internet&lt;/strong&gt; gets &lt;code&gt;192.168.1.6&lt;/code&gt; from public DNS, which is a private address that leads nowhere outside my home. If they find my home IP and connect to it directly, the router hands them to Traefik&amp;rsquo;s separate door for the internet, where Mealie doesn&amp;rsquo;t exist, and they get &lt;code&gt;404 Not Found&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Same name, HTTPS on all three paths, and only the stranger is refused.&lt;/p&gt;&#10;&lt;h2 id="the-machines-and-nixos"&gt;The machines, and NixOS &lt;a class="anchor" href="#the-machines-and-nixos" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Four machines run my services:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Machine&lt;/th&gt;&#10;&lt;th&gt;What it is&lt;/th&gt;&#10;&lt;th&gt;What it does&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/nas" target="_blank" rel="noopener"&gt;&lt;code&gt;nas&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;The physical NAS with all the disks&lt;/td&gt;&#10;&lt;td&gt;Traefik and most services run in an &lt;a href="https://linuxcontainers.org/incus/" target="_blank" rel="noopener"&gt;Incus&lt;/a&gt; system container, close to the data&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/nuc" target="_blank" rel="noopener"&gt;&lt;code&gt;nuc&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;A small, always-on Intel NUC&lt;/td&gt;&#10;&lt;td&gt;Home DNS server and a few lightweight services&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/hp" target="_blank" rel="noopener"&gt;&lt;code&gt;hp&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;An HP EliteDesk&lt;/td&gt;&#10;&lt;td&gt;Second home DNS server and a mix of services&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/pc" target="_blank" rel="noopener"&gt;&lt;code&gt;pc&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;My desktop with two RTX 3090s&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.nijho.lt/post/llama-nixos/"&gt;Local AI&lt;/a&gt;, including my dictation server&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Each name links to that machine&amp;rsquo;s NixOS configuration.&#10;The hardware of the NUC, the HP, and the NAS is in &lt;a href="https://www.nijho.lt/post/homelab/"&gt;my original homelab post&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;That system container is called &lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/docker-lxc" target="_blank" rel="noopener"&gt;&lt;code&gt;docker-lxc&lt;/code&gt;&lt;/a&gt;.&#10;It holds my Docker stack files and runs my Docker containers.&#10;In compose-farm, I call it &lt;code&gt;nas&lt;/code&gt;, so deployment commands using that name target the container on the physical NAS.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;Why the NAS runs its containers inside another container (click to unfold)&lt;/summary&gt;&#10; Incus uses &lt;a href="https://linuxcontainers.org/lxc/" target="_blank" rel="noopener"&gt;LXC&lt;/a&gt; to run system containers.&#10;This keeps Docker and its services separate from the NAS host that manages my disks.&#10;That container is a full NixOS system of its own, configured like the other machines.&#10;&lt;/details&gt;&#10;&#10;&lt;h3 id="what-nixos-brings"&gt;What NixOS brings &lt;a class="anchor" href="#what-nixos-brings" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;All four physical machines run &lt;a href="https://nixos.org/" target="_blank" rel="noopener"&gt;NixOS&lt;/a&gt;, and so does the &lt;code&gt;docker-lxc&lt;/code&gt; container.&#10;In NixOS, the &lt;em&gt;entire&lt;/em&gt; operating system is described in text files: which packages are installed, which services run, which disks get mounted, which firewall ports are open.&#10;You don&amp;rsquo;t install things by typing commands and hoping you remember them later.&#10;You write down what the machine should look like, and NixOS makes it so.&#10;If a machine dies, I install NixOS on a new one, point it at the same files, and get the same machine back.&lt;/p&gt;&#10;&lt;p&gt;My NixOS configuration lives in &lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;my public dotfiles&lt;/a&gt;, in &lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos" target="_blank" rel="noopener"&gt;&lt;code&gt;configs/nixos&lt;/code&gt;&lt;/a&gt;.&#10;A shared layer applies to every machine, and each machine adds its own specifics.&#10;Every machine joins my Tailscale network with one line in &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/common/services.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;common/services.nix&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tailscale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The NUC, the HP, and the PC mount the same shared folders from the NAS over NFS, a protocol for sharing folders over the network.&#10;That shared folder, &lt;code&gt;/opt/stacks&lt;/code&gt;, is what makes the next section work: every machine sees the same service definitions at the same path.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;The NFS mount in NixOS (click to unfold)&lt;/summary&gt;&#10; &lt;p&gt;From &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/optional/nfs-docker.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;optional/nfs-docker.nix&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;fileSystems&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/opt/stacks&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;truenas.local:/mnt/ssd/docker/stacks&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fsType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;nfs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;nfsvers=4&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;nofail&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;bg&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;soft&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;docker-lxc&lt;/code&gt; doesn&amp;rsquo;t need NFS; the NAS hands it the same folders directly from its disks (&lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/hosts/nas/virtualization.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;hosts/nas/virtualization.nix&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;h3 id="deploying-nixos-with-comin"&gt;Deploying NixOS with comin &lt;a class="anchor" href="#deploying-nixos-with-comin" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Changing a NixOS machine normally means logging in and running &lt;code&gt;nixos-rebuild switch&lt;/code&gt;.&#10;I don&amp;rsquo;t do that anymore.&#10;Every machine runs &lt;a href="https://github.com/nlewo/comin" target="_blank" rel="noopener"&gt;comin&lt;/a&gt;, a small GitOps agent.&#10;It watches my dotfiles on GitHub, and when a new commit lands on &lt;code&gt;main&lt;/code&gt;, each machine pulls it, builds its own configuration, and switches to it.&#10;The heavy builds come from &lt;a href="https://www.nijho.lt/post/nixos-cache/"&gt;my local build cache&lt;/a&gt;, so the machines themselves rarely compile anything.&#10;comin only deploys commits signed with my SSH key, so being able to push to the repo is not enough to take over my machines.&#10;comin originally only understood &lt;a href="https://gnupg.org/" target="_blank" rel="noopener"&gt;GPG&lt;/a&gt; signatures, so I &lt;a href="https://github.com/nlewo/comin/pull/171" target="_blank" rel="noopener"&gt;added support for SSH-signed commits&lt;/a&gt; upstream.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;The comin config (click to unfold)&lt;/summary&gt;&#10; &lt;p&gt;The core of &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/common/comin.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;common/comin.nix&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;comin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;remotes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;origin&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://github.com/basnijholt/dotfiles.git&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;branches&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;main&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;repositorySubdir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;configs/nixos&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sshAllowedSignersPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;cominAllowedSigners&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The last line points comin at the list of keys whose signatures it accepts.&lt;/p&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;p&gt;Changing a machine means pushing a signed commit.&#10;That comes back at the end of this post.&lt;/p&gt;&#10;&lt;h2 id="what-runs-on-it"&gt;What runs on it &lt;a class="anchor" href="#what-runs-on-it" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;As I write this, the four machines run 138 containers. A small selection:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Area&lt;/th&gt;&#10;&lt;th&gt;Services&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://traefik.io/traefik/" target="_blank" rel="noopener"&gt;Traefik&lt;/a&gt; (reverse proxy), &lt;a href="https://headscale.net/" target="_blank" rel="noopener"&gt;Headscale&lt;/a&gt; and &lt;a href="https://github.com/tale/headplane" target="_blank" rel="noopener"&gt;Headplane&lt;/a&gt; (my tailnet), &lt;a href="https://forgejo.org/" target="_blank" rel="noopener"&gt;Forgejo&lt;/a&gt; (git), the &lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt; web UI, &lt;a href="https://gethomepage.dev/" target="_blank" rel="noopener"&gt;Homepage&lt;/a&gt; (dashboard), &lt;a href="https://github.com/coder/code-server" target="_blank" rel="noopener"&gt;code-server&lt;/a&gt; (&lt;a href="https://code.visualstudio.com/" target="_blank" rel="noopener"&gt;VS Code&lt;/a&gt; in the browser)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Monitoring&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/louislam/uptime-kuma" target="_blank" rel="noopener"&gt;Uptime Kuma&lt;/a&gt;, &lt;a href="https://nicolargo.github.io/glances/" target="_blank" rel="noopener"&gt;Glances&lt;/a&gt;, &lt;a href="https://www.netdata.cloud/" target="_blank" rel="noopener"&gt;Netdata&lt;/a&gt;, &lt;a href="https://prometheus.io/" target="_blank" rel="noopener"&gt;Prometheus&lt;/a&gt; and &lt;a href="https://grafana.com/" target="_blank" rel="noopener"&gt;Grafana&lt;/a&gt;, &lt;a href="https://dozzle.dev/" target="_blank" rel="noopener"&gt;Dozzle&lt;/a&gt; (container logs), &lt;a href="https://crazymax.dev/diun/" target="_blank" rel="noopener"&gt;Diun&lt;/a&gt; and &lt;a href="https://getwud.github.io/wud/" target="_blank" rel="noopener"&gt;What&amp;rsquo;s Up Docker&lt;/a&gt; (image updates), &lt;a href="https://github.com/librespeed/speedtest" target="_blank" rel="noopener"&gt;LibreSpeed&lt;/a&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Tools&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://ntfy.sh/" target="_blank" rel="noopener"&gt;ntfy&lt;/a&gt; (push notifications), &lt;a href="https://syncthing.net/" target="_blank" rel="noopener"&gt;Syncthing&lt;/a&gt; (file sync), &lt;a href="https://atuin.sh/" target="_blank" rel="noopener"&gt;Atuin&lt;/a&gt; (shell history sync), &lt;a href="https://wakapi.dev/" target="_blank" rel="noopener"&gt;Wakapi&lt;/a&gt; (coding stats)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;agent-cli&lt;/a&gt; and &lt;a href="https://www.nijho.lt/post/diction-agent-cli-qwen/"&gt;Diction&lt;/a&gt; (dictation), &lt;a href="https://speaches.ai/" target="_blank" rel="noopener"&gt;Speaches&lt;/a&gt; (speech-to-text), &lt;a href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener"&gt;Kokoro&lt;/a&gt; (text-to-speech), &lt;a href="https://ollama.com/" target="_blank" rel="noopener"&gt;Ollama&lt;/a&gt;, &lt;a href="https://www.litellm.ai/" target="_blank" rel="noopener"&gt;LiteLLM&lt;/a&gt; (one API in front of all models), &lt;a href="https://openwebui.com/" target="_blank" rel="noopener"&gt;Open WebUI&lt;/a&gt;, &lt;a href="https://www.librechat.ai/" target="_blank" rel="noopener"&gt;LibreChat&lt;/a&gt;, &lt;a href="https://lobehub.com/" target="_blank" rel="noopener"&gt;LobeChat&lt;/a&gt;, &lt;a href="https://khoj.dev/" target="_blank" rel="noopener"&gt;Khoj&lt;/a&gt;, &lt;a href="https://docs.searxng.org/" target="_blank" rel="noopener"&gt;SearXNG&lt;/a&gt; (private metasearch), &lt;a href="https://unsloth.ai/" target="_blank" rel="noopener"&gt;Unsloth&lt;/a&gt; (fine-tuning)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Home and documents&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://mealie.io/" target="_blank" rel="noopener"&gt;Mealie&lt;/a&gt; (recipes), &lt;a href="https://grocy.info/" target="_blank" rel="noopener"&gt;Grocy&lt;/a&gt; (household inventory), &lt;a href="https://docs.paperless-ngx.com/" target="_blank" rel="noopener"&gt;Paperless-ngx&lt;/a&gt; with &lt;a href="https://github.com/clusterzx/paperless-ai" target="_blank" rel="noopener"&gt;paperless-ai&lt;/a&gt; and &lt;a href="https://github.com/icereed/paperless-gpt" target="_blank" rel="noopener"&gt;paperless-gpt&lt;/a&gt;, &lt;a href="https://immich.app/" target="_blank" rel="noopener"&gt;Immich&lt;/a&gt; (photos), &lt;a href="https://nextcloud.com/" target="_blank" rel="noopener"&gt;Nextcloud&lt;/a&gt;, &lt;a href="https://karakeep.app/" target="_blank" rel="noopener"&gt;Hoarder&lt;/a&gt; (bookmarks), &lt;a href="https://www.home-assistant.io/" target="_blank" rel="noopener"&gt;Home Assistant&lt;/a&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Chat&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://cinny.in/" target="_blank" rel="noopener"&gt;Cinny&lt;/a&gt; (&lt;a href="https://matrix.org/" target="_blank" rel="noopener"&gt;Matrix&lt;/a&gt; client for &lt;a href="https://www.nijho.lt/post/mindroom/"&gt;MindRoom&lt;/a&gt;), &lt;a href="https://thelounge.chat/" target="_blank" rel="noopener"&gt;The Lounge&lt;/a&gt; (IRC)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;The GPU-heavy AI services, like dictation, text-to-speech, and fine-tuning, run on &lt;code&gt;pc&lt;/code&gt;; the &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;local AI post&lt;/a&gt; covers that side.&#10;Home Assistant runs as a virtual machine in Incus on the HP; everything else in the table is a container.&lt;/p&gt;&#10;&lt;h2 id="containers-and-compose-farm"&gt;Containers and compose-farm &lt;a class="anchor" href="#containers-and-compose-farm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="what-a-container-is"&gt;What a container is &lt;a class="anchor" href="#what-a-container-is" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Almost everything I run is a &lt;a href="https://www.docker.com/" target="_blank" rel="noopener"&gt;Docker&lt;/a&gt; container.&#10;A container is an application packaged with everything it needs, isolated from the rest of the system.&#10;Mealie&amp;rsquo;s developers publish a Mealie image, I run it, it doesn&amp;rsquo;t care what else is installed on the machine, and removing it leaves nothing behind.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://docs.docker.com/compose/" target="_blank" rel="noopener"&gt;Docker Compose&lt;/a&gt; describes containers in a YAML file: which image, which folders it can see, which ports it uses, which settings.&#10;This is a trimmed version of my Mealie definition:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;mealie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ghcr.io/mealie-recipes/mealie&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;mealie&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="m"&gt;9925&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;9000&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;/mnt/data/mealie:/app/data/&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ALLOW_SIGNUP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;https://mealie.${DOMAIN}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;unless-stopped&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A &lt;strong&gt;port&lt;/strong&gt; is a number that tells apart the network services on one machine, so one address can serve many apps.&#10;Under &lt;code&gt;ports&lt;/code&gt;, &lt;code&gt;9925:9000&lt;/code&gt; means that port 9925 on the machine leads to port 9000 inside the container, where Mealie listens.&lt;/p&gt;&#10;&lt;p&gt;Every service (&amp;ldquo;stack&amp;rdquo;) gets its own folder with a &lt;code&gt;compose.yaml&lt;/code&gt; in one git repo, mounted at &lt;code&gt;/opt/stacks&lt;/code&gt; on every machine.&lt;/p&gt;&#10;&lt;h3 id="upstreams-compose-file-my-folders"&gt;Upstream&amp;rsquo;s Compose file, my folders &lt;a class="anchor" href="#upstreams-compose-file-my-folders" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Back when I ran Proxmox, I was a big fan of the &lt;a href="https://community-scripts.github.io/ProxmoxVE/" target="_blank" rel="noopener"&gt;Proxmox VE Helper-Scripts&lt;/a&gt;, started by tteck (&lt;a href="https://github.com/community-scripts/ProxmoxVE/discussions/237" target="_blank" rel="noopener"&gt;RIP&lt;/a&gt;) and now maintained by the community.&#10;Each one is a one-liner that creates an LXC container with an app installed inside, and they are how I &lt;a href="https://www.nijho.lt/post/homelab/"&gt;got into self-hosting&lt;/a&gt; and set up almost everything at first.&lt;/p&gt;&#10;&lt;p&gt;The catch is that they are a community effort, and the way they install an app is usually not a way the app&amp;rsquo;s own developers support.&#10;&lt;a href="https://immich.app/" target="_blank" rel="noopener"&gt;Immich&lt;/a&gt; shows the problem well: its docs say it requires Docker with the Docker Compose plugin, while today&amp;rsquo;s helper script builds Immich from source, installs &lt;a href="https://www.postgresql.org/" target="_blank" rel="noopener"&gt;PostgreSQL&lt;/a&gt; and &lt;a href="https://redis.io/" target="_blank" rel="noopener"&gt;Redis&lt;/a&gt;, and compiles six image-processing libraries directly in the container.&#10;That is effectively a mirror of the official setup, maintained by someone else; the script even pins the Immich version and only bumps it after testing each release.&#10;Every upgrade depends on a second set of maintainers keeping up with upstream, and for my containers that made upgrades painful and stressful.&lt;/p&gt;&#10;&lt;p&gt;Nowadays almost every project publishes a Docker Compose file, so that&amp;rsquo;s what I use, nearly unmodified.&#10;After the first setup, the Compose file almost never changes; the image does.&#10;Most of my services use the &lt;code&gt;latest&lt;/code&gt; tag, and upgrading is &lt;code&gt;cf update &amp;lt;stack&amp;gt;&lt;/code&gt; (or &lt;code&gt;--all&lt;/code&gt;), which pulls the newest image and recreates the container.&lt;/p&gt;&#10;&lt;p&gt;That is YOLOing it a bit.&#10;In the beginning I pinned every image to an exact digest, because I was afraid a new version would break something.&#10;Bumping all those pins became its own chore, so I switched almost everything to &lt;code&gt;latest&lt;/code&gt;.&#10;I only still pin the pieces where a surprise would really hurt: Traefik, Headscale, Forgejo, and databases, which stay on a major version like &lt;code&gt;postgres:16&lt;/code&gt; because moving to a new major version of PostgreSQL needs a manual migration.&#10;Since the switch I haven&amp;rsquo;t had real problems, and the rare breakage took a five-word prompt to an AI agent to fix.&lt;/p&gt;&#10;&lt;p&gt;The one thing I do change is where the data goes.&#10;Many Compose files keep data in named Docker volumes, which Docker manages somewhere under &lt;code&gt;/var/lib/docker/volumes&lt;/code&gt;.&#10;I stopped using them: too many times I deleted a volume by accident (&lt;code&gt;docker compose down -v&lt;/code&gt; and &lt;code&gt;docker volume prune&lt;/code&gt; make that easy), and I never had a good view of what was inside.&#10;Instead, every stack gets plain folders, which Docker calls bind mounts, in two fixed places:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;/opt/stacks/&amp;lt;stack&amp;gt;/&lt;/code&gt; holds the &lt;code&gt;compose.yaml&lt;/code&gt; and its &lt;code&gt;.env&lt;/code&gt;, in git.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;/mnt/data/&amp;lt;stack&amp;gt;/&lt;/code&gt; holds the app&amp;rsquo;s data.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Each &lt;code&gt;/mnt/data/&amp;lt;stack&amp;gt;&lt;/code&gt; is its own ZFS dataset on the NAS, with frequent snapshots and &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;backups&lt;/a&gt;.&#10;The Mealie example above shows the swap: upstream&amp;rsquo;s file uses a volume called &lt;code&gt;mealie-data&lt;/code&gt;, and mine says &lt;code&gt;/mnt/data/mealie:/app/data/&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="why-i-wrote-compose-farm"&gt;Why I wrote compose-farm &lt;a class="anchor" href="#why-i-wrote-compose-farm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/homelab-explained/compose-farm.svg" width="420" alt="Animated compose-farm logo" loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;p&gt;Docker Compose manages containers on &lt;em&gt;one&lt;/em&gt; machine, and I have four.&#10;The usual answers are Kubernetes or &lt;a href="https://docs.docker.com/engine/swarm/" target="_blank" rel="noopener"&gt;Docker Swarm&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Kubernetes feels like overkill for a homelab, and many projects don&amp;rsquo;t support it: they publish a Compose file, not a &lt;a href="https://helm.sh/" target="_blank" rel="noopener"&gt;Helm&lt;/a&gt; chart (Kubernetes&amp;rsquo; package format), so I would end up writing and maintaining my own charts.&#10;That&amp;rsquo;s the helper-script problem again.&lt;/p&gt;&#10;&lt;p&gt;I also &lt;a href="https://github.com/basnijholt/dotfiles/pull/21" target="_blank" rel="noopener"&gt;experimented with Docker Swarm&lt;/a&gt;, writing a NixOS configuration for a three-manager cluster.&#10;Docker Swarm would meet my needs almost perfectly, but it is effectively in maintenance mode: it still ships with Docker, but Docker no longer invests in it.&lt;/p&gt;&#10;&lt;p&gt;So I wrote &lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt;.&#10;It is deliberately thin: one YAML file says which stack runs on which machine, and the &lt;code&gt;cf&lt;/code&gt; command SSHes to that machine and runs plain &lt;code&gt;docker compose&lt;/code&gt; there.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;compose_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/opt/stacks&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;hosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;nas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.6&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;nuc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;stacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;traefik&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nas&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;headscale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nas&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;forgejo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nas&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;mealie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nas&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ntfy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nuc&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;agent-cli&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;pc&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;glances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;all &lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# runs on every machine&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;traefik_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/opt/stacks/traefik/dynamic.d/compose-farm.yml&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Day to day, that looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf up mealie &lt;span class="c1"&gt;# start Mealie on whichever machine the config says&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf logs -f ntfy &lt;span class="c1"&gt;# follow logs, wherever ntfy runs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf ps &lt;span class="c1"&gt;# what is running where&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf apply &lt;span class="c1"&gt;# make reality match the config: start, move, and stop stacks&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Moving a service to another machine is a one-line change.&#10;I edit &lt;code&gt;mealie: nas&lt;/code&gt; to &lt;code&gt;mealie: hp&lt;/code&gt; and run &lt;code&gt;cf up mealie&lt;/code&gt;, and compose-farm stops it on the old machine and starts it on the new one.&#10;Because every machine sees the same &lt;code&gt;/opt/stacks&lt;/code&gt; and &lt;code&gt;/mnt/data&lt;/code&gt;, the service finds its configuration and data waiting for it.&lt;/p&gt;&#10;&lt;p&gt;That is also my answer to &amp;ldquo;what if a machine dies?&amp;rdquo;&#10;There is no automatic failover: nothing notices and reschedules the services for me, the way Kubernetes would.&#10;But because no stack is tied to a machine, recovering by hand takes a minute.&#10;The NAS is the exception: it holds the data and runs Traefik, so if it dies, nothing else can take over.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;Moving a dead machine&amp;rsquo;s services by hand (click to unfold)&lt;/summary&gt;&#10; &lt;p&gt;If the NUC died, I would point its name at the HP:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;hosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;nas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.6&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;nuc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# was 192.168.1.2; the NUC is dead, so the HP takes over&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then &lt;code&gt;cf up --host nuc&lt;/code&gt; starts every NUC stack on the HP and updates Traefik&amp;rsquo;s routes, and I can clean up the names later.&#10;(Not &lt;code&gt;cf apply&lt;/code&gt;: while two names point at the same machine, it sees every stack there running twice and stops the &amp;ldquo;extra&amp;rdquo; copy.)&#10;This works for the NUC, the HP, and the PC, as long as the published ports don&amp;rsquo;t clash and a service doesn&amp;rsquo;t need the PC&amp;rsquo;s GPUs.&lt;/p&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;p&gt;There is no cluster, no database, and no agent running on each machine.&#10;If compose-farm disappeared tomorrow, every stack would still be a normal Compose folder I could start by hand.&lt;/p&gt;&#10;&lt;p&gt;The last line of that config, &lt;code&gt;traefik_file&lt;/code&gt;, is where compose-farm connects to the front door.&lt;/p&gt;&#10;&lt;h2 id="the-front-door-a-reverse-proxy"&gt;The front door: a reverse proxy &lt;a class="anchor" href="#the-front-door-a-reverse-proxy" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="what-a-reverse-proxy-brings"&gt;What a reverse proxy brings &lt;a class="anchor" href="#what-a-reverse-proxy-brings" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Every service listens on its own port on its own machine: Mealie on port 9925 of the NAS, ntfy on port 8089 of the NUC, and so on.&#10;I could bookmark &lt;code&gt;http://192.168.1.2:8089&lt;/code&gt;, but that gets annoying fast, it is unencrypted, and every service would need its own security.&lt;/p&gt;&#10;&lt;p&gt;A &lt;strong&gt;reverse proxy&lt;/strong&gt; is a receptionist in front of all of them.&#10;Every request arrives there first.&#10;It looks at which name you asked for, &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; or &lt;code&gt;ntfy.lab.nijho.lt&lt;/code&gt;, and passes the request to the right service on the right machine.&#10;Because everything goes through it, the reverse proxy is also the one place to:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;handle HTTPS and certificates, so the services don&amp;rsquo;t have to;&lt;/li&gt;&#10;&lt;li&gt;decide who is allowed in;&lt;/li&gt;&#10;&lt;li&gt;give every service a clean name instead of an IP and a port.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I use &lt;a href="https://traefik.io/traefik/" target="_blank" rel="noopener"&gt;Traefik&lt;/a&gt;.&#10;It runs on the NAS and is the only thing listening on the standard web ports: 80 for plain HTTP and 443 for HTTPS.&lt;/p&gt;&#10;&lt;h3 id="services-announce-themselves"&gt;Services announce themselves &lt;a class="anchor" href="#services-announce-themselves" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;What I like most about Traefik is that services describe their own routing with &lt;strong&gt;labels&lt;/strong&gt; in their Compose file.&#10;These are Mealie&amp;rsquo;s:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.enable=true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# https://mealie.lab.nijho.lt, only for trusted networks&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.mealie.rule=Host(`mealie.${DOMAIN}`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# http://mealie.local, plain HTTP at home&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.mealie-local.rule=Host(`mealie.local`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.mealie-local.entrypoints=web&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.services.mealie.loadbalancer.server.port=9000&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In words: if someone asks for &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; or &lt;code&gt;mealie.local&lt;/code&gt;, send them to port 9000 of this container.&#10;Every service gets two names by convention: an HTTPS name under &lt;code&gt;lab.nijho.lt&lt;/code&gt; that works everywhere, and a short &lt;code&gt;.local&lt;/code&gt; name for quick access at home.&#10;The HTTPS route doesn&amp;rsquo;t mention HTTPS, a certificate, or who is allowed in; Traefik&amp;rsquo;s defaults cover all three.&lt;/p&gt;&#10;&lt;p&gt;Traefik reads those labels by watching Docker on its own machine, and ignores any container that doesn&amp;rsquo;t opt in with &lt;code&gt;traefik.enable=true&lt;/code&gt;.&#10;Its own config sets the defaults:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;providers.docker=true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;providers.docker.exposedbydefault=false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;providers.file.directory=/dynamic.d&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.web.address=:80&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.web.http.middlewares=local-ips-only@file&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.websecure.address=:443&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.websecure.asDefault=true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.websecure.http.middlewares=local-ips-only@file&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.websecure.http.tls.certresolver=le&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.wan.address=:4443&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.wan.http.tls.certresolver=le&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An &lt;strong&gt;entrypoint&lt;/strong&gt; is a port Traefik listens on.&#10;&lt;code&gt;web&lt;/code&gt; and &lt;code&gt;websecure&lt;/code&gt; are for home, WireGuard, and Tailscale, and a route that doesn&amp;rsquo;t name an entrypoint lands on &lt;code&gt;websecure&lt;/code&gt;.&#10;&lt;code&gt;local-ips-only&lt;/code&gt; is the allowlist, more on that in &lt;a href="#keeping-it-safe-one-allowlist"&gt;Keeping it safe&lt;/a&gt;.&#10;&lt;code&gt;wan&lt;/code&gt; is for the internet, more on that in &lt;a href="#4-the-internet"&gt;The internet&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="services-on-other-machines"&gt;Services on other machines &lt;a class="anchor" href="#services-on-other-machines" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;There is a catch: Traefik only sees Docker containers on &lt;em&gt;its own&lt;/em&gt; machine.&#10;It has no idea that ntfy runs on the NUC.&lt;/p&gt;&#10;&lt;p&gt;compose-farm fills that gap.&#10;It reads the Traefik labels of every stack, looks up which machine the stack runs on and which port it publishes, and writes the result to a file that Traefik watches.&#10;After &lt;code&gt;cf up ntfy&lt;/code&gt;, that file contains:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# /opt/stacks/traefik/dynamic.d/compose-farm.yml&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Auto-generated by compose-farm (only the ntfy entries shown)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;routers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ntfy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Host(`ntfy.lab.nijho.lt`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ntfy&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ntfy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;loadbalancer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;servers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;http://192.168.1.2:8089&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Note the ports: on its own machine, Traefik talks to a container directly on its internal port (9000 for Mealie), but a route to another machine has to use the port that machine publishes (8089 for ntfy).&#10;I never edit that file.&#10;I write labels the same way no matter where a service runs, and when a service moves, its route moves with it.&lt;/p&gt;&#10;&lt;p&gt;Things that don&amp;rsquo;t run in Docker, like &lt;a href="https://www.home-assistant.io/" target="_blank" rel="noopener"&gt;Home Assistant&lt;/a&gt; in its own virtual machine, get hand-written routes in a second file in the same folder.&lt;/p&gt;&#10;&lt;h2 id="https-and-certificates"&gt;HTTPS and certificates &lt;a class="anchor" href="#https-and-certificates" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="what-a-certificate-is"&gt;What a certificate is &lt;a class="anchor" href="#what-a-certificate-is" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;When you visit an &lt;code&gt;https://&lt;/code&gt; address, your browser checks the server&amp;rsquo;s &lt;strong&gt;certificate&lt;/strong&gt;: a small file, signed by an authority your browser trusts, that proves the server really is &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt;.&#10;The certificate is also what makes the encryption between you and the server possible.&#10;Without a valid one you get a scary warning, and many apps refuse to connect at all.&lt;/p&gt;&#10;&lt;figure id="figure-a-private-service-with-a-real-certificate"&gt;&lt;img src="https://www.nijho.lt/post/homelab-explained/padlock_hu_dfe972830de4de20.webp" srcset="https://www.nijho.lt/post/homelab-explained/padlock_hu_2b6f10ce0f5359bf.webp 400w, https://www.nijho.lt/post/homelab-explained/padlock_hu_dfe972830de4de20.webp 760w, https://www.nijho.lt/post/homelab-explained/padlock_hu_42709a581b573413.webp 1030w" sizes="auto, (max-width: 760px) 100vw, 720px" width="760" height="317" alt="Screenshot of Firefox showing https://mealie.lab.nijho.lt/login with the connection panel open. It says &amp;#39;You are securely connected to this site&amp;#39; and &amp;#39;Verified by Let&amp;#39;s Encrypt&amp;#39;. The Mealie login page is partly visible behind the panel." loading="lazy" data-zoomable&gt;&lt;figcaption&gt;A private service with a real certificate&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;p&gt;&lt;a href="https://letsencrypt.org/" target="_blank" rel="noopener"&gt;Let&amp;rsquo;s Encrypt&lt;/a&gt; hands out certificates for free, automatically, to anyone who can prove they control a domain.&lt;/p&gt;&#10;&lt;h3 id="proving-i-own-the-domain-without-opening-my-network"&gt;Proving I own the domain without opening my network &lt;a class="anchor" href="#proving-i-own-the-domain-without-opening-my-network" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The usual proof is for Let&amp;rsquo;s Encrypt to &lt;em&gt;connect to your server&lt;/em&gt; and fetch a file from it (the &amp;ldquo;HTTP-01 challenge&amp;rdquo;).&#10;That doesn&amp;rsquo;t work for private services, because the whole point is that the internet can&amp;rsquo;t connect to them.&lt;/p&gt;&#10;&lt;p&gt;So I use the &lt;strong&gt;DNS-01 challenge&lt;/strong&gt; instead.&#10;Let&amp;rsquo;s Encrypt asks me to publish a specific text record in my domain&amp;rsquo;s DNS, the internet&amp;rsquo;s phone book that turns names into addresses (more on that &lt;a href="#dns-the-phone-book"&gt;below&lt;/a&gt;).&#10;My DNS is hosted at &lt;a href="https://www.cloudflare.com/" target="_blank" rel="noopener"&gt;Cloudflare&lt;/a&gt;, so Traefik creates that record through the Cloudflare API, waits for Let&amp;rsquo;s Encrypt to see it, and removes it again.&#10;Let&amp;rsquo;s Encrypt never has to reach my network.&lt;/p&gt;&#10;&lt;p&gt;DNS-01 also lets me request a &lt;strong&gt;wildcard certificate&lt;/strong&gt;: one certificate for &lt;code&gt;*.lab.nijho.lt&lt;/code&gt; that covers every name under it.&#10;A new service has HTTPS the moment its route exists, without requesting anything.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;certificatesresolvers.le.acme.dnsChallenge.provider=cloudflare&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.traefik-lab.tls.certresolver=le&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.traefik-lab.tls.domains[0].main=lab.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.traefik-lab.tls.domains[0].sans=*.lab.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Traefik renews it before it expires, and I don&amp;rsquo;t think about certificates anymore.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;.local&lt;/code&gt; names can&amp;rsquo;t get a public certificate, because nobody owns &lt;code&gt;.local&lt;/code&gt;.&#10;Those stay plain HTTP, and Traefik only answers them for devices on my home network.&#10;It is also why I don&amp;rsquo;t redirect HTTP to HTTPS everywhere: that would break every &lt;code&gt;.local&lt;/code&gt; name.&lt;/p&gt;&#10;&lt;h2 id="dns-the-phone-book"&gt;DNS: the phone book &lt;a class="anchor" href="#dns-the-phone-book" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="what-dns-does"&gt;What DNS does &lt;a class="anchor" href="#what-dns-does" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Computers talk to each other with IP addresses like &lt;code&gt;192.168.1.6&lt;/code&gt;.&#10;Humans prefer names like &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt;.&#10;&lt;strong&gt;DNS&lt;/strong&gt;, the Domain Name System, is the phone book that turns one into the other.&#10;Before your browser connects anywhere, it asks a DNS server for the address that belongs to a name.&lt;/p&gt;&#10;&lt;p&gt;Two details matter for the rest of this post.&lt;/p&gt;&#10;&lt;p&gt;First, some addresses are &lt;strong&gt;private&lt;/strong&gt;.&#10;Ranges like &lt;code&gt;192.168.x.x&lt;/code&gt; and &lt;code&gt;10.x.x.x&lt;/code&gt;, and Tailscale&amp;rsquo;s &lt;code&gt;100.64.x.x&lt;/code&gt;, are not reachable on the public internet; they only mean something inside one particular network.&#10;My NAS is &lt;code&gt;192.168.1.6&lt;/code&gt; inside my home, and so is some random device in thousands of other homes.&lt;/p&gt;&#10;&lt;p&gt;Second, the answer depends on &lt;em&gt;which DNS server you ask&lt;/em&gt;.&#10;My setup relies on that: the same name gets a different answer depending on where I am.&lt;/p&gt;&#10;&lt;h3 id="three-answers-for-the-same-name"&gt;Three answers for the same name &lt;a class="anchor" href="#three-answers-for-the-same-name" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Who is asking&lt;/th&gt;&#10;&lt;th&gt;Which DNS server&lt;/th&gt;&#10;&lt;th&gt;Answer for &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt;&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Anyone on the internet&lt;/td&gt;&#10;&lt;td&gt;Public DNS at Cloudflare&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;192.168.1.6&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;A device at home or on WireGuard&lt;/td&gt;&#10;&lt;td&gt;My own DNS servers on &lt;code&gt;nuc&lt;/code&gt; and &lt;code&gt;hp&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;192.168.1.6&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;A device on my Tailscale network&lt;/td&gt;&#10;&lt;td&gt;Headscale&amp;rsquo;s built-in DNS&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;100.64.0.28&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;&lt;strong&gt;Public DNS&lt;/strong&gt; has one wildcard record: &lt;code&gt;*.lab.nijho.lt&lt;/code&gt; points to &lt;code&gt;192.168.1.6&lt;/code&gt;.&#10;Yes, that is a private address in public DNS.&#10;It sounds odd, but it works nicely: at home, or on a tunnel that routes my home network, the public answer is simply &lt;em&gt;correct&lt;/em&gt;.&#10;For everyone else it leads nowhere, and it reveals nothing beyond the fact that I have a machine at &lt;code&gt;192.168.1.6&lt;/code&gt;, like half the internet.&#10;That is convenience, not security; more on that &lt;a href="#dns-is-not-a-lock"&gt;below&lt;/a&gt;.&#10;(Some routers with &amp;ldquo;DNS rebinding protection&amp;rdquo; refuse to pass on answers like this. Mine doesn&amp;rsquo;t.)&lt;/p&gt;&#10;&lt;p&gt;I don&amp;rsquo;t click those records together in Cloudflare&amp;rsquo;s dashboard.&#10;All my public DNS is &lt;a href="https://www.terraform.io/" target="_blank" rel="noopener"&gt;Terraform&lt;/a&gt;: a text file that lists every record, and a tool that makes Cloudflare match it.&#10;These are the lab records:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;cloudflare_dns_record&amp;#34; &amp;#34;lab_wildcard&amp;#34;&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; zone_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;cloudflare_zone_id&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;*.lab.nijho.lt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;192.168.1.6&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;A&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10800&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; proxied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;&lt;span class="c1"&gt; # Private IP: Cloudflare can&amp;#39;t proxy to it&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;terraform plan&lt;/code&gt; shows exactly which records will be added, changed, or removed before anything happens, and &lt;code&gt;terraform apply&lt;/code&gt; makes the change.&#10;The Terraform files live in a private repo, next to my other secrets, because they need a Cloudflare API token.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;My home DNS servers&lt;/strong&gt; run &lt;a href="https://coredns.io/" target="_blank" rel="noopener"&gt;CoreDNS&lt;/a&gt; on the NUC and the HP, declared in &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/optional/coredns.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;optional/coredns.nix&lt;/code&gt;&lt;/a&gt; in the same NixOS repo as everything else.&#10;They answer &lt;code&gt;.local&lt;/code&gt; and &lt;code&gt;lab.nijho.lt&lt;/code&gt; names themselves and forward everything else to public DNS:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;labNijholtZone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeText&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;lab.nijho.lt.zone&amp;#34;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; $ORIGIN lab.nijho.lt.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; @ 3600 IN A 192.168.1.6&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; * 3600 IN A 192.168.1.6&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without them, &lt;code&gt;.local&lt;/code&gt; names wouldn&amp;rsquo;t work at all, since public DNS knows nothing about them.&lt;/p&gt;&#10;&lt;p&gt;Strictly speaking, &lt;code&gt;.local&lt;/code&gt; is reserved for multicast DNS, the protocol printers and AirPlay speakers use to announce themselves, and &lt;a href="https://support.apple.com/en-us/101903" target="_blank" rel="noopener"&gt;Apple warns&lt;/a&gt; that its devices might not resolve &lt;code&gt;.local&lt;/code&gt; names served by a regular DNS server.&#10;I know, and I have rarely run into it.&#10;The official name for home networks is &lt;code&gt;home.arpa&lt;/code&gt;, but some browsers treat a name like &lt;code&gt;example.home.arpa&lt;/code&gt; as a search term: Chrome on my phone searched Google instead of opening the site.&#10;I had to type the full address, &lt;code&gt;https://example.home.arpa&lt;/code&gt;, every time.&#10;The &lt;code&gt;.local&lt;/code&gt; names are a shortcut anyway; the &lt;code&gt;lab.nijho.lt&lt;/code&gt; names are the ones that work everywhere.&#10;They also keep lab names working when my internet connection is down.&#10;There are two so that one can reboot without taking name resolution with it.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Headscale&amp;rsquo;s DNS&lt;/strong&gt; is the odd one out, and it needs Tailscale explained first.&lt;/p&gt;&#10;&lt;h2 id="four-ways-in"&gt;Four ways in &lt;a class="anchor" href="#four-ways-in" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;There are four ways a request can reach Traefik:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Way in&lt;/th&gt;&#10;&lt;th&gt;When I use it&lt;/th&gt;&#10;&lt;th&gt;Address Traefik sees&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Home network&lt;/td&gt;&#10;&lt;td&gt;At home, on Wi-Fi or a cable&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;192.168.1.x&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;WireGuard on the router&lt;/td&gt;&#10;&lt;td&gt;My phone, laptop, and travel router when I&amp;rsquo;m away&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;10.6.0.x&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Tailscale, via Headscale&lt;/td&gt;&#10;&lt;td&gt;Friends and family, cloud VMs, and my own devices when WireGuard can&amp;rsquo;t get through&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;100.64.0.x&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;The internet&lt;/td&gt;&#10;&lt;td&gt;Anyone, for the few public services&lt;/td&gt;&#10;&lt;td&gt;Any public address&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;h3 id="1-the-home-network"&gt;1. The home network &lt;a class="anchor" href="#1-the-home-network" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;At home my devices are on &lt;code&gt;192.168.1.x&lt;/code&gt;, the home DNS servers answer, and everything just works.&lt;/p&gt;&#10;&lt;h3 id="2-wireguard-on-the-router"&gt;2. WireGuard on the router &lt;a class="anchor" href="#2-wireguard-on-the-router" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;A &lt;strong&gt;VPN&lt;/strong&gt;, a virtual private network, creates an encrypted tunnel from your device to another network, so your device behaves as if it were plugged in there.&#10;&lt;a href="https://www.wireguard.com/" target="_blank" rel="noopener"&gt;WireGuard&lt;/a&gt; is a modern VPN protocol: small, fast, and based on key pairs instead of usernames and passwords.&lt;/p&gt;&#10;&lt;p&gt;My router, an ASUS ZenWiFi XT8, has a WireGuard server built in.&#10;When I&amp;rsquo;m away, my phone connects to it and gets an address in &lt;code&gt;10.6.0.x&lt;/code&gt;, and from then on it is effectively at home.&#10;It can reach &lt;code&gt;192.168.1.6&lt;/code&gt;, and it uses my home DNS servers.&#10;The WireGuard iOS app has &amp;ldquo;On-Demand&amp;rdquo; rules, so the tunnel turns on by itself whenever I leave my home Wi-Fi.&#10;I wrote more about that in &lt;a href="https://www.nijho.lt/post/agentic-mobile-workflow/"&gt;my post about coding from my phone&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;To find my router, the phone needs my home&amp;rsquo;s public IP address.&#10;My internet provider can change that address whenever it likes, so the router also runs &lt;strong&gt;dynamic DNS (DDNS)&lt;/strong&gt;: it keeps a public hostname pointed at whatever my current IP is.&#10;The WireGuard client connects to that hostname.&lt;/p&gt;&#10;&lt;h3 id="3-tailscale-via-my-own-headscale"&gt;3. Tailscale, via my own Headscale &lt;a class="anchor" href="#3-tailscale-via-my-own-headscale" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://tailscale.com/" target="_blank" rel="noopener"&gt;Tailscale&lt;/a&gt; is a VPN built on WireGuard, but organized differently.&#10;Instead of every device tunneling to one central point, Tailscale builds a &lt;strong&gt;mesh&lt;/strong&gt;: every device can make a direct, encrypted WireGuard connection to every other device.&#10;It gets through firewalls and home routers without opening any ports, and when a direct connection is impossible, it falls back to encrypted relay servers.&#10;Every device gets a stable address in &lt;code&gt;100.64.x.x&lt;/code&gt;, no matter which network it is on.&#10;All those devices together are called a &lt;strong&gt;tailnet&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;A mesh like that needs something that knows which devices exist, what their keys are, and who may talk to whom.&#10;That is the &lt;strong&gt;coordination server&lt;/strong&gt;, which Tailscale normally runs for you.&#10;&lt;a href="https://headscale.net/" target="_blank" rel="noopener"&gt;Headscale&lt;/a&gt; is an open-source coordination server that you run yourself.&#10;The official Tailscale apps for phones, laptops, and Linux all work with it; you point them at your own server instead of Tailscale&amp;rsquo;s.&lt;/p&gt;&#10;&lt;p&gt;Headscale runs as one of my stacks on the NAS, and &lt;a href="https://github.com/tale/headplane" target="_blank" rel="noopener"&gt;Headplane&lt;/a&gt; next to it gives it a web UI.&#10;The coordination server only &lt;em&gt;introduces&lt;/em&gt; devices to each other.&#10;My actual traffic flows directly between devices and never passes through it.&#10;Its configuration, the list of who may reach what, and its DNS records are all files in my stacks repo.&lt;/p&gt;&#10;&lt;h3 id="why-both"&gt;Why both? &lt;a class="anchor" href="#why-both" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Two VPNs look redundant, but they are good at different things.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;WireGuard on the router puts a device on my home network.&lt;/strong&gt;&#10;Adding a client is one entry in the router&amp;rsquo;s settings.&#10;That device can then reach everything at home, from the NAS to the printer to the router itself, at the same addresses and with the same DNS answers as on my home Wi-Fi.&#10;Nothing needs to be installed on the machines at home, and nothing depends on my homelab: if the NAS is down, or I broke something, WireGuard still gets me home so I can fix it.&#10;So my own devices use WireGuard: my iPhone, my laptop, and my travel router, which brings every device connected to it along.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Tailscale puts a device on a separate, virtual network.&lt;/strong&gt;&#10;A device on the tailnet can only reach other devices that run Tailscale themselves.&#10;My home network isn&amp;rsquo;t part of it, which is why the DNS trick below is needed.&#10;In return, it works almost everywhere.&#10;WireGuard to my router only works if the network I&amp;rsquo;m on lets UDP traffic out to my router&amp;rsquo;s port, and some hotel, airport, and office networks don&amp;rsquo;t.&#10;Tailscale doesn&amp;rsquo;t need an open port, and its relays speak HTTPS, which nearly every network allows.&#10;It also connects machines that are all away from home, like my laptop and a cloud VM, directly, without a detour through my house.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Tailscale also has fine-grained access control.&lt;/strong&gt;&#10;On my router, every WireGuard client gets my whole home network.&#10;On the tailnet, Headscale&amp;rsquo;s ACL decides per person and per device what they can reach.&#10;So everything that isn&amp;rsquo;t one of my own devices goes on the tailnet: friends, family, and cloud VMs.&lt;/p&gt;&#10;&lt;h3 id="the-tailscale-dns-problem"&gt;The Tailscale DNS problem &lt;a class="anchor" href="#the-tailscale-dns-problem" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The public answer, &lt;code&gt;192.168.1.6&lt;/code&gt;, works on WireGuard because the tunnel routes my home network.&#10;It doesn&amp;rsquo;t work on Tailscale, because I don&amp;rsquo;t route my home network through the tailnet.&#10;Worse, &lt;code&gt;192.168.1.x&lt;/code&gt; is one of the most common home network ranges in the world, so on a friend&amp;rsquo;s Wi-Fi &lt;code&gt;192.168.1.6&lt;/code&gt; might be their printer.&lt;/p&gt;&#10;&lt;p&gt;The fix is Headscale&amp;rsquo;s built-in DNS, &lt;strong&gt;MagicDNS&lt;/strong&gt;.&#10;Every device on the tailnet asks Headscale&amp;rsquo;s DNS first, and I tell it to answer every lab name with the NAS&amp;rsquo;s &lt;em&gt;tailnet&lt;/em&gt; address, &lt;code&gt;100.64.0.28&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;magic_dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;base_domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ts.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;extra_records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# BEGIN MANAGED HEADSCALE LAB DNS&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;mealie.lab.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;A&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100.64.0.28&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ntfy.lab.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;A&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100.64.0.28&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# ... one entry per name, about a hundred of them&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# END MANAGED HEADSCALE LAB DNS&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;ntfy points at the NAS too, even though it runs on the NUC.&#10;These records don&amp;rsquo;t point at &lt;em&gt;services&lt;/em&gt;; they point at &lt;em&gt;the front door&lt;/em&gt;, and Traefik forwards the request to the right machine as usual.&lt;/p&gt;&#10;&lt;p&gt;I don&amp;rsquo;t maintain that list by hand.&#10;A &lt;a href="https://gist.github.com/basnijholt/144eea772149f3d503bd1cd27fd72b45" target="_blank" rel="noopener"&gt;small script&lt;/a&gt; collects every &lt;code&gt;Host(...)&lt;/code&gt; rule from my Traefik labels and route files and rewrites the block between the &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;END&lt;/code&gt; markers:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 scripts/sync-headscale-lab-dns.py --dry-run &lt;span class="c1"&gt;# preview&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 scripts/sync-headscale-lab-dns.py &lt;span class="c1"&gt;# write&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf restart headscale &lt;span class="c1"&gt;# Headscale doesn&amp;#39;t hot-reload&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Forgetting this step is sneaky: the new service works perfectly at home and silently fails over Tailscale.&#10;That&amp;rsquo;s why it is written down in &lt;code&gt;AGENTS.md&lt;/code&gt;, the instruction file my AI agents read, with the exact commands.&lt;/p&gt;&#10;&lt;p&gt;The result is that one name works everywhere I am.&#10;&lt;code&gt;https://mealie.lab.nijho.lt&lt;/code&gt; reaches the same Mealie, with the same certificate, whether my phone is at home, on WireGuard, or on Tailscale; only the path underneath differs.&lt;/p&gt;&#10;&lt;h3 id="4-the-internet"&gt;4. The internet &lt;a class="anchor" href="#4-the-internet" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The last way in is the open internet.&#10;My router forwards TCP port 443, HTTPS, to Traefik on the NAS, but to its own entrypoint, &lt;code&gt;wan&lt;/code&gt; on port 4443, not to the &lt;code&gt;websecure&lt;/code&gt; one that everything else uses.&#10;Only routes that list &lt;code&gt;wan&lt;/code&gt; answer there.&lt;/p&gt;&#10;&lt;p&gt;A few services are public on purpose.&#10;The clearest example is my git server, &lt;a href="https://forgejo.org/" target="_blank" rel="noopener"&gt;Forgejo&lt;/a&gt;, at &lt;code&gt;git.nijho.lt&lt;/code&gt;.&#10;A git server is only useful if the people and machines I work with can reach it, and not all of them are on my tailnet.&lt;/p&gt;&#10;&lt;p&gt;Its public name is a Terraform record that points at the router&amp;rsquo;s DDNS hostname, so it follows my home IP around automatically:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;cloudflare_dns_record&amp;#34; &amp;#34;git&amp;#34;&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; zone_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;cloudflare_zone_id&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;git.nijho.lt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;lt;my router&amp;#39;s DDNS hostname&amp;gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;CNAME&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10800&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; proxied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;&lt;span class="c1"&gt; # SSH uses port 222, CF only proxies 80/443&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In Traefik, Forgejo simply has two HTTPS routes: the usual &lt;code&gt;git.lab.nijho.lt&lt;/code&gt;, private like everything else, and &lt;code&gt;git.nijho.lt&lt;/code&gt;, which also listens on &lt;code&gt;wan&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.forgejo.rule=Host(`git.${DOMAIN}`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.forgejo-public.rule=Host(`git.nijho.lt`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.forgejo-public.entrypoints=websecure,wan&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Git over SSH doesn&amp;rsquo;t go through Traefik; the router forwards a second port, 222, straight to Forgejo on the NAS.&#10;That is also why this DNS record is &amp;ldquo;DNS-only&amp;rdquo;: Cloudflare&amp;rsquo;s proxy only handles web traffic.&#10;Without the allowlist, Forgejo has to guard its own door: sign-ups are off, you have to log in to see anything, and SSH only accepts keys.&lt;/p&gt;&#10;&lt;p&gt;The Headscale coordination server is public too, at &lt;code&gt;headscale.nijho.lt&lt;/code&gt;, and it &lt;em&gt;has&lt;/em&gt; to be: a laptop on hotel Wi-Fi needs to reach it to log in and find the other devices before any tunnel exists.&#10;That record goes through Cloudflare&amp;rsquo;s proxy, which hides my home IP behind Cloudflare&amp;rsquo;s.&#10;Only the part the Tailscale apps talk to is public; the Headplane admin UI and Headscale&amp;rsquo;s &lt;code&gt;lab&lt;/code&gt; name stay behind the allowlist.&#10;Headscale doesn&amp;rsquo;t run its own relay server either; the tailnet uses Tailscale&amp;rsquo;s public relays when a direct connection isn&amp;rsquo;t possible.&lt;/p&gt;&#10;&lt;p&gt;Forwarding port 443 sounds scary if you assume it means everything is on the internet now.&#10;It doesn&amp;rsquo;t: the internet only reaches &lt;code&gt;wan&lt;/code&gt;, and a route has to ask for that.&lt;/p&gt;&#10;&lt;h2 id="keeping-it-safe-one-allowlist"&gt;Keeping it safe: one allowlist &lt;a class="anchor" href="#keeping-it-safe-one-allowlist" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="dns-is-not-a-lock"&gt;DNS is not a lock &lt;a class="anchor" href="#dns-is-not-a-lock" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Remember &lt;a href="#three-answers-for-the-same-name"&gt;the public DNS record from earlier&lt;/a&gt;: &lt;code&gt;*.lab.nijho.lt&lt;/code&gt; points to &lt;code&gt;192.168.1.6&lt;/code&gt;, a private IP address on my home network.&#10;I made this mistake myself: I thought pointing DNS at a private address was enough to keep my services off the internet.&#10;It wasn&amp;rsquo;t.&#10;DNS only tells a client where to go, and nothing forces a client to ask.&#10;Anyone can skip DNS and send a request for &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; straight to my home IP:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl --resolve mealie.lab.nijho.lt:443:&amp;lt;my home IP&amp;gt; https://mealie.lab.nijho.lt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A home IP is not a secret: any public service at home points to it, and scanners sweep every address on the internet anyway.&#10;That request reaches the router, gets forwarded to Traefik like any other HTTPS request, and asks for Mealie by name.&#10;The names aren&amp;rsquo;t secret either, because they&amp;rsquo;re easy to guess.&#10;The wildcard certificate keeps the individual names out of the public &lt;a href="https://certificate.transparency.dev/" target="_blank" rel="noopener"&gt;certificate transparency logs&lt;/a&gt;, which is nice, but that is hiding, not locking.&lt;/p&gt;&#10;&lt;p&gt;So the DNS setup is there for convenience: one name that works everywhere.&#10;The security boundary is Traefik, which knows how every request actually arrived.&lt;/p&gt;&#10;&lt;h3 id="who-is-knocking"&gt;Who is knocking &lt;a class="anchor" href="#who-is-knocking" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Every network request carries a &lt;strong&gt;source address&lt;/strong&gt;: where it came from.&#10;When the router forwards a request from the internet, the source stays the real public address of whoever sent it.&#10;Unlike a name, a source address can&amp;rsquo;t be faked for a real connection: the reply goes back to that address, so a forged one never completes the connection.&#10;So Traefik can tell which of the four ways in a request took, just by looking at where it came from.&lt;/p&gt;&#10;&lt;p&gt;I use that in a single Traefik &lt;strong&gt;middleware&lt;/strong&gt;, a check that runs before a request reaches the service, called &lt;code&gt;local-ips-only&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;middlewares&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;local-ips-only&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ipAllowList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;sourceRange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;192.168.1.0/24&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# home network&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;100.64.0.0/24&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# Tailscale devices&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;10.6.0.0/24&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# WireGuard clients&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;172.20.0.1/32&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# the NAS itself (see below)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;127.0.0.1/32&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# localhost&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;/24&lt;/code&gt; means the first three numbers are fixed and the last can be anything, so &lt;code&gt;192.168.1.0/24&lt;/code&gt; covers &lt;code&gt;192.168.1.0&lt;/code&gt; through &lt;code&gt;192.168.1.255&lt;/code&gt;.&#10;&lt;code&gt;/32&lt;/code&gt; means exactly one address.&lt;/p&gt;&#10;&lt;p&gt;The middleware sits on the whole &lt;code&gt;websecure&lt;/code&gt; entrypoint, so every HTTPS route there gets the check without a word in its labels.&#10;A request from any other address gets &lt;code&gt;403 Forbidden&lt;/code&gt; before it gets anywhere near the service.&#10;For the &lt;code&gt;.local&lt;/code&gt; names, the same middleware sits on the plain-HTTP entrypoint.&#10;Internet traffic doesn&amp;rsquo;t arrive on &lt;code&gt;websecure&lt;/code&gt; at all, so this is the second lock: if I ever point the port forward at the wrong port, strangers get &lt;code&gt;403&lt;/code&gt; instead of my services.&lt;/p&gt;&#10;&lt;p&gt;The odd one, &lt;code&gt;172.20.0.1&lt;/code&gt;, is the gateway of my Docker network.&#10;Requests that start on the NAS itself, like a container calling another service by its lab name, show up with that address.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;Keeping real Tailscale addresses (click to unfold)&lt;/summary&gt;&#10; &lt;p&gt;Tailscale requests keep their real &lt;code&gt;100.64.0.x&lt;/code&gt; address thanks to one line in the NixOS config of &lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/docker-lxc" target="_blank" rel="noopener"&gt;&lt;code&gt;docker-lxc&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tailscale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extraSetFlags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;--snat-subnet-routes=false&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without it, Tailscale replaces the source address of traffic it passes on to another network, like Docker&amp;rsquo;s internal one, and every Tailscale request would look like it came from &lt;code&gt;172.20.0.1&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;p&gt;To check all of this, I test from every way in.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;How I test it (click to unfold)&lt;/summary&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# From home, WireGuard, or Tailscale: expect 200&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -o /dev/null -w &lt;span class="s1"&gt;&amp;#39;%{http_code}\n&amp;#39;&lt;/span&gt; https://mealie.lab.nijho.lt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Through the router&amp;#39;s port forward, skipping DNS: expect 404&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -o /dev/null -w &lt;span class="s1"&gt;&amp;#39;%{http_code}\n&amp;#39;&lt;/span&gt; --resolve mealie.lab.nijho.lt:443:&amp;lt;my home IP&amp;gt; https://mealie.lab.nijho.lt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# A public service, from anywhere: expect 200&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -o /dev/null -w &lt;span class="s1"&gt;&amp;#39;%{http_code}\n&amp;#39;&lt;/span&gt; https://git.nijho.lt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The second one works from inside too: the router sends the request back in through the same port forward, so it lands on &lt;code&gt;wan&lt;/code&gt; like a stranger&amp;rsquo;s would.&lt;/p&gt;&#10;&lt;/details&gt;&#10;&#10;&lt;h3 id="private-by-default"&gt;Private by default &lt;a class="anchor" href="#private-by-default" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Out of roughly a hundred HTTPS routes, only a handful are public, and each of those is a deliberate decision.&lt;/p&gt;&#10;&lt;p&gt;A route is public only if it lists the &lt;code&gt;wan&lt;/code&gt; entrypoint and has no allowlist of its own.&#10;A private service needs nothing in its labels: it lands on &lt;code&gt;websecure&lt;/code&gt;, where the allowlist guards the whole entrypoint.&#10;Forget a label and a service ends up private, not public.&#10;The one change that publishes something is typing &lt;code&gt;wan&lt;/code&gt;, which stands out in a diff and is easy to list:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -rnw wan /opt/stacks/*/compose.yaml /opt/stacks/traefik/dynamic.d/&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One wrinkle: devices at home that use a public name, like &lt;code&gt;git.nijho.lt&lt;/code&gt;, also come in through the port forward, because DNS points that name at my home IP.&#10;For public routes that changes nothing.&#10;A couple of private services that I reach by a public name list &lt;code&gt;wan&lt;/code&gt; too, and keep the allowlist on the route itself.&lt;/p&gt;&#10;&lt;h3 id="trusting-my-own-network"&gt;Trusting my own network &lt;a class="anchor" href="#trusting-my-own-network" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;For most services, the allowlist is the only lock in front.&#10;Anything on my home network or on WireGuard, and any of my own devices on Tailscale, can reach them without an extra login.&lt;/p&gt;&#10;&lt;p&gt;I could add one.&#10;&lt;a href="https://www.authelia.com/" target="_blank" rel="noopener"&gt;Authelia&lt;/a&gt; runs next to Traefik, and putting its login page in front of a service takes one label.&#10;For Traefik&amp;rsquo;s own dashboard, that looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.traefik-lab.middlewares=authelia@docker&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I do that for some services, but not all, because a login page in front of an app sometimes breaks things, like mobile apps that talk to the app directly.&lt;/p&gt;&#10;&lt;p&gt;Most apps have their own login, and I trust that enough on my own network.&#10;I also trust the people on it.&#10;If your home network has guests or devices you don&amp;rsquo;t trust, the trade-off looks different.&lt;/p&gt;&#10;&lt;h2 id="sharing-with-friends-and-family"&gt;Sharing with friends and family &lt;a class="anchor" href="#sharing-with-friends-and-family" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The allowlist trusts the whole &lt;code&gt;100.64.0.x&lt;/code&gt; range, and some devices on my tailnet belong to friends and family.&#10;That doesn&amp;rsquo;t give them everything, because there is a second layer in front: Headscale&amp;rsquo;s &lt;strong&gt;access control list (ACL)&lt;/strong&gt;.&#10;It decides, at the network level, which device may reach which port on which machine, and everything not listed is blocked.&lt;/p&gt;&#10;&lt;p&gt;A few examples of what that looks like in practice:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;A friend uses my Mealie.&lt;/strong&gt; Their router runs Tailscale and shares their home network with the tailnet, so every device in their home can reach Mealie without installing anything.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A friend uses my &lt;a href="https://www.nijho.lt/post/diction-agent-cli-qwen/"&gt;self-hosted dictation&lt;/a&gt;&lt;/strong&gt; on the GPU machine, and nothing else.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;An AI agent I run for a family member&lt;/strong&gt;, in a &lt;a href="https://www.nijho.lt/post/mindroom/"&gt;MindRoom&lt;/a&gt; container, can reach one Home Assistant instance and nothing else.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A small NixOS box at a family member&amp;rsquo;s home&lt;/strong&gt; joins the tailnet and makes a few shared services available on their network under simple &lt;code&gt;.local&lt;/code&gt; names, for devices that can&amp;rsquo;t run Tailscale themselves. It is defined in the same NixOS repo as my own machines.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;A simplified version of those rules:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;hosts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;nas&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;100.64.0.28/32&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;pc&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;100.64.0.3/32&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;friend-a-home&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;10.10.0.0/16&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;acls&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;action&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;accept&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;friend-a@&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;friend-a-home&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;nas:9925&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;action&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;accept&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;friend-b@&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;pc:61337&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pc:8080&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;action&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;accept&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;me@&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;*:*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Friends reach their service directly on its port, not through Traefik.&#10;They can&amp;rsquo;t even reach port 443 on the NAS, so Traefik never sees them.&#10;My own devices can reach everything.&lt;/p&gt;&#10;&lt;p&gt;That also means friends don&amp;rsquo;t use my lab names.&#10;I give them the machine&amp;rsquo;s tailnet address and the port, like &lt;code&gt;http://100.64.0.28:9925&lt;/code&gt;, and that&amp;rsquo;s it.&#10;A device keeps its tailnet address, so it can go in a bookmark, and one friend points a name in their own homelab&amp;rsquo;s DNS at it.&#10;It is plain HTTP, but between their network and mine it travels inside the encrypted tunnel.&lt;/p&gt;&#10;&lt;p&gt;Because the ACL is a file in my stacks repo, sharing one more service with someone is a one-line diff and a &lt;code&gt;cf restart headscale&lt;/code&gt;.&#10;The commit history doubles as a log of who got access to what, and when.&lt;/p&gt;&#10;&lt;p&gt;This is also how friend-to-friend setups like &lt;a href="https://www.nijho.lt/post/truenas-remote-backups/"&gt;my remote TrueNAS backups&lt;/a&gt; work: a tailnet is a private network you can extend to exactly the people you trust.&lt;/p&gt;&#10;&lt;h2 id="why-not-cloudflare-tunnel-or-tailscales-own-server"&gt;Why not Cloudflare Tunnel or Tailscale&amp;rsquo;s own server? &lt;a class="anchor" href="#why-not-cloudflare-tunnel-or-tailscales-own-server" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This is usually the next question.&#10;&lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" target="_blank" rel="noopener"&gt;Cloudflare Tunnel&lt;/a&gt; publishes services without forwarding any ports: a small daemon at home keeps an outbound connection open to Cloudflare, and Cloudflare serves your sites to the world.&#10;Tailscale runs the coordination server for you, for free, so you don&amp;rsquo;t need Headscale at all.&#10;Both are great products, and both are easier than what I do.&lt;/p&gt;&#10;&lt;p&gt;The reason I don&amp;rsquo;t use them is simple: I don&amp;rsquo;t want to depend on a third-party company for any of this.&#10;The plumbing in this post is open source and runs on my own machines, so if one of these companies disappears or changes its terms tomorrow, everything keeps working.&#10;With Cloudflare Tunnel, every request to my services would pass through Cloudflare, which decrypts it on the way.&#10;With Tailscale&amp;rsquo;s hosted server, a company would decide which devices are part of my network.&lt;/p&gt;&#10;&lt;p&gt;There are a few places where I still lean on someone else, and each one is replaceable:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Cloudflare hosts my public DNS.&lt;/strong&gt; The records are a Terraform file, so moving to another provider means rewriting that file for a different provider. The same goes for the certificate challenge, which Traefik supports for dozens of DNS providers, and for the few names that go through Cloudflare&amp;rsquo;s proxy.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Tailscale&amp;rsquo;s public relays&lt;/strong&gt; carry traffic when two devices can&amp;rsquo;t connect directly. Headscale has a relay server built in, which I can turn on in its config.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Let&amp;rsquo;s Encrypt&lt;/strong&gt; issues my certificates, but it speaks a standard protocol, ACME, that other certificate authorities speak too.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Terraform&lt;/strong&gt; moved to a source-available license in 2023. &lt;a href="https://opentofu.org/" target="_blank" rel="noopener"&gt;OpenTofu&lt;/a&gt; is the open-source fork, and it reads the same files.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="declarative-everything"&gt;Declarative everything &lt;a class="anchor" href="#declarative-everything" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;One idea runs through all of this: every piece of configuration is a &lt;strong&gt;text file in git&lt;/strong&gt;, not a setting I clicked somewhere.&#10;The opposite is running commands and clicking buttons until things look right, and then hoping you remember what you did.&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Piece&lt;/th&gt;&#10;&lt;th&gt;Where it&amp;rsquo;s defined&lt;/th&gt;&#10;&lt;th&gt;How it&amp;rsquo;s applied&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Operating system, Docker, Tailscale, NFS mounts, home DNS, firewalls&lt;/td&gt;&#10;&lt;td&gt;NixOS config in my public dotfiles&lt;/td&gt;&#10;&lt;td&gt;comin pulls signed commits and switches&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Which service runs where&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;compose-farm.yaml&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cf apply&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Each service and its routing&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;compose.yaml&lt;/code&gt; with Traefik labels&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cf up&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Routes to services on other machines&lt;/td&gt;&#10;&lt;td&gt;Generated by compose-farm&lt;/td&gt;&#10;&lt;td&gt;Automatically on &lt;code&gt;cf up&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Non-Docker routes and the allowlist&lt;/td&gt;&#10;&lt;td&gt;A route file next to Traefik&lt;/td&gt;&#10;&lt;td&gt;Traefik watches the folder&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Public DNS records&lt;/td&gt;&#10;&lt;td&gt;Terraform in a private repo&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;terraform apply&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Tailnet DNS records&lt;/td&gt;&#10;&lt;td&gt;Generated from Traefik rules into Headscale&amp;rsquo;s config&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cf restart headscale&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Who may reach what on the tailnet&lt;/td&gt;&#10;&lt;td&gt;Headscale ACL file&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cf restart headscale&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Router: WireGuard, port forward, DDNS&lt;/td&gt;&#10;&lt;td&gt;The router&amp;rsquo;s web UI&lt;/td&gt;&#10;&lt;td&gt;❌ Clicked&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Which devices are on the tailnet&lt;/td&gt;&#10;&lt;td&gt;Headscale&amp;rsquo;s database&lt;/td&gt;&#10;&lt;td&gt;Enrolled once per device; state, not configuration&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;The only configuration I still click is the router, and those settings change maybe once a year.&#10;Everything that changes weekly is a file.&#10;The next step in project &amp;ldquo;overengineered homelab&amp;rdquo; is a router running NixOS, but that&amp;rsquo;s for another time.&#10;What isn&amp;rsquo;t configuration, like which devices are enrolled in my tailnet and the data inside each app, lives on ZFS and is backed up like any other data.&lt;/p&gt;&#10;&lt;p&gt;It wasn&amp;rsquo;t always like this.&#10;My previous setup was built from good products that are all driven by clicking through a web UI:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Job&lt;/th&gt;&#10;&lt;th&gt;Before: clicked in a web UI&lt;/th&gt;&#10;&lt;th&gt;Now: a text file in git&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Virtualization&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.proxmox.com/" target="_blank" rel="noopener"&gt;Proxmox&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;NixOS and Incus&lt;/a&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.truenas.com/" target="_blank" rel="noopener"&gt;TrueNAS&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;NixOS with ZFS&lt;/a&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;DNS&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://technitium.com/dns/" target="_blank" rel="noopener"&gt;Technitium&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;CoreDNS in my NixOS config&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Reverse proxy&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager" target="_blank" rel="noopener"&gt;Nginx Proxy Manager&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;Traefik labels in each &lt;code&gt;compose.yaml&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Containers&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/louislam/dockge" target="_blank" rel="noopener"&gt;Dockge&lt;/a&gt;, one machine at a time&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt;, all machines from one file&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Yes, most of them have APIs, but that is not how most people use them, me included.&#10;Most of the configuration lived in each product&amp;rsquo;s database, and the reasons behind each setting lived in my head.&#10;Now both live in git.&lt;/p&gt;&#10;&lt;h3 id="why-it-matters"&gt;Why it matters &lt;a class="anchor" href="#why-it-matters" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The classic benefits are real: git history is my changelog, rebuilding a dead machine is boring, and nothing lives only in my head.&lt;/p&gt;&#10;&lt;p&gt;But the benefit that matters most to me now is a newer one: &lt;strong&gt;this setup is easy to work on with AI.&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;Coding agents are very good at reading and editing text files, running commands, and checking the result.&#10;They are much worse at clicking through a web dashboard.&#10;Because almost everything here is files and CLI commands, an agent can do exactly what I do: add a service, move it to another machine, find out why a route returns &lt;code&gt;403&lt;/code&gt;, add a DNS record, write the NixOS change, and show me the diff.&#10;Terraform is a good example of why this is safe: the agent edits the record, runs &lt;code&gt;terraform plan&lt;/code&gt;, and I see exactly what will change at Cloudflare before anything does.&lt;/p&gt;&#10;&lt;p&gt;I &lt;a href="https://www.nijho.lt/post/removing-guardrails/"&gt;run my agents in YOLO mode&lt;/a&gt;, and declarative config is a big part of why that doesn&amp;rsquo;t scare me.&#10;Every change is a reviewable diff, and every mistake is a &lt;code&gt;git revert&lt;/code&gt; away.&#10;It&amp;rsquo;s the same reason I &lt;a href="https://www.nijho.lt/post/file-based-rag-memory/"&gt;prefer plain files over databases&lt;/a&gt; for AI memory.&lt;/p&gt;&#10;&lt;p&gt;The stacks repo has an &lt;a href="https://agents.md/" target="_blank" rel="noopener"&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/a&gt; that captures the conventions and the sharp edges (a symlink to &lt;code&gt;CLAUDE.md&lt;/code&gt;, so &lt;a href="https://code.claude.com/docs/en/overview" target="_blank" rel="noopener"&gt;Claude Code&lt;/a&gt; and other agents read the same file):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;&amp;gt; &lt;/span&gt;&lt;span class="ge"&gt;**⚠️ IMPORTANT: NEVER run `docker compose` directly!**&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="ge"&gt;&amp;gt; Always use `cf` (compose-farm) for ALL compose operations.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;&amp;gt; &lt;/span&gt;&lt;span class="ge"&gt;Running `docker compose` directly bypasses host routing,&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;&amp;gt; &lt;/span&gt;&lt;span class="ge"&gt;state tracking, and Traefik config generation.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Tailscale DNS step from earlier lives there too, with the exact commands.&#10;Whenever something bites me once, it goes into that file, so it doesn&amp;rsquo;t bite an agent twice.&lt;/p&gt;&#10;&lt;p&gt;The other half is generating everything I would otherwise keep in sync by hand.&#10;The cross-machine Traefik routes come from the Compose labels, the Headscale DNS records come from the Traefik rules, and even my &lt;a href="https://github.com/louislam/uptime-kuma" target="_blank" rel="noopener"&gt;Uptime Kuma&lt;/a&gt; monitors are synced from the Traefik labels.&#10;Hand-maintained lists drift; generated ones don&amp;rsquo;t.&lt;/p&gt;&#10;&lt;h2 id="putting-it-together-adding-a-service"&gt;Putting it together: adding a service &lt;a class="anchor" href="#putting-it-together-adding-a-service" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This is everything it takes to add a new service, say ntfy on the NUC:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Create &lt;code&gt;/opt/stacks/ntfy/compose.yaml&lt;/code&gt; with the container and its Traefik labels: an HTTPS name and a &lt;code&gt;.local&lt;/code&gt; name, like Mealie.&lt;/li&gt;&#10;&lt;li&gt;Add &lt;code&gt;ntfy: nuc&lt;/code&gt; to &lt;code&gt;compose-farm.yaml&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Run &lt;code&gt;cf up ntfy&lt;/code&gt;. compose-farm starts it on the NUC and writes its route into Traefik&amp;rsquo;s file, and Traefik picks it up immediately.&lt;/li&gt;&#10;&lt;li&gt;Run the Headscale DNS script and &lt;code&gt;cf restart headscale&lt;/code&gt;, so the name also works over Tailscale.&lt;/li&gt;&#10;&lt;li&gt;Commit.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;After that:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;https://ntfy.lab.nijho.lt&lt;/code&gt; works at home, over WireGuard, and over Tailscale, with a valid certificate I never requested.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;http://ntfy.local&lt;/code&gt; works at home.&lt;/li&gt;&#10;&lt;li&gt;Sent to my home IP from the internet, it answers &lt;code&gt;404 Not Found&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;To make it public like &lt;code&gt;git.nijho.lt&lt;/code&gt;, I would add a Terraform record and a second route that also listens on &lt;code&gt;wan&lt;/code&gt;.&#10;To share it with a friend, one line in the ACL.&lt;/p&gt;&#10;&lt;p&gt;In practice, I describe the service to an agent and review what it did.&lt;/p&gt;&#10;&lt;h2 id="lessons-learned"&gt;Lessons learned &lt;a class="anchor" href="#lessons-learned" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Use the DNS-01 challenge.&lt;/strong&gt; You get real certificates for private services and one wildcard for everything, and the internet never needs to reach you.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One front door, private by default.&lt;/strong&gt; Knowing that a route has to ask to be on the internet is what made forwarding port 443 feel fine.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A private address in public DNS is a great trick&lt;/strong&gt; for home and WireGuard. A mesh VPN like Tailscale needs its own answer.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;DNS is not a security boundary.&lt;/strong&gt; Anyone can send any name to your IP. The check has to happen at the front door.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Point DNS at the front door, not at the service.&lt;/strong&gt; Traefik knows where things run, so moving a service never touches DNS.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Keep one way in that doesn&amp;rsquo;t depend on the homelab.&lt;/strong&gt; For me that&amp;rsquo;s WireGuard on the router. The NAS holds the data and runs Traefik, so when it is down, everything is, and I still need a way in to fix it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Test from every way in.&lt;/strong&gt; Some problems only show up on one path, like a new name that works at home but not over Tailscale.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Two layers for sharing.&lt;/strong&gt; Headscale ACLs decide who reaches what; Traefik&amp;rsquo;s &lt;code&gt;wan&lt;/code&gt; entrypoint decides what the internet sees.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Make private the default.&lt;/strong&gt; Public should take an extra word in the config, so a forgotten label fails closed.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Generate what you can.&lt;/strong&gt; A generated list can&amp;rsquo;t drift.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Make everything a file.&lt;/strong&gt; It&amp;rsquo;s better for future me, and it&amp;rsquo;s the difference between an AI agent that can help and one that can only give advice.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt;: my tool for running Docker Compose stacks across multiple machines&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos" target="_blank" rel="noopener"&gt;My NixOS configuration&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/nlewo/comin" target="_blank" rel="noopener"&gt;comin&lt;/a&gt;: GitOps for NixOS machines&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://doc.traefik.io/traefik/" target="_blank" rel="noopener"&gt;Traefik&lt;/a&gt;, including the &lt;a href="https://doc.traefik.io/traefik/middlewares/http/ipallowlist/" target="_blank" rel="noopener"&gt;IP allowlist middleware&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://letsencrypt.org/docs/challenge-types/" target="_blank" rel="noopener"&gt;Let&amp;rsquo;s Encrypt challenge types&lt;/a&gt;: HTTP-01 vs DNS-01&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs" target="_blank" rel="noopener"&gt;Terraform Cloudflare provider&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.wireguard.com/" target="_blank" rel="noopener"&gt;WireGuard&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://tailscale.com/blog/how-tailscale-works" target="_blank" rel="noopener"&gt;How Tailscale works&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://headscale.net/" target="_blank" rel="noopener"&gt;Headscale&lt;/a&gt; and &lt;a href="https://github.com/tale/headplane" target="_blank" rel="noopener"&gt;Headplane&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://coredns.io/" target="_blank" rel="noopener"&gt;CoreDNS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forgejo.org/" target="_blank" rel="noopener"&gt;Forgejo&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;Yes, &lt;a href="https://repology.org/repositories/graphs" target="_blank" rel="noopener"&gt;nixpkgs is the largest and most up-to-date package repository&lt;/a&gt; there is. Even so, I follow &lt;code&gt;nixos-unstable&lt;/code&gt;, and a new version only reaches me once it is merged, built, and tested, and the channel moves forward, which usually takes a couple of days. Updates that trigger large rebuilds go through a staging branch first and take longer, and not every package gets updated as quickly as the popular ones. With &lt;a href="https://www.docker.com/" target="_blank" rel="noopener"&gt;Docker&lt;/a&gt;, I can run a release the day upstream publishes it.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>The last btrfs machine: migrating my PC to ZFS</title><link>https://www.nijho.lt/post/btrfs-to-zfs/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/btrfs-to-zfs/</guid><description>&lt;p&gt;My GPU machine was the last machine in my fleet still running btrfs.&#10;I &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;bought it for gaming&lt;/a&gt; (which never happened), and these days its dual RTX 3090s almost exclusively run &lt;a href="https://www.nijho.lt/post/llama-nixos/"&gt;local AI models&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;btrfs itself never failed me.&#10;When I set up NixOS on this machine for the first time, I had read enough forum threads to become convinced that ZFS and NixOS were a troublesome combination, so I picked btrfs to be safe.&#10;That belief was simply wrong.&#10;As I &lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;went full Nix&lt;/a&gt; and eventually &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;moved even my TrueNAS to NixOS&lt;/a&gt;, I learned there was no issue at all.&#10;I just had to figure out the right incantation to make ZFS work with boot drives, &lt;a href="https://github.com/nix-community/disko" target="_blank" rel="noopener"&gt;disko&lt;/a&gt;, and &lt;a href="https://github.com/nix-community/nixos-anywhere" target="_blank" rel="noopener"&gt;nixos-anywhere&lt;/a&gt;.&#10;My HP got ZFS first, that config grew into a shared disko module, and soon the whole fleet ran one pattern: NixOS on ZFS, snapshots and replication declared in code, and backups I can reason about.&lt;/p&gt;&#10;&lt;p&gt;Only the machine I sit at every day kept running on my old misconception, because a daily driver is exactly the one you do not casually reinstall.&lt;/p&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#why-touch-a-working-machine"&gt;Why touch a working machine&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-backup-that-wasnt"&gt;The backup that wasn&amp;rsquo;t&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-the-audit-found"&gt;What the audit found&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-plan-one-shared-disko-module"&gt;The plan: one shared disko module&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#rehearsing-in-a-vm-again"&gt;Rehearsing in a VM, again&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-recovery-kit"&gt;The recovery kit&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#trusting-the-backup-enough-to-wipe-the-disk"&gt;Trusting the backup enough to wipe the disk&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-fifth-question"&gt;The fifth question&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#restoring-in-stages"&gt;Restoring in stages&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#slimming-down-first"&gt;Slimming down first&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-cutover"&gt;The cutover&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="why-touch-a-working-machine"&gt;Why touch a working machine &lt;a class="anchor" href="#why-touch-a-working-machine" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The operating system was already uniform.&#10;The filesystem was not, and that turned out to matter more than I expected.&lt;/p&gt;&#10;&lt;p&gt;The real trigger was that I got increasingly paranoid about an AI agent messing up my system.&#10;I run agentic AI in YOLO mode, exclusively.&#10;All day, every day, agents execute commands on this machine without asking me first.&#10;YOLO does not mean root, though: the agents run as my regular user, and the backup credentials live where only root can read them.&#10;An agent that could destroy the backup itself would make everything else in this post pointless.&#10;Meanwhile the stories keep coming in about even the most frontier models wiping someone&amp;rsquo;s home directory:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;GPT-5.6-Sol just accidentally deleted almost ALL of my Mac&amp;rsquo;s files. And this is why I trust Fable 1000x more.&lt;/p&gt;&#10;&lt;p&gt;— &lt;a href="https://x.com/mattshumer_/status/2075657271401390161" target="_blank" rel="noopener"&gt;@mattshumer_&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The reaction is always about which model deserves more trust.&#10;I read these tweets differently.&#10;Give any agent enough sessions and it will eventually delete something important, the same way I do every few years with a tired &lt;code&gt;rm -rf&lt;/code&gt;.&#10;Whether that moment becomes a catastrophe or an inconvenience depends entirely on the restore path.&lt;/p&gt;&#10;&lt;p&gt;So I felt I needed to properly audit the way my backups were done.&#10;I want to be able to answer four questions about any machine I own:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;What exactly is in the backup?&lt;/li&gt;&#10;&lt;li&gt;What is &lt;em&gt;not&lt;/em&gt; in the backup, and did I choose that deliberately?&lt;/li&gt;&#10;&lt;li&gt;How big is a restore?&lt;/li&gt;&#10;&lt;li&gt;How long will it take?&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="the-backup-that-wasnt"&gt;The backup that wasn&amp;rsquo;t &lt;a class="anchor" href="#the-backup-that-wasnt" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Asking &lt;span class="hover-tip" data-tooltip="What exactly is in the backup?" tabindex="0"&gt;question 1&lt;/span&gt; delivered an unpleasant answer within the hour: the backups had quietly stopped four months earlier.&#10;Two boring causes had stacked up: an interrupted prune left a stale repository lock, which the job could never clear because its own unlock step only ran &lt;em&gt;after&lt;/em&gt; a successful backup, and the repository URL still pointed at &lt;code&gt;truenas.local&lt;/code&gt;, a name that had stopped resolving &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;along the way&lt;/a&gt;.&#10;The last successful backup ran at 07:00 on March 22; the next one at 07:16 on July 22, after &lt;a href="https://github.com/basnijholt/dotfiles/commit/4d2a75efcb503b9ca2aabcbe8339be3e5a62d0ce" target="_blank" rel="noopener"&gt;the fix&lt;/a&gt;.&#10;Four months, almost to the minute.&#10;systemd logged the failure on every single run, nobody read it, and nothing escalated.&#10;I found out because I asked the question, not because anything told me.&lt;/p&gt;&#10;&lt;h2 id="what-the-audit-found"&gt;What the audit found &lt;a class="anchor" href="#what-the-audit-found" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;On the btrfs box, &lt;span class="hover-tip" data-tooltip="How big is a restore?" tabindex="0"&gt;question 3&lt;/span&gt; alone had three different answers depending on which tool I asked.&#10;&lt;code&gt;du&lt;/code&gt; counted reflinked copies at full size, so directories reported terabytes that physically did not exist.&#10;&lt;code&gt;df&lt;/code&gt; counted physical extents, which is honest about disk usage but tells you nothing about what a file-based restore will write.&#10;And restic reported the &lt;em&gt;apparent&lt;/em&gt; size of sparse VM images, which inflated the restore estimate to several times the disk.&lt;/p&gt;&#10;&lt;p&gt;When your storage layer gives you three sizes for the same data, you do not really understand your backup.&#10;You have a backup, and you have hope.&lt;/p&gt;&#10;&lt;p&gt;&lt;span class="hover-tip" data-tooltip="How long will it take?" tabindex="0"&gt;Question 4&lt;/span&gt; was no better.&#10;Because the PC was my only btrfs machine, it could not join &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/replication.nix" target="_blank" rel="noopener"&gt;the ZFS snapshot replication the rest of the fleet uses&lt;/a&gt;, so its backups relied entirely on restic pushing to the NAS.&#10;Off-machine, note, but not off-site; &lt;span class="hover-tip" data-tooltip="What is not in the backup, and did I choose that deliberately?" tabindex="0"&gt;question 2&lt;/span&gt; turned out to apply to locations too, and closing that gap is its own project.&#10;restic is file-based: every run visits the metadata of every single file to find what changed.&#10;On this machine that meant close to a hundred million files per scan, which turned out to include some 600 GB of git worktrees stuffed with virtual environments and &lt;code&gt;node_modules&lt;/code&gt;.&#10;Each run took about an hour and a half, even when almost nothing had changed.&lt;/p&gt;&#10;&lt;p&gt;That scan time dictated my backup cadence.&#10;The timer ran hourly, which in practice meant back-to-back scanning all day to move a couple hundred megabytes of real changes, and I recently &lt;a href="https://github.com/basnijholt/dotfiles/commit/4f2401de725d5201d4daca0e67505ad2e6f6691a" target="_blank" rel="noopener"&gt;dropped it to every six hours&lt;/a&gt; to stop the madness.&#10;Either way, my most recent work existed only on the machine an agent was busy poking at.&lt;/p&gt;&#10;&lt;p&gt;The scanning is also not free for the hardware.&#10;Reads are gentle on an SSD compared to writes, but hourly scans of an hour and a half each kept the drive at full duty cycle and elevated temperature for most of every day.&#10;And drives stopped being cheap: I paid $227 for &lt;a href="https://www.nijho.lt/post/homelab/"&gt;a 4 TB NVMe drive&lt;/a&gt; in 2024, and the same drive lists for around $800 today.&#10;I would rather not run the experiment of what years of continuous scanning do to a drive I can no longer afford to replace.&lt;/p&gt;&#10;&lt;p&gt;btrfs has perfectly good snapshots of its own, but sending them off-site was never an option for me: snapshot replication needs the same filesystem on the receiving end, and my NAS speaks ZFS.&#10;With ZFS I can snapshot every few minutes and &lt;code&gt;zfs send&lt;/code&gt; the actual block storage: an incremental send streams exactly the blocks that changed since the last snapshot, without walking a single file.&#10;A hundred million files cost nothing when nobody has to visit them.&lt;/p&gt;&#10;&lt;p&gt;ZFS does not magically collapse those numbers into one.&#10;But it gives me the same semantics everywhere, including on the NAS pools that hold the backups themselves.&#10;Eight of my machines already share a single ZFS disk layout, defined in &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/common/disko-zfs.nix" target="_blank" rel="noopener"&gt;one shared disko module&lt;/a&gt;.&#10;The PC was the odd one out.&#10;This migration makes it nine machines with the exact same strategy, and one backup story to audit instead of one per machine.&lt;/p&gt;&#10;&lt;h2 id="the-plan-one-shared-disko-module"&gt;The plan: one shared disko module &lt;a class="anchor" href="#the-plan-one-shared-disko-module" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The Nix side was the easy part.&#10;The PC now imports the same shared disko module as the other eight machines, with one addition: a &lt;code&gt;swapSize&lt;/code&gt; parameter, because ZFS has no reliable swapfile story and the PC actually needs its 96 GB of swap.&lt;/p&gt;&#10;&lt;p&gt;The disk is &lt;a href="https://github.com/basnijholt/dotfiles/blob/8c0bc5faded40362ac01347682aaf2089562047d/configs/nixos/hosts/pc/disko.nix" target="_blank" rel="noopener"&gt;pinned by ID&lt;/a&gt;, not by device name:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/dev/disk/by-id/nvme-Samsung_SSD_990_EVO_Plus_4TB_...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This matters more than usual here.&#10;The PC dual-boots Windows from a second NVMe drive, and I want zero ambiguity about which of the two disks disko is allowed to erase.&lt;/p&gt;&#10;&lt;p&gt;The scary property of this migration is different from the NAS one.&#10;For the NAS, the whole point was that disko must &lt;em&gt;not&lt;/em&gt; touch the data pools.&#10;For the PC, the new ZFS pool goes on the &lt;strong&gt;same disk the data currently lives on&lt;/strong&gt;.&#10;No second disk, no local rollback, no btrfs to boot back into.&#10;From the moment disko runs, the restic repository on the NAS is the only road back.&lt;/p&gt;&#10;&lt;p&gt;That sentence is the reason most of this post is about verification.&lt;/p&gt;&#10;&lt;p&gt;The road back also has to survive the same agents that motivated all of this.&#10;The PC pushes its backups over sftp as a user that owns every file in the repository, which means a compromised PC could delete or encrypt its own safety net.&#10;So the NAS &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/storage.nix#L128-L143" target="_blank" rel="noopener"&gt;snapshots the repository dataset on its own side&lt;/a&gt;, where the PC&amp;rsquo;s credentials cannot reach: two days of hourly ZFS snapshots and two weeks of dailies that only root on the NAS can destroy.&lt;/p&gt;&#10;&lt;h2 id="rehearsing-in-a-vm-again"&gt;Rehearsing in a VM, again &lt;a class="anchor" href="#rehearsing-in-a-vm-again" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;NAS migration&lt;/a&gt; taught me to rehearse the destructive step in a VM, so the flake grew a &lt;a href="https://github.com/basnijholt/dotfiles/blob/8c0bc5faded40362ac01347682aaf2089562047d/configs/nixos/flake.nix#L110" target="_blank" rel="noopener"&gt;&lt;code&gt;pc-vmtest&lt;/code&gt; target&lt;/a&gt; and I ran the whole install through &lt;code&gt;nixos-anywhere --vm-test&lt;/code&gt;.&#10;Disko formatted a virtual disk, created the pool, installed the system, and booted it with swap active.&lt;/p&gt;&#10;&lt;p&gt;Passing that test proves the generated logic.&#10;It does not prove the backup, which is where the real risk lives.&lt;/p&gt;&#10;&lt;h2 id="the-recovery-kit"&gt;The recovery kit &lt;a class="anchor" href="#the-recovery-kit" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;A restore that starts with &amp;ldquo;first, log in to the dead machine and fetch the credentials&amp;rdquo; is not a restore plan.&#10;So everything a bare reinstall needs lives in a small recovery kit on two other machines in the house, permissions locked down: the restic password and SSH key that reach the repository, the staged restore script, a couple of verification scripts, and the PC&amp;rsquo;s SSH host keys.&#10;The host keys matter more than they look: the reinstalled machine comes back with its old identity, so no client complains and nothing in the fleet needs to be taught to trust a new machine.&#10;A munge key and a README round it out.&lt;/p&gt;&#10;&lt;h2 id="trusting-the-backup-enough-to-wipe-the-disk"&gt;Trusting the backup enough to wipe the disk &lt;a class="anchor" href="#trusting-the-backup-enough-to-wipe-the-disk" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My recovery story has two layers.&#10;NixOS makes the &lt;em&gt;system&lt;/em&gt; disposable: every service, driver fix, and firewall rule is in git, so no agent can leave the machine in a state I cannot rebuild.&#10;Verified backups make the &lt;em&gt;data&lt;/em&gt; recoverable.&#10;This migration is the voluntary version of the tweets above: I am about to delete all of my PC&amp;rsquo;s files on purpose.&lt;/p&gt;&#10;&lt;p&gt;I did three verifications, each one converting an assumption into a fact.&#10;An AI agent did the tedious sweeps; I reviewed the evidence.&lt;/p&gt;&#10;&lt;p&gt;The first check was completeness.&#10;We walked everything on the machine &lt;em&gt;outside&lt;/em&gt; the backup paths, until every byte was accounted for.&#10;It was all re-downloadable caches, and the largest unexplained thing turned out to be restic&amp;rsquo;s own local cache.&#10;Everything outside the backup set is now &lt;em&gt;known&lt;/em&gt; disposable instead of &lt;em&gt;assumed&lt;/em&gt; disposable.&lt;/p&gt;&#10;&lt;p&gt;The second check was the restore path.&#10;A backup you have never restored from is a hypothesis.&#10;On a different machine, using only the recovery kit and no access to the PC at all, we restored a sample from the latest snapshot and compared hashes against the live system.&#10;The hashes matched byte for byte, and even the symlinks came back pointing at the right targets.&#10;A sample proves the path works, from credentials to symlink handling; reading back every byte is the third check&amp;rsquo;s job.&#10;The machine running the rehearsal had the kit and nothing else, so recovery provably does not depend on the machine being recovered.&lt;/p&gt;&#10;&lt;p&gt;The third check is the data itself.&#10;&lt;code&gt;restic check&lt;/code&gt; validates the repository structure without reading back the actual data blobs.&#10;Before the wipe, a &lt;code&gt;--read-data&lt;/code&gt; pass ran on the NAS, where the repository sits on local disk instead of behind the network.&#10;When the backup is the only rollback, I want its bits read back at least once.&lt;/p&gt;&#10;&lt;h2 id="the-fifth-question"&gt;The fifth question &lt;a class="anchor" href="#the-fifth-question" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The outage taught me a &lt;span class="hover-tip" data-tooltip="How do I find out when any of this stops working?" tabindex="0"&gt;fifth question&lt;/span&gt;: how do I find out when any of this stops working?&#10;All three checks above are point-in-time, and verification rots.&#10;So the NAS now &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/replication.nix#L263-L285" target="_blank" rel="noopener"&gt;re-verifies every hour&lt;/a&gt;, from its own side of the sftp connection, that the newest snapshot in the repository is less than a day old, and pushes an alert to my phone when it is not.&#10;That catches every failure mode on the PC side, including &amp;ldquo;the timer is simply disabled.&amp;rdquo;&#10;The watcher itself is watched: the NAS pings an &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/health.nix#L252-L280" target="_blank" rel="noopener"&gt;external dead-man&amp;rsquo;s switch&lt;/a&gt; every five minutes, so if the machine holding my only road back goes dark, the alert comes from outside the house.&lt;/p&gt;&#10;&lt;p&gt;While wiring this up we discovered that months of earlier NAS alerts had never arrived, because &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/health.nix#L60-L67" target="_blank" rel="noopener"&gt;ntfy silently rejects message bodies over 4 KB&lt;/a&gt;.&#10;A notification you have never received is also a hypothesis.&lt;/p&gt;&#10;&lt;h2 id="restoring-in-stages"&gt;Restoring in stages &lt;a class="anchor" href="#restoring-in-stages" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The measured restore set came out to roughly 1 TB, and 99.9% of it is &lt;code&gt;/home&lt;/code&gt;.&#10;But two directories are 64% of that terabyte: Steam (430 GB of re-downloadable games) and my local-AI model collection (217 GB of GGUF files).&lt;/p&gt;&#10;&lt;p&gt;I do not need either of those to have a working machine.&#10;So the restore script now runs in stages:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./restore_from_backup.sh stage1 &lt;span class="c1"&gt;# everything else → a fully working machine&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./restore_from_backup.sh stage2 &lt;span class="c1"&gt;# Steam + models, whenever&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Stage 1 restores everything except the deferred paths, plus the system identity (host keys).&#10;Stage 2 can run days later, from the rebooted system, while I am already using the machine.&lt;/p&gt;&#10;&lt;p&gt;One detail I care about: stage 1 resolves &lt;code&gt;latest&lt;/code&gt; to a concrete snapshot ID and pins it to a file.&#10;Stage 2 reads the pin.&#10;My backups run every six hours, so without this, a timer firing between the stages would silently split the restore across two different snapshots.&lt;/p&gt;&#10;&lt;p&gt;The backup itself is untouched: Steam and the models are still in every snapshot.&#10;Only the restore order changed.&lt;/p&gt;&#10;&lt;h2 id="slimming-down-first"&gt;Slimming down first &lt;a class="anchor" href="#slimming-down-first" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The PC had also quietly become an Incus host for three LXC containers, including the &lt;a href="https://www.nijho.lt/post/mindroom/"&gt;MindRoom bots&lt;/a&gt; my family talks to every day.&#10;Before the migration, those containers moved to the NAS.&#10;That shrinks both the backup and the restore, and the family bots keep running while their old host is being wiped.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;Moving the containers produced its own lesson in btrfs size confusion.&#10;One container that measured &amp;ldquo;about 250 GB&amp;rdquo; shipped over 590 GB across the network overnight, because a directory that every disk-usage probe had failed to measure turned out to hold 2.5 TB of reflink-inflated build dependencies.&#10;The rule I took away: a directory that has &lt;em&gt;never been successfully measured&lt;/em&gt; is a red alert, not a rounding error.&#10;That kind of confusion is part of why this migration exists.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="the-cutover"&gt;The cutover &lt;a class="anchor" href="#the-cutover" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The install followed the same phased &lt;code&gt;nixos-anywhere&lt;/code&gt; pattern as the NAS cutover, driven from another machine on the LAN: kexec into an installer running in RAM, stop, re-verify the target disk from inside the installer, and only then run the destructive disko/install phase.&#10;That checkpoint is the whole point: right up to the destructive phase, aborting was still cheap.&#10;The destructive part turned out to be the trivial part: from wiping the disk to booting the new system took less than twenty minutes.&lt;/p&gt;&#10;&lt;p&gt;The one surprise of the day was in the restore, and it was exactly the kind rehearsal is supposed to catch.&#10;Stage 1 would not start: the restore script runs restic as root, and a freshly installed root has no SSH identity and no known_hosts.&#10;The key it needed sat in the recovery kit inside my home directory.&#10;The rehearsal never hit this, because the rehearsal machine&amp;rsquo;s root had already talked to the NAS.&#10;A verified restore path can still depend on state you forgot you had.&lt;/p&gt;&#10;&lt;p&gt;Stage 1 restored 534 GiB across 4.5 million files in just over an hour and a half, at wire speed.&#10;The machine rebooted with its old host keys and its old &lt;code&gt;/home&lt;/code&gt;, and stage 2 started immediately.&#10;The plan said stage 2 could wait for days; it started the minute the machine was back, because once the machine works there is no reason left to wait.&#10;The deferred 648 GiB of games and models took six more hours in the background.&lt;/p&gt;&#10;&lt;p&gt;With that, the PC finally joins the fleet pattern it was locked out of: sanoid snapshots the pool every hour, so a tired &lt;code&gt;rm -rf&lt;/code&gt;, mine or an agent&amp;rsquo;s, now costs at most an hour of work and a rollback, and a daily syncoid run sends the changed blocks to the NAS.&#10;restic is on borrowed time.&#10;Its six-hour timer keeps running until I trust the new path, and then it stops: the repository stays on the NAS as the last file-level snapshots of the machine, but nothing will be added to it.&lt;/p&gt;&#10;&lt;p&gt;Aftercare was the same short list, written down instead of remembered: revert the temporary root-SSH commit the installer needed, and put the restic password back where the backup service expects it.&lt;/p&gt;&#10;&lt;p&gt;The slow work, as always, was not the cutover.&#10;The slow work was making the cutover boring.&lt;/p&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;I&amp;rsquo;ve gone full Nix: Proxmox to NixOS + Incus&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;Migrating from TrueNAS to NixOS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/homelab/"&gt;My homelab&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/pull/16" target="_blank" rel="noopener"&gt;The btrfs→ZFS migration PR&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/pull/67" target="_blank" rel="noopener"&gt;The PR that closed the silent backup-monitoring failure modes&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/nix-community/disko" target="_blank" rel="noopener"&gt;disko&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/nix-community/nixos-anywhere" target="_blank" rel="noopener"&gt;nixos-anywhere&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://restic.net/" target="_blank" rel="noopener"&gt;restic&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/jimsalterjrs/sanoid" target="_blank" rel="noopener"&gt;Sanoid&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Migrating from TrueNAS to NixOS without losing data after the build-system rug pull</title><link>https://www.nijho.lt/post/truenas-to-nixos/</link><pubDate>Sun, 28 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/truenas-to-nixos/</guid><description>&lt;p&gt;TrueNAS was the last non-NixOS machine in my home lab.&lt;/p&gt;&#10;&lt;p&gt;I kept postponing it for a simpler reason: moving something with ~100 TiB of data feels scary.&#10;My NAS holds the big ZFS pools.&#10;It has the SMB shares, NFS exports, snapshots, replication tasks, encrypted datasets, Incus state, Docker workloads, and all the boring-but-important health checks that are easy to take for granted until something fails.&lt;/p&gt;&#10;&lt;p&gt;I had already migrated most of my machines from Proxmox to NixOS with Incus, as I wrote about in &lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;I&amp;rsquo;ve gone full Nix: Proxmox to NixOS + Incus&lt;/a&gt;.&#10;That migration made me increasingly intolerant of appliance-style infrastructure.&#10;If the important state lives in a database behind a web UI, I cannot review it in a pull request.&#10;If I change something while debugging, I cannot guarantee the change made it back into the source of truth.&#10;And if I let an AI agent help me, imperative systems become even more uncomfortable: an agent can run a hundred commands, succeed, and leave behind a system that nobody can reproduce.&#10;For the NAS specifically, that basically meant I did not dare touch the machine with an AI agent at all.&lt;/p&gt;&#10;&lt;p&gt;NixOS is different.&#10;The diff is the state.&#10;If an agent changes my infrastructure, I can review the patch.&#10;If the change is wrong, I can reject it.&#10;If it works, I can commit it.&lt;/p&gt;&#10;&lt;p&gt;TrueNAS was the last holdout.&#10;It had been itching for a while.&#10;Then the &lt;a href="https://forums.truenas.com/t/clearing-the-air-on-build-scripts/64357" target="_blank" rel="noopener"&gt;build-system rug pull&lt;/a&gt; made the decision easy.&lt;/p&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#the-itch-before-the-last-straw"&gt;The itch before the last straw&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-last-straw"&gt;The last straw&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-this-was-scarier-than-proxmox"&gt;Why this was scarier than Proxmox&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#giving-an-agent-the-keys-carefully"&gt;Giving an agent the keys, carefully&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-nixos-scaffold"&gt;The NixOS scaffold&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-disko-problem"&gt;The disko problem&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#rehearsing-the-dangerous-part-in-a-vm"&gt;Rehearsing the dangerous part in a VM&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-parts-that-are-not-just-storage"&gt;The parts that are not just storage&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#secrets-stay-out-of-the-repo"&gt;Secrets stay out of the repo&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#where-this-leaves-truenas"&gt;Where this leaves TrueNAS&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-cutover"&gt;The cutover&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="the-itch-before-the-last-straw"&gt;The itch before the last straw &lt;a class="anchor" href="#the-itch-before-the-last-straw" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The build-system change was not the first thing that bothered me.&#10;It was just the last straw.&lt;/p&gt;&#10;&lt;p&gt;The deeper problem was that my TrueNAS setup had slowly become complicated in exactly the way I dislike.&#10;I had clicked many buttons in the UI over time:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;SMB shares&lt;/li&gt;&#10;&lt;li&gt;NFS exports&lt;/li&gt;&#10;&lt;li&gt;snapshot tasks&lt;/li&gt;&#10;&lt;li&gt;replication tasks&lt;/li&gt;&#10;&lt;li&gt;users and groups&lt;/li&gt;&#10;&lt;li&gt;encrypted datasets&lt;/li&gt;&#10;&lt;li&gt;apps and containers&lt;/li&gt;&#10;&lt;li&gt;Incus storage&lt;/li&gt;&#10;&lt;li&gt;old Jailmaker/systemd-nspawn state&lt;/li&gt;&#10;&lt;li&gt;alerting&lt;/li&gt;&#10;&lt;li&gt;UPS settings&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;TrueNAS can export and import its configuration.&#10;That is genuinely useful.&#10;But a database export is not the same thing as a configuration I can comfortably review.&#10;When I look at a TrueNAS backup, it is not obvious to me which settings I intentionally changed, which settings are defaults, which settings are historical leftovers, and which settings only exist because I clicked something while debugging two years ago.&lt;/p&gt;&#10;&lt;p&gt;One concrete example was NFS.&#10;I needed the &lt;code&gt;crossmnt&lt;/code&gt; export option so child datasets would show up correctly inside the Docker container host, but TrueNAS did not expose that option in the UI.&#10;So I had a periodic root cron job patching &lt;code&gt;/etc/exports&lt;/code&gt; after TrueNAS generated it.&#10;It worked, but it was exactly the kind of small appliance hack that made me uneasy: partly outside the UI, partly outside the exported configuration, and very easy to forget during a migration.&#10;In NixOS, that behavior is just part of the export line.&lt;/p&gt;&#10;&lt;p&gt;That is the exact discomfort that pushed me away from Proxmox.&#10;I do not want to remember what I clicked.&#10;I want the system to remember it as code.&lt;/p&gt;&#10;&lt;p&gt;The second itch was virtualization.&#10;My TrueNAS container story had already gone through one migration.&#10;For a while I used &lt;a href="https://github.com/Jip-Hop/jailmaker" target="_blank" rel="noopener"&gt;Jailmaker&lt;/a&gt;, which is a clever script around &lt;code&gt;systemd-nspawn&lt;/code&gt; for running Linux &amp;ldquo;jails&amp;rdquo; on TrueNAS SCALE.&#10;It worked well enough for what I needed at the time: a Debian environment where I could run Docker workloads close to the data.&#10;But it also reinforced the feeling that I was building a fairly custom server on top of an appliance OS.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://www.truenas.com/docs/scale/25.04/" target="_blank" rel="noopener"&gt;TrueNAS 25.04 &amp;ldquo;Fangtooth&amp;rdquo;&lt;/a&gt; introduced experimental Containers, named Instances in the early 25.04 releases, and the &lt;a href="https://www.truenas.com/blog/truenas-fangtooth-25-04-1/" target="_blank" rel="noopener"&gt;25.04.1 announcement&lt;/a&gt; explicitly described Instances as being based on Incus, supporting both VMs and LXCs.&#10;That was interesting to me because the older TrueNAS VM system was libvirt-based, while Incus felt like a more coherent container/VM story.&#10;Then the &lt;a href="https://forums.truenas.com/t/truenas-virtualization-plans-for-25-04-2/46236" target="_blank" rel="noopener"&gt;TrueNAS virtualization plan for 25.04.2&lt;/a&gt; explained that Fangtooth&amp;rsquo;s Instances feature caused challenges for some users and that 25.04.2 would re-enable the older Virtualization VM path alongside Instances.&#10;The &lt;a href="https://forums.truenas.com/t/truenas-25-04-2-is-now-available/49165" target="_blank" rel="noopener"&gt;25.04.2 release announcement&lt;/a&gt; made that split official: Virtual Machines came back, while VMs created through the earlier Instances feature continued to appear under Containers.&lt;/p&gt;&#10;&lt;p&gt;For many people that churn was probably annoying.&#10;For me it was especially ironic.&#10;I had migrated from Jailmaker&amp;rsquo;s &lt;code&gt;systemd-nspawn&lt;/code&gt; setup to Incus, read up on &lt;a href="https://linuxcontainers.org/incus/" target="_blank" rel="noopener"&gt;Incus&lt;/a&gt;, got excited about it, started using it, and then became genuinely happy with it when I migrated from Proxmox to NixOS with Incus.&#10;Incus fit my mental model perfectly: a clean CLI, containers and VMs in one place, easy scripting, and a workflow that agents can inspect.&lt;/p&gt;&#10;&lt;p&gt;Then TrueNAS started moving away from that direction again.&#10;Forum discussion around the 25.04.2 and later virtualization plans made it clear that Incus was not the stable long-term abstraction I hoped it would be inside TrueNAS.&#10;In the long-running &lt;a href="https://forums.truenas.com/t/linux-jails-containers-vms-with-incus/23599?page=21" target="_blank" rel="noopener"&gt;Linux Jails with Incus thread&lt;/a&gt;, Kris Moore is quoted saying that Incus is being removed from the base system and that libvirt will handle the backend for VMs and LXC.&#10;That probably makes sense for TrueNAS.&#10;They have enterprise users, compatibility concerns, and a support matrix that is very different from mine.&lt;/p&gt;&#10;&lt;p&gt;But for me it meant the NAS was again becoming an appliance whose internal direction I had to follow.&#10;I had just escaped that with Proxmox.&#10;I did not want to do it again for the most important machine in my house.&lt;/p&gt;&#10;&lt;h2 id="the-last-straw"&gt;The last straw &lt;a class="anchor" href="#the-last-straw" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Then I saw Jeff Geerling mention TrueNAS&amp;rsquo;s recent &amp;ldquo;shady behavior&amp;rdquo; in a video, and I started looking into what had actually changed.&lt;/p&gt;&#10;&lt;p&gt;The short version is that iXsystems made part of the TrueNAS ecosystem closed source.&#10;Specifically, iXsystems moved the TrueNAS build scripts and related build infrastructure internal for the TrueNAS 27 line.&#10;Their forum post, &lt;a href="https://forums.truenas.com/t/clearing-the-air-on-build-scripts/64357" target="_blank" rel="noopener"&gt;Clearing the Air on Build Scripts&lt;/a&gt;, frames this as a practical build-system decision and says normal installation and updates remain unaffected.&#10;The old &lt;a href="https://github.com/truenas/scale-build" target="_blank" rel="noopener"&gt;&lt;code&gt;truenas/scale-build&lt;/code&gt;&lt;/a&gt; repository now says it is preserved historically and is no longer the maintained build system.&#10;Community reactions, for example &lt;a href="https://forums.truenas.com/t/truenas-closing-its-build-system-has-sparked-a-significant-trust-crisis-in-the-self-hosted-and-engineering-communities/64549" target="_blank" rel="noopener"&gt;this TrueNAS forum thread&lt;/a&gt;, understandably read this as a meaningful step away from fully open development.&lt;/p&gt;&#10;&lt;p&gt;Would I ever have built TrueNAS from source myself?&#10;Almost certainly not.&lt;/p&gt;&#10;&lt;p&gt;That misses the point.&#10;I also rarely compile my Linux kernel manually, yet I care deeply that the ecosystem is open enough that I &lt;em&gt;could&lt;/em&gt; understand, rebuild, inspect, and fork the pieces if necessary.&#10;For a storage appliance, trust matters more than convenience.&#10;The NAS is not a toy VM.&#10;It is the machine that owns my data.&lt;/p&gt;&#10;&lt;p&gt;That was the final push.&#10;The UI state bothered me.&#10;The virtualization churn bothered me.&#10;The build-system change made the decision obvious.&lt;/p&gt;&#10;&lt;h2 id="why-this-was-scarier-than-proxmox"&gt;Why this was scarier than Proxmox &lt;a class="anchor" href="#why-this-was-scarier-than-proxmox" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Migrating Proxmox to NixOS was scary in the normal infrastructure sense.&#10;I had containers and VMs to move, but the blast radius was understandable.&#10;I could export with &lt;code&gt;vzdump&lt;/code&gt;, import into Incus, and test a lot of it in virtual machines.&lt;/p&gt;&#10;&lt;p&gt;Migrating TrueNAS is a different kind of scary.&#10;The data is not a VM disk that I can casually copy around.&#10;The important thing is &lt;em&gt;not&lt;/em&gt; to recreate the data pools.&#10;The important thing is to &lt;strong&gt;not touch them&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;The target architecture is simple in principle:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Conceptually:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - disko owns only the boot disk&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - existing data pools are imported by name&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zfs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extraPools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;ssd&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;tank&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The NixOS boot disk can be destroyed and recreated.&#10;The existing &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt; pools cannot.&lt;/p&gt;&#10;&lt;p&gt;This is the part where many migration guides become hand-wavy:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Install the new OS.&lt;/li&gt;&#10;&lt;li&gt;Import your ZFS pools.&lt;/li&gt;&#10;&lt;li&gt;Good luck.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;That is not enough for me.&#10;I could physically access the machine, but unplugging the relevant NVMe hardware would mean taking apart more of the box than I wanted to touch.&#10;So I treated the cutover as if the disks had to stay connected.&#10;And I absolutely did not want one typo in a disk path to become yet another meme in AI agent failures.&lt;/p&gt;&#10;&lt;h2 id="giving-an-agent-the-keys-carefully"&gt;Giving an agent the keys, carefully &lt;a class="anchor" href="#giving-an-agent-the-keys-carefully" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Without AI this would probably have become a week-long project that I would forever postpone.&#10;With AI, it felt plausible to get the first serious version done in a single sitting.&#10;My approach was to give GPT 5.5 in xhigh mode SSH access to the live TrueNAS box and tell it, repeatedly and very explicitly, not to change anything.&#10;To be clear, it had root access.&#10;At this point these models have become powerful enough that I trust them to follow explicit instructions.&#10;The safety boundary was instruction-following, narrow scope, and review.&lt;/p&gt;&#10;&lt;p&gt;Not &amp;ldquo;please be careful.&amp;rdquo;&#10;More like:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;You can inspect.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;You cannot change anything.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do not start or stop services.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do not import or export pools.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do not load keys.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do not mount datasets.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Read only.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is the part where people either nod along or think I have completely lost my mind.&#10;I think the distinction matters.&#10;I did not ask an agent to &amp;ldquo;migrate my NAS.&amp;rdquo;&#10;I asked it to inspect the current system, summarize the live state, compare that against my NixOS repository, and propose declarative config.&lt;/p&gt;&#10;&lt;p&gt;Then I reviewed the diffs.&#10;Then I asked for another review.&#10;Then another.&#10;The process was messier than one neat two-agent pass: build a scaffold, ask a model to attack it, compare that against the live TrueNAS state, refine the scaffold, and repeat.&#10;GPT 5.5 did most of the initial construction.&#10;Claude Opus 4.8 on max effort did one of the later independent cross-checks against the live system.&#10;By the end, I trusted the result because multiple independent reviews had converged on the same assumptions, and I understood the remaining differences.&#10;The loop felt like code review: inspect live state, turn the desired parts into Nix, review the diff, and ask another model to attack it.&#10;The scariest assumption got a VM test, and the destructive step stayed small.&lt;/p&gt;&#10;&lt;p&gt;Byte-for-byte TrueNAS cloning was never the target.&#10;I wanted every important behavior explicit: storage import, shares, snapshots, replication, Incus recovery, monitoring, and the few places where NixOS intentionally differs from the old appliance.&#10;At that point, the NixOS config reproduced the parts of TrueNAS I actually depended on, and the remaining differences were deliberate.&lt;/p&gt;&#10;&lt;p&gt;The agent was excellent at inventory:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Which pools exist&lt;/li&gt;&#10;&lt;li&gt;Which datasets are encrypted&lt;/li&gt;&#10;&lt;li&gt;Which NFS exports are live&lt;/li&gt;&#10;&lt;li&gt;Which SMB shares exist&lt;/li&gt;&#10;&lt;li&gt;Which users and numeric IDs matter&lt;/li&gt;&#10;&lt;li&gt;What Incus storage pool is in use&lt;/li&gt;&#10;&lt;li&gt;What replication tasks exist&lt;/li&gt;&#10;&lt;li&gt;What SMART/NUT/health monitoring exists&lt;/li&gt;&#10;&lt;li&gt;What host ID ZFS expects&lt;/li&gt;&#10;&lt;li&gt;Which disk is the current boot pool&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Preserving everything blindly would have been wrong.&#10;The useful parts became code, and the dangerous parts became explicit.&lt;/p&gt;&#10;&lt;h2 id="the-nixos-scaffold"&gt;The NixOS scaffold &lt;a class="anchor" href="#the-nixos-scaffold" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The resulting NixOS host is split into small files:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hosts/nas/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── default.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── disko.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── hardware-configuration.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── health.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── identity.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── networking.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── nfs.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── replication.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── samba.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── storage.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── virtualization.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── CUTOVER.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└── PLANNING.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is exactly why I like NixOS.&#10;The migration stopped being a list of steps I needed to follow and became a &lt;a href="https://github.com/basnijholt/dotfiles/pull/61/" target="_blank" rel="noopener"&gt;pull request&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The important storage boundary is:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;disko.nix&lt;/code&gt; describes only the boot disk.&lt;/li&gt;&#10;&lt;li&gt;The data pools are imported by name.&lt;/li&gt;&#10;&lt;li&gt;ZFS force-import is disabled.&lt;/li&gt;&#10;&lt;li&gt;Boot-time encrypted credential prompts are disabled.&lt;/li&gt;&#10;&lt;li&gt;Encrypted datasets are unlocked manually after boot.&lt;/li&gt;&#10;&lt;li&gt;Sanoid/Syncoid replace TrueNAS snapshot and replication tasks.&lt;/li&gt;&#10;&lt;li&gt;SMB, NFS, Docker, Incus, SMART, ZED, NUT, Netdata, and exporters are all declared in code.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The data pools are intentionally &lt;em&gt;not&lt;/em&gt; in disko.&#10;That is the entire point.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;The host is called &lt;code&gt;nas&lt;/code&gt; because that is the role I want to keep.&#10;TrueNAS was the old implementation.&#10;NixOS becomes the new one, while preserving the services and data I actually need.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="the-disko-problem"&gt;The disko problem &lt;a class="anchor" href="#the-disko-problem" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/nix-community/disko" target="_blank" rel="noopener"&gt;&lt;code&gt;disko&lt;/code&gt;&lt;/a&gt; is a fantastic tool.&#10;It lets you describe disks declaratively and then formats/partitions/mounts them.&#10;For a fresh NixOS install, it is exactly what I want.&lt;/p&gt;&#10;&lt;p&gt;This also fits how I install NixOS machines.&#10;I build a custom &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/installers/iso.nix" target="_blank" rel="noopener"&gt;installer ISO&lt;/a&gt;, put it on a USB stick, and boot from it.&#10;The image already has SSH enabled with my keys, flakes enabled, and the tooling needed to run the repo-defined &lt;code&gt;disko&lt;/code&gt; and &lt;code&gt;nixos-install&lt;/code&gt; flow.&#10;That turns the installer environment into part of the reviewed configuration, instead of another place where I have to remember which choices to make.&lt;/p&gt;&#10;&lt;p&gt;It is also exactly the tool that can ruin your day if pointed at the wrong disk.&lt;/p&gt;&#10;&lt;p&gt;My requirement was:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Use disko to destroy and recreate the boot disk, but prove it cannot touch the existing ZFS data pools.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The static config already looked right.&#10;The generated disko script only referenced the boot device and generated partition labels.&#10;The data pools were only present under &lt;code&gt;boot.zfs.extraPools&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;But &amp;ldquo;looks right&amp;rdquo; is not enough when the downside is wiping a NAS.&lt;/p&gt;&#10;&lt;p&gt;So we built a VM rehearsal.&lt;/p&gt;&#10;&lt;h2 id="rehearsing-the-dangerous-part-in-a-vm"&gt;Rehearsing the dangerous part in a VM &lt;a class="anchor" href="#rehearsing-the-dangerous-part-in-a-vm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The VM test creates a miniature version of the dangerous situation:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Attach several throwaway disks to a disposable NixOS VM.&lt;/li&gt;&#10;&lt;li&gt;Create a fake &lt;code&gt;boot-pool&lt;/code&gt; on one disk.&lt;/li&gt;&#10;&lt;li&gt;Create fake &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt; ZFS pools on other disks.&lt;/li&gt;&#10;&lt;li&gt;Write sentinel files into the fake data pools.&lt;/li&gt;&#10;&lt;li&gt;Export the fake data pools.&lt;/li&gt;&#10;&lt;li&gt;Run the generated &lt;code&gt;nas&lt;/code&gt; disko script against the fake boot disk.&lt;/li&gt;&#10;&lt;li&gt;Confirm the boot disk now contains the new &lt;code&gt;zroot&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Confirm fake &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt; still have their ZFS labels.&lt;/li&gt;&#10;&lt;li&gt;Re-import fake &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Read the sentinel files.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The check lives in the flake now:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; configs/nixos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;nix build .#checks.x86_64-linux.nas-disko-safety&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It passed.&lt;/p&gt;&#10;&lt;p&gt;Passing that VM test gives me confidence in the generated Nix/disko logic.&#10;The read-only check on the live TrueNAS box gives me confidence in the configured disk target: the by-id path resolves to the 500 GB &lt;code&gt;boot-pool&lt;/code&gt; disk, and &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt; live on different devices.&#10;The installer preflight repeats that same check immediately before the destructive step.&lt;/p&gt;&#10;&lt;p&gt;This is the level of paranoia I want around storage migrations.&lt;/p&gt;&#10;&lt;h2 id="the-parts-that-are-not-just-storage"&gt;The parts that are not just storage &lt;a class="anchor" href="#the-parts-that-are-not-just-storage" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Once the &amp;ldquo;do not wipe the pools&amp;rdquo; boundary was in place, the rest of the migration became more normal infrastructure work.&#10;Still annoying, but normal.&lt;/p&gt;&#10;&lt;p&gt;SMB was not a byte-for-byte port.&#10;TrueNAS ships private Samba VFS modules and has its own ACL/middleware behavior.&#10;Stock NixOS Samba covers the parts I need with &lt;code&gt;fruit&lt;/code&gt;, &lt;code&gt;streams_xattr&lt;/code&gt;, and sensible share definitions, but it is not literally the same stack.&#10;So the plan calls out what must be tested from real clients: Time Machine, guest access, and photo/media access.&lt;/p&gt;&#10;&lt;p&gt;Snapshots and replication also changed.&#10;TrueNAS snapshot tasks become Sanoid policy.&#10;TrueNAS replication tasks become Syncoid systemd timers.&#10;Existing snapshots are preserved, but Sanoid will not magically prune old TrueNAS-named snapshots.&#10;That is a feature, not a bug.&#10;I would rather manually decide when old snapshots age out than have a new tool silently &amp;ldquo;clean up&amp;rdquo; history.&lt;/p&gt;&#10;&lt;p&gt;Incus is another subtle one.&#10;The storage pool lives on a data pool, so disko will not touch it.&#10;But a fresh NixOS install has a fresh Incus database.&#10;The plan is to run &lt;code&gt;incus admin recover&lt;/code&gt;, then apply a small declarative reconciler for the known instances.&#10;The state boundary has to be clear: preserve the storage dataset, recover the Incus database, then reconcile the instance config.&lt;/p&gt;&#10;&lt;p&gt;Health monitoring is also declared:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;SMARTD&lt;/li&gt;&#10;&lt;li&gt;ZFS Event Daemon&lt;/li&gt;&#10;&lt;li&gt;NUT/UPS client&lt;/li&gt;&#10;&lt;li&gt;Netdata bound locally&lt;/li&gt;&#10;&lt;li&gt;Prometheus exporters&lt;/li&gt;&#10;&lt;li&gt;A shared alert hook that can log, wall, and optionally send ntfy alerts&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;TrueNAS gives you many of these things in the UI.&#10;NixOS gives them to me as code.&lt;/p&gt;&#10;&lt;p&gt;I know using your NAS for other services is a bit of an anti-pattern.&#10;In an ideal world, the storage box just stores data and stays boring.&#10;But when I originally bought and configured this machine, it became the most powerful machine in my home lab.&#10;So it naturally started attracting the Docker workloads too.&#10;Most of those workloads are not resource intensive, and keeping them close to the data is genuinely convenient.&#10;At one point I was running more than a hundred containers on that machine and it barely made a dent.&#10;They ran inside LXC containers on Incus rather than directly on the TrueNAS host, which made it feel like a reasonable compromise for a long time.&lt;/p&gt;&#10;&lt;p&gt;The migration also became a chance to fix a real operational problem.&#10;My worst TrueNAS incident was a long OOM death spiral: no swap, too many unbounded containers, and services repeatedly getting killed and restarted.&#10;The philosophy came from Nix and open infrastructure.&#10;The OOM incident was the practical reminder that the NAS had become a general-purpose compute host.&#10;In the NixOS version, container memory limits, zram, earlyoom, and an explicit ZFS ARC cap are part of the configuration instead of being tribal knowledge or post-incident notes.&lt;/p&gt;&#10;&lt;h2 id="secrets-stay-out-of-the-repo"&gt;Secrets stay out of the repo &lt;a class="anchor" href="#secrets-stay-out-of-the-repo" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My dotfiles are public.&#10;That makes the migration more interesting.&lt;/p&gt;&#10;&lt;p&gt;The Nix config necessarily contains some operational facts: hostnames, share names, service definitions, and so on.&#10;But the plan deliberately keeps secrets and sensitive cutover material out of git.&lt;/p&gt;&#10;&lt;p&gt;For this migration, that means:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;ZFS dataset passphrases and unlock material stay off-box and outside the repo.&lt;/li&gt;&#10;&lt;li&gt;Replication private keys are installed manually under &lt;code&gt;/etc/ssh&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Inbound authorized keys for backup senders are installed during cutover.&lt;/li&gt;&#10;&lt;li&gt;Alerting secrets live in a runtime env file.&lt;/li&gt;&#10;&lt;li&gt;A local &lt;code&gt;~/nas-cutover/&lt;/code&gt; staging directory can exist, but it is explicitly not part of the repo and should not be casually read by agents.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;One important detail: the encrypted datasets use passphrase keys.&#10;In the TrueNAS setup I handled that with &lt;a href="https://github.com/basnijholt/truenas-unlock" target="_blank" rel="noopener"&gt;truenas-unlock&lt;/a&gt;, a small tool I wrote for this exact problem.&#10;The unlock material lives on another device; when that device is on the same network, it can unlock the datasets through the TrueNAS API.&#10;That gave me a lightweight hardware/network-presence factor: the NAS could boot, and the encrypted datasets only unlocked when the separate unlock device was present too.&lt;/p&gt;&#10;&lt;p&gt;That changes the migration footgun.&#10;The TrueNAS API path does not survive the cutover as-is, so I split the same idea out into &lt;a href="https://github.com/basnijholt/zfs-unlock" target="_blank" rel="noopener"&gt;zfs-unlock&lt;/a&gt;: the NixOS/OpenZFS version that talks to a restricted SSH receiver instead.&#10;During the migration I still kept an independent manual passphrase recovery path, because I do not want the automatic unlock mechanism to be the only way back into my data.&lt;/p&gt;&#10;&lt;h2 id="where-this-leaves-truenas"&gt;Where this leaves TrueNAS &lt;a class="anchor" href="#where-this-leaves-truenas" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I still like TrueNAS.&#10;It got me very far.&#10;It made ZFS approachable.&#10;It gave me a good NAS UI when I wanted a NAS UI.&#10;And if you want an appliance that mostly owns storage for you, it is still a very compelling project.&lt;/p&gt;&#10;&lt;p&gt;I like NixOS more for my own infrastructure.&#10;The most important machine in my house should live in reviewable code instead of mutable appliance state spread across a UI, a database, and old debugging decisions.&#10;NixOS and AI are a particularly good match for that.&#10;I can describe messy infrastructure intent in imprecise English, let the agent turn that into exact Nix code, and review the diff before it becomes system state.&#10;The prompt stays fuzzy.&#10;The machine state does not.&lt;/p&gt;&#10;&lt;p&gt;After the Proxmox migration, TrueNAS was the last major exception.&#10;This migration removes that exception.&lt;/p&gt;&#10;&lt;h2 id="the-cutover"&gt;The cutover &lt;a class="anchor" href="#the-cutover" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I ended up using a phased &lt;a href="https://github.com/nix-community/nixos-anywhere" target="_blank" rel="noopener"&gt;&lt;code&gt;nixos-anywhere&lt;/code&gt;&lt;/a&gt; cutover instead of the USB installer path.&#10;The important safety property stayed the same: first kexec into a temporary NixOS installer, stop there, run the disk preflight from inside that installer, and only then run the destructive &lt;code&gt;disko,install,reboot&lt;/code&gt; phase.&#10;Before the destructive phase, the box was running the installer in RAM and the disks had not been intentionally modified yet.&#10;That gave me a real checkpoint where aborting was still cheap.&lt;/p&gt;&#10;&lt;p&gt;I kept my hands on the few commands that actually changed the machine.&#10;I ran the &lt;code&gt;nixos-anywhere&lt;/code&gt; phases and the later &lt;code&gt;nixos-rebuild&lt;/code&gt; commands myself.&#10;The agent stayed next to it, reading the outputs, checking the assumptions, SSHing in to verify pool health, services, mounts, Incus state, NFS clients, and then turning any findings back into code or documentation.&#10;That split felt right to me: I pressed enter on the small number of irreversible commands, and the AI did the tedious verification work that I would otherwise be tempted to rush.&lt;/p&gt;&#10;&lt;p&gt;The actual migration was almost anticlimactic compared to the preparation.&#10;From the moment TrueNAS stopped being the running OS to having the Incus containers back with my hundred-plus services running again took less than 20 minutes.&#10;The slow work was not the cutover.&#10;The slow work was making the cutover boring.&lt;/p&gt;&#10;&lt;details&gt;&#10;&lt;summary&gt;The small things that still needed fixing during cutover&lt;/summary&gt;&#10;&lt;p&gt;There were a few small first-boot reconciliations. I am writing them down mostly for future readers who want to follow a similar path:&lt;/p&gt;&#10;&lt;ul&gt;&#10; &lt;li&gt;imported data-pool mountpoints needed to be reconciled to the &lt;code&gt;/mnt/...&lt;/code&gt; paths my clients and services already used;&lt;/li&gt;&#10; &lt;li&gt;encrypted datasets needed a better manual post-boot unlock helper, and the old TrueNAS API unlock flow needed a NixOS/OpenZFS successor;&lt;/li&gt;&#10; &lt;li&gt;Incus needed &lt;code&gt;incus admin recover&lt;/code&gt; because the storage pool survived but the new OS had a fresh Incus database;&lt;/li&gt;&#10; &lt;li&gt;one unprivileged recovered container needed the matching subordinate UID/GID ranges declared for its passthrough idmap;&lt;/li&gt;&#10; &lt;li&gt;&lt;code&gt;nas.local&lt;/code&gt; needed an explicit DNS record so my wildcard did not send it to a workload container;&lt;/li&gt;&#10; &lt;li&gt;the old PC job that backed up the TrueNAS API config needed to be removed;&lt;/li&gt;&#10; &lt;li&gt;the PC NFS mounts, Nix cache endpoints, and recovered Docker workload container all needed client-side validation.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Those findings went back into the cutover runbooks and the Nix config in a &lt;a href="https://github.com/basnijholt/dotfiles/pull/62"&gt;follow-up PR&lt;/a&gt;, so the notes are available for anyone who might benefit from them later.&lt;/p&gt;&#10;&lt;/details&gt;&#10;&lt;p&gt;At the end of the cutover, the machine was &lt;code&gt;nas&lt;/code&gt;, the ZFS pools were healthy, the NFS mounts worked from my PC, the Incus containers were recovered, and the Docker workloads were running again.&#10;SMB authentication, guest access, Time Machine, and normal media browsing from macOS Finder worked after one final share-root permission fix.&#10;At that point the migration was working end to end: the pools were healthy and the services I actually use were back.&lt;/p&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;I&amp;rsquo;ve gone full Nix: Proxmox to NixOS + Incus&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/homelab/"&gt;My homelab: from a single Raspberry Pi to a Proxmox cluster and a dedicated NAS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;On agentic coding&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/clearing-the-air-on-build-scripts/64357" target="_blank" rel="noopener"&gt;TrueNAS forum: Clearing the Air on Build Scripts&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/truenas/scale-build" target="_blank" rel="noopener"&gt;&lt;code&gt;truenas/scale-build&lt;/code&gt; on GitHub&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/truenas-closing-its-build-system-has-sparked-a-significant-trust-crisis-in-the-self-hosted-and-engineering-communities/64549" target="_blank" rel="noopener"&gt;TrueNAS forum: build-system trust crisis discussion&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.truenas.com/docs/scale/25.04/" target="_blank" rel="noopener"&gt;TrueNAS 25.04 documentation&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.truenas.com/blog/truenas-fangtooth-25-04-1/" target="_blank" rel="noopener"&gt;TrueNAS 25.04.1: Fangtooth Unification Gains Momentum&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/truenas-virtualization-plans-for-25-04-2/46236" target="_blank" rel="noopener"&gt;TrueNAS Virtualization Plans for 25.04.2&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/truenas-25-04-2-is-now-available/49165" target="_blank" rel="noopener"&gt;TrueNAS 25.04.2 release announcement&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/linux-jails-containers-vms-with-incus/23599?page=21" target="_blank" rel="noopener"&gt;Linux Jails with Incus forum thread&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/Jip-Hop/jailmaker" target="_blank" rel="noopener"&gt;Jailmaker&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/truenas-unlock" target="_blank" rel="noopener"&gt;truenas-unlock&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/zfs-unlock" target="_blank" rel="noopener"&gt;zfs-unlock&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/pull/61" target="_blank" rel="noopener"&gt;The NAS NixOS scaffold PR&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>I've gone full Nix: Proxmox to NixOS + Incus</title><link>https://www.nijho.lt/post/proxmox-to-nixos/</link><pubDate>Sat, 29 Nov 2025 13:00:00 +0100</pubDate><guid isPermaLink="false">/post/proxmox-to-nixos/</guid><description>&lt;h1 id="ive-gone-full-nix-proxmox-to-nixos--incus"&gt;I&amp;rsquo;ve gone full Nix: Proxmox to NixOS + Incus&lt;/h1&gt;&#10;&lt;p&gt;I have officially decommissioned my Proxmox cluster.&#10;After years of running my homelab on Proxmox, starting with a single NUC and expanding to a multi-node cluster, I have migrated everything to NixOS running Incus.&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;&lt;strong&gt;Update 2026-06-26:&lt;/strong&gt; This post reached the front page of &lt;a href="https://news.ycombinator.com/item?id=48679385" target="_blank" rel="noopener"&gt;Hacker News&lt;/a&gt;, and several readers correctly pointed out that Proxmox is not limited to clicking around in the web UI.&#10;It has &lt;code&gt;qm&lt;/code&gt;, &lt;code&gt;pct&lt;/code&gt;, config files, a REST API, Terraform providers, and Ansible workflows.&#10;My point is not that Proxmox cannot be automated.&#10;Even with that automation, state drift can still creep in when debugging means running one quick command, especially if an agent is allowed to execute imperative fixes that never make it back into the automation framework.&#10;It is that, for my setup, I wanted the reproducible configuration itself to be the source of truth.&#10;The thing I care about is not buttons versus commands, but whether I can rebuild the host from version-controlled text files and know that every important change is captured there.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;h2 id="from-skeptic-to-believer"&gt;From Skeptic to Believer &lt;a class="anchor" href="#from-skeptic-to-believer" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I wasn&amp;rsquo;t always a Nix evangelist.&#10;In fact, I initially despised the language and its syntax.&#10;I couldn&amp;rsquo;t figure out how it worked, and I already had my own specific way of setting up my dotfiles.&#10;I used &lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;Dotbot&lt;/a&gt; for symlinking and a tool I wrote called &lt;a href="https://www.nijho.lt/post/dotbins/"&gt;dotbins&lt;/a&gt; for managing binaries.&#10;I didn&amp;rsquo;t feel like I required Nix for most of my tools.&#10;I used &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nix-darwin/configuration.nix" target="_blank" rel="noopener"&gt;nix-darwin&lt;/a&gt; on my Mac for a long time, but only to specify Homebrew packages and application settings.&lt;/p&gt;&#10;&lt;p&gt;My true conversion happened when I bought my gaming PC, as described in my &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;local LLM post&lt;/a&gt;.&#10;I initially installed Pop!_OS because I wanted to play games and absolutely wanted to avoid Windows.&#10;I got some games to work, but I constantly ran into NVIDIA driver issues that required running random, imperative commands to fix.&#10;I felt that was a bad solution because I could never reproduce those debugging steps later.&#10;Then I did the dumb thing of updating my NVIDIA drivers, not realizing that imperatively managing driver versions and repositories is a recipe for disaster, and got stuck in a GRUB boot loop.&#10;Frustrated, I installed NixOS, hoping its promise of atomic updates would solve this.&#10;The result was glorious.&#10;I never really believed that everything would be byte-for-byte equivalent until I migrated that system to a new disk.&#10;I didn&amp;rsquo;t clone the drive; I just applied my Nix configuration to a fresh install.&#10;It booted, I copied my data, and everything was identical.&#10;That was the moment it clicked.&lt;/p&gt;&#10;&lt;h2 id="the-friction-of-imperative-systems"&gt;The Friction of Imperative Systems &lt;a class="anchor" href="#the-friction-of-imperative-systems" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Proxmox is fantastic software.&#10;It lowered the barrier to entry for me and taught me almost everything I know about virtualization, LXC containers, and ZFS.&#10;But fundamentally, Proxmox is built around &lt;strong&gt;clicking buttons&lt;/strong&gt;.&#10;It is a GUI-first paradigm.&#10;While you &lt;em&gt;can&lt;/em&gt; automate it with Terraform or Ansible, it often feels like fighting the tool.&#10;State drift is real.&#10;You change a setting in the UI to debug something, forget about it, and six months later your &amp;ldquo;infrastructure as code&amp;rdquo; is out of sync with reality.&#10;For a human maintaining a system manually, this is annoying.&#10;But when you introduce AI agents, this becomes a disaster for the operator.&#10;An agent running in &amp;ldquo;YOLO mode&amp;rdquo; might execute hundreds of imperative commands to fix a problem.&#10;It might succeed, but it leaves your system in an undefined, unreproducible state that no one—not even the agent—can fully understand or replicate later.&lt;/p&gt;&#10;&lt;p&gt;This friction manifests in hardware management too.&#10;On my HP EliteDesk, the Intel I219-LM network card has a known bug where it hangs with hardware offloading enabled.&#10;I vaguely remembered fixing this years ago on Proxmox, but I had forgotten the details.&#10;When I set up NixOS, I ran into the same issue: the network would randomly drop.&#10;This time, however, the fix isn&amp;rsquo;t a forgotten command run in a root shell history.&#10;It is a &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/hosts/hp/networking.nix#L16-L31" target="_blank" rel="noopener"&gt;documented systemd service&lt;/a&gt; in my configuration.&#10;I added a comment explaining exactly &lt;em&gt;why&lt;/em&gt; &lt;code&gt;tso off gso off&lt;/code&gt; is needed, citing the forum threads.&#10;If I ever reinstall this machine, the fix applies automatically.&#10;On Proxmox, I would have had to rediscover this pain all over again.&lt;/p&gt;&#10;&lt;p&gt;Another example is my Intel NUC.&#10;Since my homelab lives behind my TV, I wondered if I could also use it as a Home Theater PC (HTPC).&#10;On Proxmox, this would have required passing the GPU through to a VM to get video output.&#10;But doing so would mean the Proxmox host loses access to the GPU entirely, meaning no local console if things go wrong.&#10;It was a strict trade-off: either a media player, or a debuggable hypervisor. I tried it, but it was so much trouble that I quickly reverted.&#10;With NixOS, I don&amp;rsquo;t have to choose.&#10;The host OS runs &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/hosts/nuc/kodi.nix" target="_blank" rel="noopener"&gt;Kodi directly&lt;/a&gt;, giving me native hardware acceleration and video output.&#10;Simultaneously, &lt;code&gt;incus&lt;/code&gt; runs in the background, hosting my containers.&#10;I get my HTPC and my server on the same metal, without the virtualization tax or the &amp;ldquo;headless host&amp;rdquo; limitation.&lt;/p&gt;&#10;&lt;p&gt;There is a deeper philosophical difference too.&#10;Systems like Proxmox or TrueNAS are designed as appliances.&#10;You aren&amp;rsquo;t supposed to run arbitrary commands on the host; installing packages or tweaking config files is discouraged because you might break the middleware or lose changes on upgrade.&#10;You are effectively locked out of your own hardware&amp;rsquo;s full potential.&#10;With NixOS, the host is fully mine.&#10;I can mess with it—installing Kodi, tweaking network drivers, running local LLMs—without fear.&#10;Because the state is declarative, it is 100% obvious and reproducible.&#10;I can break the host configuration and recover to a working state in seconds, even if the machine is running essential services.&lt;/p&gt;&#10;&lt;h2 id="the-agentic-multiplier"&gt;The Agentic Multiplier &lt;a class="anchor" href="#the-agentic-multiplier" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I have written before about &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;my shift towards agentic coding&lt;/a&gt;.&#10;In a world where AI agents execute tasks, &lt;strong&gt;CLI-first&lt;/strong&gt; and &lt;strong&gt;declarative&lt;/strong&gt; systems are king.&#10;An AI agent cannot reliably &amp;ldquo;click buttons&amp;rdquo; in a web interface to configure a VLAN tag or resize a disk.&#10;It needs text.&#10;It needs determinism.&lt;/p&gt;&#10;&lt;p&gt;By moving to NixOS, my entire infrastructure is defined in text files.&#10;This means my AI agents can read, understand, and even safely modify my infrastructure.&#10;Proxmox&amp;rsquo;s opaque database and UI-driven workflow were a black box to my agents.&#10;NixOS is an open book.&#10;If I want my agent to &amp;ldquo;deploy a Faster Whisper API server on port 9000 and expose it to the LAN&amp;rdquo;, it doesn&amp;rsquo;t need to navigate a &amp;ldquo;Services&amp;rdquo; menu, then a &amp;ldquo;Network&amp;rdquo; menu, and then a &amp;ldquo;Firewall&amp;rdquo; menu.&#10;It just &lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/hosts/pc/ai.nix#L385" target="_blank" rel="noopener"&gt;writes a systemd service definition&lt;/a&gt; and adds &lt;code&gt;networking.firewall.allowedTCPPorts = [ 9000 ];&lt;/code&gt; in the same file.&#10;The agent can even verify the change was successful by checking the git diff or the active configuration.&#10;This is the infrastructure counterpart to the &amp;ldquo;agentic coding&amp;rdquo; revolution I&amp;rsquo;m living in.&lt;/p&gt;&#10;&lt;p&gt;A friend who has used NixOS for many years recently complimented me on how nicely my configuration was structured, especially for managing 8 different machines.&#10;The funny thing is, I haven&amp;rsquo;t written almost a single line of my configuration myself.&#10;I did all of it using agentic AI over many sessions.&#10;I have restructured and refactored it quite a few times, going from a single machine to 2 machines, and eventually to 9.&#10;The AI handles the heavy lifting of refactoring, ensuring that my &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/flake.nix" target="_blank" rel="noopener"&gt;PC, NUC, and HP&lt;/a&gt; all share common modules while keeping their unique personalities.&lt;/p&gt;&#10;&lt;h2 id="the-architecture-incus--simulation"&gt;The Architecture: Incus &amp;amp; Simulation &lt;a class="anchor" href="#the-architecture-incus--simulation" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I still wish there was a purely &amp;ldquo;Nix&amp;rdquo; way to manage persistent, stateful LXC containers and VMs.&#10;There are projects like &lt;code&gt;nixos-containers&lt;/code&gt; or &lt;code&gt;microvm.nix&lt;/code&gt;, but they often lack the operational maturity or live-migration features of a robust hypervisor.&#10;&lt;a href="https://linuxcontainers.org/incus/" target="_blank" rel="noopener"&gt;Incus&lt;/a&gt; (the community fork of LXD) fills this gap perfectly.&#10;It gives me the &amp;ldquo;cattle&amp;rdquo; management of NixOS for the host, while allowing me to run &amp;ldquo;pet&amp;rdquo; legacy workloads (like my old Ubuntu containers or Home Assistant VM) in a stable, manageable environment.&#10;Crucially, Incus is entirely controllable via a clean CLI, making it a perfect citizen in my agentic workflow.&lt;/p&gt;&#10;&lt;p&gt;Over time, I had already migrated most of my legacy LXC containers—services like DNS or media managers—to declarative Docker Compose files running inside VMs.&#10;But I still had one major holdout: &lt;strong&gt;Home Assistant OS&lt;/strong&gt;.&#10;I didn&amp;rsquo;t have a good alternative for it.&#10;I assumed I needed a dedicated appliance OS like Proxmox to run it reliably.&#10;I didn&amp;rsquo;t realize that with Incus on NixOS, running a full VM for Home Assistant is trivial.&#10;It’s just another QEMU process, but managed with the same ease as a container.&lt;/p&gt;&#10;&lt;p&gt;One neat thing I did was create Incus VMs that replicate the exact same configuration as my physical machines.&#10;Before I actually switched off my last Proxmox host, I was already confident that the full configuration worked because I had a virtual machine running the same setup.&#10;I just have a small file with &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/hosts/hp/incus-overrides.nix" target="_blank" rel="noopener"&gt;overrides&lt;/a&gt;.&#10;Then I could validate that the virtual machine worked.&#10;This removed the fear of &amp;ldquo;nuking and paving&amp;rdquo; my physical servers.&lt;/p&gt;&#10;&lt;h2 id="the-migration"&gt;The Migration &lt;a class="anchor" href="#the-migration" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Migrating was surprisingly straightforward, thanks to &lt;code&gt;vzdump&lt;/code&gt; and &lt;code&gt;qemu-img&lt;/code&gt;.&#10;Here is how I moved my 7+ years of digital history without losing data.&lt;/p&gt;&#10;&lt;p&gt;I also kept some &lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/archive/PROXMOX_MIGRATION.md" target="_blank" rel="noopener"&gt;detailed migration notes&lt;/a&gt; in my dotfiles.&#10;These notes should be sufficient for anyone trying to do the same.&#10;And if not, giving them to an AI (I used Gemini 3 Pro) will certainly allow you to figure it out.&lt;/p&gt;&#10;&lt;h3 id="1-migrating-lxc-containers"&gt;1. Migrating LXC Containers &lt;a class="anchor" href="#1-migrating-lxc-containers" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;For my LXC containers (running Docker, DNS, etc.), I essentially &amp;ldquo;teleported&amp;rdquo; them.&#10;I dumped the running container on Proxmox to a standardized tarball, copied it over, and streamed it directly into a fresh Incus container.&lt;/p&gt;&#10;&lt;p&gt;On Proxmox:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Dump container 101 to a compressed archive&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;vzdump &lt;span class="m"&gt;101&lt;/span&gt; --dumpdir /var/lib/vz/dump --mode &lt;span class="nb"&gt;suspend&lt;/span&gt; --compress zstd&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On NixOS (using my &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/archive/migrate-lxc.sh" target="_blank" rel="noopener"&gt;&lt;code&gt;migrate-lxc.sh&lt;/code&gt;&lt;/a&gt; script):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Stream the backup into a new Incus container&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./migrate-lxc.sh vzdump-lxc-101-*.tar.zst ubuntu-container&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This script creates a fresh container, mounts its root filesystem, and overwrites it with the Proxmox dump.&#10;It handles the messy parts—like mapping UIDs and fixing &lt;code&gt;machine-id&lt;/code&gt;—automatically.&lt;/p&gt;&#10;&lt;h3 id="2-migrating-virtual-machines"&gt;2. Migrating Virtual Machines &lt;a class="anchor" href="#2-migrating-virtual-machines" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;For VMs (like Home Assistant OS), it was a matter of converting the disk format.&#10;Proxmox uses LVM-thin or ZFS zvols; Incus is happy with QCOW2 or raw ZFS.&lt;/p&gt;&#10;&lt;p&gt;On Proxmox:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Export the ZFS volume to a QCOW2 file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;qemu-img convert -p -O qcow2 /dev/zvol/rpool/data/vm-100-disk-0 vm-100.qcow2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On NixOS (using &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/archive/migrate-vm.sh" target="_blank" rel="noopener"&gt;&lt;code&gt;migrate-vm.sh&lt;/code&gt;&lt;/a&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Import the disk and create the VM&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./migrate-vm.sh vm-100.qcow2 home-assistant&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="the-result"&gt;The Result &lt;a class="anchor" href="#the-result" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I now manage my entire fleet—Host OS, networking, storage, and hypervisor configuration—from a single git repository.&#10;I get the best of both worlds: persistent, stateful services where I need them, and a reproducible, bulletproof host operating system.&#10;I can wipe my host machine, reinstall NixOS, run a restore script, and be back online in minutes.&#10;Best of all, I never have to remember which checkbox I clicked in a web UI three years ago.&#10;It is all in the code.&#10;And because it is code, my agents can help me manage it.&lt;/p&gt;&#10;&lt;h2 id="references--configuration"&gt;References &amp;amp; Configuration &lt;a class="anchor" href="#references--configuration" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;All configuration files mentioned in this post are available in my &lt;a href="https://github.com/basnijholt/dotfiles" target="_blank" rel="noopener"&gt;dotfiles repository&lt;/a&gt;.&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/flake.nix" target="_blank" rel="noopener"&gt;&lt;strong&gt;flake.nix&lt;/strong&gt;&lt;/a&gt;: The entry point defining my entire fleet (PC, NUC, HP).&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/hosts/hp/networking.nix#L16-L31" target="_blank" rel="noopener"&gt;&lt;strong&gt;networking.nix&lt;/strong&gt;&lt;/a&gt;: The declarative fix for the Intel I219-LM network hang.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/hosts/nuc/kodi.nix" target="_blank" rel="noopener"&gt;&lt;strong&gt;kodi.nix&lt;/strong&gt;&lt;/a&gt;: NUC HTPC configuration running Kodi directly on the host.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/hosts/pc/ai.nix#L385" target="_blank" rel="noopener"&gt;&lt;strong&gt;ai.nix&lt;/strong&gt;&lt;/a&gt;: Declarative service definition for Faster Whisper.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/hosts/hp/incus-overrides.nix" target="_blank" rel="noopener"&gt;&lt;strong&gt;incus-overrides.nix&lt;/strong&gt;&lt;/a&gt;: Overrides used to simulate physical machines inside Incus VMs.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/archive/migrate-lxc.sh" target="_blank" rel="noopener"&gt;&lt;strong&gt;migrate-lxc.sh&lt;/strong&gt;&lt;/a&gt;: Script to import Proxmox &lt;code&gt;vzdump&lt;/code&gt; archives into Incus containers.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/archive/migrate-vm.sh" target="_blank" rel="noopener"&gt;&lt;strong&gt;migrate-vm.sh&lt;/strong&gt;&lt;/a&gt;: Script to import QCOW2 disk images into Incus VMs.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/archive/PROXMOX_MIGRATION.md" target="_blank" rel="noopener"&gt;&lt;strong&gt;PROXMOX_MIGRATION.md&lt;/strong&gt;&lt;/a&gt;: Detailed raw notes and inventory of the migration.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Automating nightly NixOS builds with a local cache server to avoid local compilation</title><link>https://www.nijho.lt/post/nixos-cache/</link><pubDate>Sat, 29 Nov 2025 09:30:00 +0100</pubDate><guid isPermaLink="false">/post/nixos-cache/</guid><description>&lt;h1 id="the-cuda-compilation-nightmare-how-i-solved-18-hour-builds-with-nixos"&gt;The CUDA compilation nightmare: how I solved 18-hour builds with NixOS&lt;/h1&gt;&#10;&lt;p&gt;I recently migrated my entire fleet to NixOS (read my &lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;full migration story&lt;/a&gt;).&#10;If you use NixOS on &lt;code&gt;unstable&lt;/code&gt; and have heavy dependencies, you&amp;rsquo;ve probably faced the dreaded &amp;ldquo;compiling from source&amp;rdquo; wall.&lt;/p&gt;&#10;&lt;p&gt;It starts innocently enough.&#10;You run &lt;code&gt;nixos-rebuild switch&lt;/code&gt;, expecting a quick update.&#10;Suddenly, Nix decides it needs to compile PyTorch with CUDA support, or even &amp;ldquo;standard&amp;rdquo; desktop apps like Firefox and Thunderbird, from scratch.&lt;/p&gt;&#10;&lt;p&gt;Your fans spin up to 100%.&#10;Your RAM usage creeps up&amp;hellip; 16GB, 32GB, 64GB.&#10;The UI starts to stutter, the mouse freezes, and your music stops.&#10;&lt;strong&gt;OOM Killer&lt;/strong&gt; might save you, but often the system locks up completely.&#10;There is no way to recover; you have to physically hit the reset button.&#10;I recently installed &lt;code&gt;earlyoom&lt;/code&gt;, which helps avoid the total system lockup by killing the process before the kernel panics.&#10;But that is a hollow victory.&#10;It is incredibly frustrating to have a build run for 5 hours across 24 cores at 100% usage, only to be killed right at the linking stage.&#10;The result is the same: I didn&amp;rsquo;t get my update, and I wasted a lot of electricity.&lt;/p&gt;&#10;&lt;p&gt;To fix this, you try the safe route: &lt;code&gt;--cores 1 --max-jobs 1&lt;/code&gt;.&#10;It works, but now your update takes &lt;strong&gt;18 hours&lt;/strong&gt;.&#10;During that time, you can&amp;rsquo;t easily iterate on your system configuration because the Nix lock is held, and your CPU is busy.&lt;/p&gt;&#10;&lt;p&gt;I solved this by building a dedicated, automated build cache that effectively &amp;ldquo;pins&amp;rdquo; my entire fleet to a nightly snapshot.&#10;Here is how it works.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;Update:&lt;/strong&gt; I recently discovered that a significant part of my &amp;ldquo;CUDA nightmare&amp;rdquo; was due to a change in the official CUDA binary cache URL.&#10;It moved from &lt;code&gt;cuda-maintainers.cachix.org&lt;/code&gt; to &lt;code&gt;https://cache.nixos-cuda.org&lt;/code&gt;, and information about this was surprisingly sparse.&#10;Once I added the &lt;a href="https://wiki.nixos.org/wiki/CUDA" target="_blank" rel="noopener"&gt;new cache URL&lt;/a&gt;, many of the heavy builds simply disappeared because I could finally download them.&#10;However, the architecture described below remains valuable for pinning &lt;code&gt;nixpkgs&lt;/code&gt; revisions and avoiding compilation when upstream caches (Hydra or CUDA) are lagging behind &lt;code&gt;nixos-unstable&lt;/code&gt;.&#10;I updated the &lt;a href="https://nixos.wiki/wiki/CUDA" target="_blank" rel="noopener"&gt;nixos.wiki&lt;/a&gt; to reflect this change as well, which showed conflicting information.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="the-inspiration"&gt;The Inspiration &lt;a class="anchor" href="#the-inspiration" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This entire setup was born from a &amp;ldquo;nerd snipe&amp;rdquo; just 5 days ago.&#10;I was comparing NixOS configurations with a colleague (see &lt;a href="https://github.com/aos/nix-config" target="_blank" rel="noopener"&gt;his config&lt;/a&gt;), and he mentioned he ran his own cache server.&#10;It instantly clicked.&#10;This was the missing piece.&#10;A dedicated cache server wouldn&amp;rsquo;t just be &amp;ldquo;nice to have&amp;rdquo;—it was the definitive solution to my OOM crashes and 18-hour compilation marathons.&lt;/p&gt;&#10;&lt;h2 id="the-architecture"&gt;The Architecture &lt;a class="anchor" href="#the-architecture" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The core idea is simple: &lt;strong&gt;Never compile heavy packages on your daily driver.&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;I manage 3 physical NixOS machines (&lt;code&gt;pc&lt;/code&gt;, &lt;code&gt;nuc&lt;/code&gt;, &lt;code&gt;hp&lt;/code&gt;), and updating them individually was becoming a chore.&#10;So, I set up a dedicated build server (an LXC container named &lt;code&gt;nix-cache&lt;/code&gt;) that does the heavy lifting overnight for the entire fleet.&#10;Here is the high-level workflow:&lt;/p&gt;&#10;&lt;div class="mermaid"&gt;graph TD&#10; subgraph Server&#10; Timer[Systemd Timer 2 AM] --&gt; AutoBuild[auto-build Service]&#10; AutoBuild --&gt;|1. Clone| Git[Git Repo]&#10; Git --&gt;|2. Update| FlakeLock[flake.lock]&#10; FlakeLock --&gt;|3. Build| Store[Nix Store]&#10; Store --&gt;|4. Save Hash| RevFile[hostname.rev]&#10; Store --&gt;|5. Serve| Harmonia[Harmonia Cache]&#10; end&#10;&#10; subgraph Fleet&#10; PC[PC Desktop]&#10; NUC[Intel NUC]&#10; HP[HP EliteDesk]&#10; end&#10;&#10; RevFile --&gt;|1. Read Hash| PC&#10; RevFile --&gt;|1. Read Hash| NUC&#10; RevFile --&gt;|1. Read Hash| HP&#10;&#10; Harmonia -.-&gt;|2. Fetch Binaries| PC&#10; Harmonia -.-&gt;|2. Fetch Binaries| NUC&#10; Harmonia -.-&gt;|2. Fetch Binaries| HP&lt;/div&gt;&#10;&lt;h3 id="1-the-build-server-nix-cache"&gt;1. The Build Server (&lt;code&gt;nix-cache&lt;/code&gt;) &lt;a class="anchor" href="#1-the-build-server-nix-cache" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Every night at 2 AM, a systemd timer triggers my &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/hosts/nix-cache/auto-build.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;auto-build&lt;/code&gt; service&lt;/a&gt;.&#10;This service:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Pulls the latest &lt;code&gt;dotfiles&lt;/code&gt; from git.&lt;/li&gt;&#10;&lt;li&gt;Updates the &lt;code&gt;flake.lock&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Crucially:&lt;/strong&gt; Extracts the exact commit hash of &lt;code&gt;nixpkgs&lt;/code&gt; it just locked.&lt;/li&gt;&#10;&lt;li&gt;Builds the system configurations for all my machines (&lt;code&gt;pc&lt;/code&gt;, &lt;code&gt;nuc&lt;/code&gt;, &lt;code&gt;hp&lt;/code&gt;) using conservative settings (&lt;code&gt;--cores 1&lt;/code&gt;) to ensure stability.&lt;/li&gt;&#10;&lt;li&gt;Saves the successful &lt;code&gt;nixpkgs&lt;/code&gt; commit hash to a simple text file: &lt;code&gt;/var/lib/nix-auto-build/&amp;lt;hostname&amp;gt;.rev&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="2-the-binary-cache-harmonia"&gt;2. The Binary Cache (Harmonia) &lt;a class="anchor" href="#2-the-binary-cache-harmonia" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The build server runs &lt;a href="https://github.com/nix-community/harmonia" target="_blank" rel="noopener"&gt;Harmonia&lt;/a&gt;, a lightweight binary cache.&#10;Since the builds finished successfully on the server, all the resulting binaries (the &amp;ldquo;store paths&amp;rdquo;) are now waiting in the server&amp;rsquo;s nix store, ready to be served over HTTP.&#10;You can see my Harmonia configuration &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/hosts/nix-cache/harmonia.nix" target="_blank" rel="noopener"&gt;here&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="3-the-client-nixcacheupdate"&gt;3. The Client (&lt;code&gt;nixcacheupdate&lt;/code&gt;) &lt;a class="anchor" href="#3-the-client-nixcacheupdate" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;This is the key mechanism.&#10;On my workstation, I don&amp;rsquo;t just run &lt;code&gt;nix flake update&lt;/code&gt; (which would likely grab a slightly newer version of nixpkgs than my server built, causing a cache miss).&lt;/p&gt;&#10;&lt;p&gt;Instead, I use a custom script, &lt;a href="https://github.com/basnijholt/dotfiles/blob/4f534bf32fb4396dd86ce631dec00717eab7656d/configs/nixos/upgrade-from-cache.sh" target="_blank" rel="noopener"&gt;&lt;code&gt;upgrade-from-cache.sh&lt;/code&gt;&lt;/a&gt;, which I aliased to &lt;code&gt;nixcacheupdate&lt;/code&gt;.&#10;This script:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;SSHs into the build server and asks: &lt;em&gt;&amp;ldquo;What commit did you successfully build for me last night?&amp;rdquo;&lt;/em&gt;&lt;/li&gt;&#10;&lt;li&gt;Reads the hash from the &lt;code&gt;.rev&lt;/code&gt; file.&lt;/li&gt;&#10;&lt;li&gt;Runs the rebuild with a forced override:&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;nixos-rebuild switch &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --flake .#pc &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --override-input nixpkgs github:NixOS/nixpkgs/&amp;lt;REMOTE_COMMIT_HASH&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="the-benefits"&gt;The benefits &lt;a class="anchor" href="#the-benefits" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Guaranteed Cache Hits:&lt;/strong&gt; My workstation is mathematically forced to use the exact same package inputs as the server.&#10;Nix calculates the hash, sees the match in the Harmonia cache, and downloads the binary instead of compiling it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;No More OOMs:&lt;/strong&gt; The heavy lifting happens on a machine where I don&amp;rsquo;t care if the build is slow or memory-intensive.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Iterate Faster:&lt;/strong&gt; I can still tweak my local configuration files (&lt;code&gt;configuration.nix&lt;/code&gt;, &lt;code&gt;home.nix&lt;/code&gt;).&#10;Since changing a config file doesn&amp;rsquo;t alter the &lt;em&gt;source code&lt;/em&gt; of PyTorch, I still get the heavy binaries from the cache, while only locally building the tiny activation scripts.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="the-result"&gt;The Result &lt;a class="anchor" href="#the-result" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;What used to be a frightening, system-freezing event is now a boring, fast download.&#10;I get the stability of a frozen release with the freshness of &lt;code&gt;nixos-unstable&lt;/code&gt;, minus the compilation heat.&lt;/p&gt;&#10;&lt;p&gt;If you are struggling with CUDA builds on NixOS, stop compiling locally.&#10;Let a server take the heat.&lt;/p&gt;&#10;&lt;h2 id="references--configuration"&gt;References &amp;amp; Configuration &lt;a class="anchor" href="#references--configuration" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;All configuration files mentioned in this post are available in my &lt;a href="https://github.com/basnijholt/dotfiles" target="_blank" rel="noopener"&gt;dotfiles repository&lt;/a&gt;.&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/hosts/nix-cache/auto-build.nix" target="_blank" rel="noopener"&gt;&lt;strong&gt;auto-build.nix&lt;/strong&gt;&lt;/a&gt;: The systemd service that orchestrates nightly builds.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/hosts/nix-cache/harmonia.nix" target="_blank" rel="noopener"&gt;&lt;strong&gt;harmonia.nix&lt;/strong&gt;&lt;/a&gt;: Configuration for the Harmonia binary cache server.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/blob/fa890fa22ee608be2d11ab4f705550337092c414/configs/nixos/upgrade-from-cache.sh" target="_blank" rel="noopener"&gt;&lt;strong&gt;upgrade-from-cache.sh&lt;/strong&gt;&lt;/a&gt;: The client-side script aliased to &lt;code&gt;nixcacheupdate&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/aos/nix-config" target="_blank" rel="noopener"&gt;&lt;strong&gt;aos/nix-config&lt;/strong&gt;&lt;/a&gt;: My colleague&amp;rsquo;s configuration that inspired this setup.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Switching from Ollama to llama-swap + llama.cpp on NixOS: the power user's choice 🦙</title><link>https://www.nijho.lt/post/llama-nixos/</link><pubDate>Sat, 29 Nov 2025 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/llama-nixos/</guid><description>&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#the-draft-i-never-published"&gt;The draft I never published&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-original-trigger-broken-models"&gt;The original trigger: broken models&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-catalyst-dual-gpus-and-control"&gt;The catalyst: dual GPUs and control&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#api-compatibility-and-the-switch"&gt;API compatibility and the &amp;ldquo;switch&amp;rdquo;&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#my-nixos-setup"&gt;My NixOS setup&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#compiler-flags-matter"&gt;Compiler flags matter&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-i-gained-and-lost"&gt;What I gained (and lost)&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#advantages-of-llama-swap--llamacpp"&gt;Advantages of llama-swap + llama.cpp&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-trade-off"&gt;The trade-off&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#conclusion-maturing-as-an-ai-hobbyist"&gt;Conclusion: maturing as an AI hobbyist&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="the-draft-i-never-published"&gt;The draft I never published &lt;a class="anchor" href="#the-draft-i-never-published" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I started writing this blog post four months ago.&#10;Back then, I was frustrated because a specific model I wanted to try—&lt;code&gt;gpt-oss-20b&lt;/code&gt;—was completely broken in &lt;a href="https://ollama.com/" target="_blank" rel="noopener"&gt;Ollama&lt;/a&gt;.&#10;I went down the rabbit hole, set up &lt;a href="https://github.com/mostlygeek/llama-swap" target="_blank" rel="noopener"&gt;&lt;code&gt;llama-swap&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/ggml-org/llama.cpp" target="_blank" rel="noopener"&gt;&lt;code&gt;llama.cpp&lt;/code&gt;&lt;/a&gt;, got the model working perfectly, wrote half this post&amp;hellip; and then I stopped.&lt;/p&gt;&#10;&lt;p&gt;Why? &lt;strong&gt;Laziness.&lt;/strong&gt;&#10;Once the novelty of that specific model wore off, I drifted back to Ollama.&#10;The real convenience of Ollama was never the &lt;code&gt;ollama run&lt;/code&gt; command—I almost exclusively interact with my models via the API using my own tools like &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;agent-cli&lt;/a&gt;.&#10;The convenience was &lt;code&gt;ollama pull&lt;/code&gt;.&#10;Being able to grab a model with one command and have it &amp;ldquo;just work&amp;rdquo; was great.&lt;/p&gt;&#10;&lt;p&gt;But as a &lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;NixOS&lt;/a&gt; user, this imperative convenience is actually a flaw.&#10;I want my system to be declarative.&#10;I want my model configuration committed to git, not hiding in a local database.&#10;So I let convenience win for a while, but it always felt wrong.&lt;/p&gt;&#10;&lt;p&gt;But last month, that changed.&#10;I upgraded my hobbyist AI rig by adding a &lt;strong&gt;second RTX 3090&lt;/strong&gt;.&#10;Suddenly, I had 48GB of VRAM and a whole new set of problems—and possibilities.&#10;I wanted to run massive models (like &lt;code&gt;gpt-oss-120b&lt;/code&gt; or &lt;code&gt;Qwen-3-VL-32B&lt;/code&gt; with a huge context window).&#10;I needed to balance layers between two GPUs.&#10;I needed to spill specific parts of the model into system RAM without crashing.&lt;/p&gt;&#10;&lt;p&gt;When you start pushing hardware to its limits, &amp;ldquo;magic&amp;rdquo; abstractions like Ollama stop being helpful and start getting in the way.&#10;I realized that &lt;code&gt;llama.cpp&lt;/code&gt; isn&amp;rsquo;t just a backend; it is &lt;em&gt;the&lt;/em&gt; place where development happens.&#10;If you want to use split-mode inference, granular CPU offloading, or the latest quantization tricks immediately, there is no alternative.&lt;/p&gt;&#10;&lt;p&gt;So, I dusted off this draft, updated my Nix configuration, and I&amp;rsquo;ve been running &lt;code&gt;llama-swap&lt;/code&gt; full-time for a month.&#10;I&amp;rsquo;m finally ready to vouch for it.&lt;/p&gt;&#10;&lt;h2 id="the-original-trigger-broken-models"&gt;The original trigger: broken models &lt;a class="anchor" href="#the-original-trigger-broken-models" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Four months ago, my frustration stemmed from compatibility.&#10;I was trying to run &lt;code&gt;gpt-oss-20b&lt;/code&gt;, a model that required specific chat templates and GGUF handling that &lt;a href="https://ollama.com/" target="_blank" rel="noopener"&gt;Ollama&lt;/a&gt; simply broke.&lt;/p&gt;&#10;&lt;p&gt;The maintainer of &lt;a href="https://github.com/ggml-org/llama.cpp" target="_blank" rel="noopener"&gt;&lt;code&gt;llama.cpp&lt;/code&gt;&lt;/a&gt;, Georgi Gerganov, &lt;a href="https://github.com/ollama/ollama/issues/11714#issuecomment-3172893576" target="_blank" rel="noopener"&gt;explained it perfectly&lt;/a&gt; regarding the gpt-oss debacle:&#10;Ollama forked the ggml inference engine to rush out &amp;ldquo;day-1 support&amp;rdquo; for gpt-oss without coordinating with upstream.&#10;The result? Their implementation was not only incompatible with standard GGUF files but also significantly slower and unoptimized.&lt;/p&gt;&#10;&lt;p&gt;Benchmarks at the time showed Ollama had significant performance overhead—often 20-30% slower inference speeds compared to running &lt;code&gt;llama.cpp&lt;/code&gt; directly.&lt;/p&gt;&#10;&lt;p&gt;This pattern means Ollama will always be behind &lt;code&gt;llama.cpp&lt;/code&gt;.&#10;Meanwhile, I&amp;rsquo;ve had &lt;a href="https://github.com/ollama/ollama/pull/11249" target="_blank" rel="noopener"&gt;a pull request&lt;/a&gt; sitting unreviewed for over a month that fixes their broken OpenAI API.&#10;Compare that to &lt;code&gt;llama.cpp&lt;/code&gt;: I submitted &lt;a href="https://github.com/ggml-org/llama.cpp/pull/15295" target="_blank" rel="noopener"&gt;a PR there&lt;/a&gt; and it was merged in less than an hour.&#10;If you want the latest features &lt;em&gt;now&lt;/em&gt; (and a responsive community), you go to the source.&lt;/p&gt;&#10;&lt;h2 id="the-catalyst-dual-gpus-and-control"&gt;The catalyst: dual GPUs and control &lt;a class="anchor" href="#the-catalyst-dual-gpus-and-control" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;With a single GPU, you usually either fit the model or you don&amp;rsquo;t.&#10;With two GPUs (and 64GB of system RAM), things get interesting.&#10;You enter the territory of &lt;strong&gt;heterogeneous inference&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;Ollama tries to handle splitting automatically, but I found myself fighting it.&#10;I wanted to say: &lt;em&gt;&amp;ldquo;Put 40 layers on GPU 0, 40 layers on GPU 1, and keep the KV cache here.&amp;rdquo;&lt;/em&gt;&#10;Or: &lt;em&gt;&amp;ldquo;This model is slightly too big for VRAM; I want to offload exactly the last 10% to system RAM.&amp;rdquo;&lt;/em&gt;&lt;/p&gt;&#10;&lt;p&gt;With &lt;code&gt;llama.cpp&lt;/code&gt; directly, this is just a command line flag.&#10;With &lt;code&gt;llama-swap&lt;/code&gt;, I can define this behavior per-model in a config file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt;&amp;#34;gpt-oss:120b&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="sd"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; ${pkgs.llama-cpp}/bin/llama-server&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; -hf ggml-org/gpt-oss-120b-GGUF&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; --port ${PORT}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; --ctx-size 65536&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; --split-mode layer&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; --tensor-split 3,1.3 # Tuned balance between my GPUs&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; --n-cpu-moe 15 # Offload 15 experts to CPU RAM&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; --threads 8&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; --chat-template-kwargs &amp;#39;{&amp;#34;reasoning_effort&amp;#34;: &amp;#34;high&amp;#34;}&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This level of granularity turned my rig from a &amp;ldquo;black box&amp;rdquo; into a tunable instrument.&#10;The &lt;code&gt;--tensor-split&lt;/code&gt; and &lt;code&gt;--n-cpu-moe&lt;/code&gt; flags allow me to run a 120B parameter model that technically shouldn&amp;rsquo;t fit, by carefully balancing the load across both GPUs and system RAM.&lt;/p&gt;&#10;&lt;h2 id="api-compatibility-and-the-switch"&gt;API compatibility and the &amp;ldquo;switch&amp;rdquo; &lt;a class="anchor" href="#api-compatibility-and-the-switch" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;One of the reasons I hesitated to switch was the fear of breaking my existing workflows.&#10;I use &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;agent-cli&lt;/a&gt; and other custom Python scripts daily.&#10;But it turned out to be a non-issue.&#10;Both Ollama and &lt;code&gt;llama-swap&lt;/code&gt; (wrapping &lt;code&gt;llama-server&lt;/code&gt;) expose an OpenAI-compatible API.&lt;/p&gt;&#10;&lt;p&gt;Once I adopted the pattern of setting a custom &lt;code&gt;OPENAI_BASE_URL&lt;/code&gt; in my projects, the backend became irrelevant.&#10;My tools don&amp;rsquo;t care if they are talking to &lt;code&gt;api.openai.com&lt;/code&gt;, a local Ollama instance, or &lt;code&gt;llama-swap&lt;/code&gt;.&#10;They just send JSON and get JSON back.&#10;This standardization meant I could swap the entire inference engine underneath my applications without changing a single line of their code.&lt;/p&gt;&#10;&lt;h2 id="my-nixos-setup"&gt;My NixOS setup &lt;a class="anchor" href="#my-nixos-setup" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Since I manage my entire system with Nix, setting up &lt;code&gt;llama-swap&lt;/code&gt; and pinning &lt;code&gt;llama-cpp&lt;/code&gt; to the absolute bleeding edge was surprisingly elegant.&#10;I even wrote an &lt;a href="https://github.com/basnijholt/dotfiles/blob/51c7af46e62a7d13b4ff497380c8b58c05ed81c8/configs/nixos/scripts/update_overrides.py" target="_blank" rel="noopener"&gt;auto-update script&lt;/a&gt; to ensure I&amp;rsquo;m always on the latest commit.&lt;/p&gt;&#10;&lt;p&gt;This is where my &lt;a href="https://www.nijho.lt/post/nixos-cache/"&gt;NixOS build cache&lt;/a&gt; shines.&#10;Unlike Ollama, which releases every few weeks, &lt;code&gt;llama.cpp&lt;/code&gt; can have multiple releases &lt;em&gt;a day&lt;/em&gt;.&#10;My cache server runs this update script automatically, builds the new binaries, and serves them to my workstation.&#10;I get the absolute latest performance improvements without ever compiling source code locally.&lt;/p&gt;&#10;&lt;h3 id="compiler-flags-matter"&gt;Compiler flags matter &lt;a class="anchor" href="#compiler-flags-matter" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;One critical lesson I learned: default builds matter.&#10;Initially, I was getting a measly &lt;strong&gt;8 tokens/sec&lt;/strong&gt; on &lt;code&gt;gpt-oss:120b&lt;/code&gt;.&#10;It turned out the default &lt;code&gt;llama.cpp&lt;/code&gt; package wasn&amp;rsquo;t enabling BLAS or native CPU optimizations, crippling the layers I offloaded to system RAM.&lt;/p&gt;&#10;&lt;p&gt;By overriding the package to enable &lt;code&gt;blasSupport&lt;/code&gt; and passing &lt;code&gt;-DGGML_NATIVE=ON&lt;/code&gt; (as seen below), performance jumped to &lt;strong&gt;50 tokens/sec&lt;/strong&gt;.&#10;This is another reason why I prefer managing this via Nix: I can enforce these compile-time flags declaratively.&lt;/p&gt;&#10;&lt;p&gt;Here&amp;rsquo;s my complete configuration (see &lt;a href="https://github.com/basnijholt/dotfiles/blob/51c7af46e62a7d13b4ff497380c8b58c05ed81c8/configs/nixos/hosts/pc/package-overrides.nix#L28-L76" target="_blank" rel="noopener"&gt;&lt;code&gt;package-overrides.nix&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/basnijholt/dotfiles/blob/51c7af46e62a7d13b4ff497380c8b58c05ed81c8/configs/nixos/hosts/pc/ai.nix#L360-L380" target="_blank" rel="noopener"&gt;&lt;code&gt;ai.nix&lt;/code&gt;&lt;/a&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Override llama-cpp to latest version with CUDA support&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;llama-cpp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llama-cpp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;override&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cudaSupport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;rocmSupport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;metalSupport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Enable BLAS for optimized CPU layer performance (OpenBLAS)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;blasSupport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;overrideAttrs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldAttrs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;rec&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;7205&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fetchFromGitHub&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ggml-org&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;llama.cpp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;b&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;sha256-1CcYbc8RWAPVz8hoxKEmbAgQesC1oGFZ3fhfuU5vmOc=&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;leaveDotGit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;postFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; git -C &amp;#34;$out&amp;#34; rev-parse --short HEAD &amp;gt; $out/COMMIT&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; find &amp;#34;$out&amp;#34; -name .git -print0 | xargs -0 rm -rf&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; &amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Enable native CPU optimizations (AVX, AVX2, etc.)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cmakeFlags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldAttrs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cmakeFlags&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;-DGGML_NATIVE=ON&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Disable Nix&amp;#39;s march=native stripping&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;preConfigure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; export NIX_ENFORCE_NO_NATIVE=0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;oldAttrs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preConfigure&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; &amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# llama-swap from GitHub releases&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;llama-swap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;runCommand&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;llama-swap&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; mkdir -p $out/bin&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; tar -xzf &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fetchurl&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://github.com/mostlygeek/llama-swap/releases/download/v175/llama-swap_175_linux_amd64.tar.gz&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;sha256-zeyVz0ldMxV4HKK+u5TtAozfRI6IJmeBo92IJTgkGrQ=&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s1"&gt; -C $out/bin&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; chmod +x $out/bin/llama-swap&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Configure llama-swap as a systemd service&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;systemd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llama-swap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;llama-swap - OpenAI compatible proxy with automatic model swapping&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;network.target&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;wantedBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;multi-user.target&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;serviceConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;simple&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;basnijholt&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;users&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Point to your declarative config file&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;ExecStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llama-swap&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/bin/llama-swap --config /etc/llama-swap/config.yaml --listen 0.0.0.0:9292 --watch-config&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Restart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;always&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;RestartSec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Environment for CUDA support&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Environment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;PATH=/run/current-system/sw/bin&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;LD_LIBRARY_PATH=/run/opengl-driver/lib:/run/opengl-driver-32/lib&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="what-i-gained-and-lost"&gt;What I gained (and lost) &lt;a class="anchor" href="#what-i-gained-and-lost" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="advantages-of-llama-swap--llamacpp"&gt;Advantages of llama-swap + llama.cpp &lt;a class="anchor" href="#advantages-of-llama-swap--llamacpp" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Heterogeneous compute&lt;/strong&gt;: Perfectly balance loads between GPUs and CPU RAM.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Bleeding edge&lt;/strong&gt;: I use features (like new quantization types) the day they land in &lt;code&gt;llama.cpp&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Zero idle VRAM&lt;/strong&gt;: Models unload completely when not in use.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Transparent operation&lt;/strong&gt;: No hidden prompts or &amp;ldquo;helpful&amp;rdquo; formatting that breaks complex tasks.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="the-trade-off"&gt;The trade-off &lt;a class="anchor" href="#the-trade-off" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The main trade-off is &amp;ldquo;laziness.&amp;rdquo;&#10;You can&amp;rsquo;t just &lt;code&gt;ollama pull new-model&lt;/code&gt; and have it appear.&#10;You have to find the GGUF on HuggingFace, decide on the quantization (Q4_K_M? Q6_K?), and add 5 lines of YAML to your config.&lt;/p&gt;&#10;&lt;p&gt;But honestly? That&amp;rsquo;s a feature, not a bug.&#10;It forces you to understand &lt;em&gt;what&lt;/em&gt; you are running.&#10;It stops you from running a quantized model that is too stupid for your task just because it was the default.&#10;And for a NixOS user, putting that configuration into code instead of a hidden database is exactly how it should be.&lt;/p&gt;&#10;&lt;h2 id="conclusion-maturing-as-an-ai-hobbyist"&gt;Conclusion: maturing as an AI hobbyist &lt;a class="anchor" href="#conclusion-maturing-as-an-ai-hobbyist" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My switch back to &lt;code&gt;llama-swap&lt;/code&gt; wasn&amp;rsquo;t about fixing a bug; it was about outgrowing a tool.&#10;Ollama is the &amp;ldquo;starter bike&amp;rdquo; of local AI—fantastic for getting rolling.&#10;But when you add a second GPU, start caring about tokens per second, and want to maximize every gigabyte of your 48GB VRAM buffer, you need a manual transmission.&lt;/p&gt;&#10;&lt;p&gt;For anyone else building a hobbyist multi-GPU rig: stop fighting the abstraction.&#10;Go to the source. The control is worth it.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;p&gt;&lt;em&gt;Check out my &lt;a href="https://github.com/basnijholt/dotfiles/tree/51c7af46e62a7d13b4ff497380c8b58c05ed81c8/configs/nixos" target="_blank" rel="noopener"&gt;NixOS configuration&lt;/a&gt; for the complete setup!&lt;/em&gt;&lt;/p&gt;&#10;</description></item><item><title>Coding from my phone 📱</title><link>https://www.nijho.lt/post/agentic-mobile-workflow/</link><pubDate>Mon, 20 Oct 2025 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/agentic-mobile-workflow/</guid><description>&lt;p&gt;I am dictating this post at ten kilometers altitude on a flight home from Mexico, Blink Shell open on my iPhone, while an agentic assistant on my &lt;a href="https://nixos.org/" target="_blank" rel="noopener"&gt;NixOS&lt;/a&gt; machine writes an initial draft for this post.&#10;Admittedly, I’m a little addicted to agentic coding: when an idea pops up, I want to jump on it right away—even if that means doing it from a cramped seat in the sky.&#10;This is the workflow I’ve arrived at after trying many alternatives, it extends &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;my agentic coding write-up&lt;/a&gt; and the self-hosted AI obsession in &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;my local AI journey&lt;/a&gt;.&#10;It&amp;rsquo;s my backup plan for those moments when a computer simply isn&amp;rsquo;t nearby—the rest of the time I&amp;rsquo;m still at a keyboard like any other developer.&#10;This is in no way a replacement for a proper computer/laptop workflow but gets the job done when needed.&#10;It&amp;rsquo;s a personal, mostly open-source stack built around &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt;—my local-first voice layer.&#10;Here&amp;rsquo;s how it works: I speak into my phone, the audio goes to &lt;a href="https://github.com/SYSTRAN/faster-whisper-server" target="_blank" rel="noopener"&gt;FasterWhisper&lt;/a&gt; for transcription, &lt;a href="https://ollama.com/" target="_blank" rel="noopener"&gt;Ollama&lt;/a&gt; cleans up the text, and it lands on my clipboard ready to paste.&#10;My iOS Shortcut handles this through a small HTTP interface that agent-cli exposes.&#10;The coding agent itself still uses the best proprietary model I can access.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;Meta: I&amp;rsquo;m writing about agentic coding on mobile from my actual phone.&#10;It&amp;rsquo;s not the most efficient way to write, but it gets the job done — during a 6.5‑hour flight I dictated and iterated this entire post.&#10;Each iteration was a commit: dictate changes, review the diff, refine, commit, repeat.&#10;See all &lt;a href="https://github.com/basnijholt/nijho.lt/pull/40" target="_blank" rel="noopener"&gt;50+ commits in PR #40&lt;/a&gt; for the full iteration trail.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; iPhone → WireGuard → Blink+Mosh → Zellij.&#10;I dictate via a Shortcut → FasterWhisper (transcribe) → Ollama (polish) → clipboard, then paste into Codex CLI using OpenAI’s &lt;code&gt;gpt-5-codex-high&lt;/code&gt;.&#10;Everything is local except the model.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#1-why-phone-coding-works-now"&gt;1. Why Phone Coding Works Now&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-constraints-and-trade-offs"&gt;2. Constraints and Trade-offs&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-layer-1-wireguard-from-the-router"&gt;3. Layer 1: WireGuard From the Router&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-layer-2-blink-shell--mosh-for-durable-sessions"&gt;4. Layer 2: Blink Shell + Mosh for Durable Sessions&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#5-layer-3-zellij-layouts"&gt;5. Layer 3: Zellij Layouts&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#6-layer-4-agent-cli-server"&gt;6. Layer 4: &lt;code&gt;agent-cli&lt;/code&gt; Server&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#7-layer-5-the-ios-shortcut-pipeline"&gt;7. Layer 5: The iOS Shortcut Pipeline&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#8-workflow-in-practice"&gt;8. Workflow in Practice&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#9-conclusion-and-further-reading"&gt;9. Conclusion and Further Reading&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="1-why-phone-coding-works-now"&gt;1. Why Phone Coding Works Now &lt;a class="anchor" href="#1-why-phone-coding-works-now" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For years I used &lt;a href="https://ish.app/" target="_blank" rel="noopener"&gt;&lt;strong&gt;iSH&lt;/strong&gt;&lt;/a&gt; (a full Alpine Linux emulator) with SSH to hop into servers from my phone, but dropped connections were a constant frustration (Mosh fixes this, more on that later).&#10;Because coding on a phone keyboard is terrible, I kept it to tiny configuration tweaks or one-off fixes of a few characters.&#10;Agentic tools changed that: with a CLI coding agent, I don’t need to type the code—I describe the change, review the patch, and run it.&#10;That made meaningful work on the phone possible for the first time, for those moments when a computer isn’t around.&lt;/p&gt;&#10;&lt;p&gt;I tried &lt;strong&gt;VS Code in the browser&lt;/strong&gt;, bounced between &lt;strong&gt;iSH&lt;/strong&gt; and &lt;strong&gt;Terminus&lt;/strong&gt; for SSH, and even lived inside a handful of in-browser terminal clients.&#10;I also spent time with mobile companions like &lt;a href="https://happy.engineering/" target="_blank" rel="noopener"&gt;&lt;strong&gt;Happy&lt;/strong&gt;&lt;/a&gt; and &lt;a href="https://www.ycombinator.com/companies/omnara" target="_blank" rel="noopener"&gt;&lt;strong&gt;Omnara&lt;/strong&gt;&lt;/a&gt;, both designed to mirror Claude Code sessions on the phone, but they still felt like extra steps between me and my actual terminal.&#10;That friction pushed me toward a phone‑ready, self‑hosted workflow that still gives me raw SSH access to my own machine when the computer is out of reach.&lt;/p&gt;&#10;&lt;p&gt;This post is based on the way I develop software today.&#10;Your mileage may vary, but if you also care about privacy, open tooling, and reproducible environments, I think there are useful pieces here.&lt;/p&gt;&#10;&lt;h2 id="2-constraints-and-trade-offs"&gt;2. Constraints and Trade-offs &lt;a class="anchor" href="#2-constraints-and-trade-offs" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This setup grew organically over time. What emerged are less strict requirements and more happy accidents that turned into core benefits:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Single trust boundary:&lt;/strong&gt; Only the model provider (OpenAI) sees code context; audio and automation stay local, and I avoid any additional third‑party relays.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Resilient sessions:&lt;/strong&gt; Connections should survive sleep and spotty networks.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Voice-friendly:&lt;/strong&gt; Dictation should be accurate enough that I can trust it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Reproducible config:&lt;/strong&gt; The entire stack must live in my &lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;dotfiles&lt;/a&gt; and &lt;a href="https://github.com/basnijholt/dotfiles/tree/8f6bf0b7219195a46a3e010d3538e1e449634db7/configs/nixos" target="_blank" rel="noopener"&gt;NixOS configuration&lt;/a&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;To give you a sense of what I tried, here is the short comparison that convinced me to roll my own:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Approach&lt;/th&gt;&#10;&lt;th&gt;Pros&lt;/th&gt;&#10;&lt;th&gt;Why I moved on&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;VS Code in the browser&lt;/td&gt;&#10;&lt;td&gt;Familiar editor UI&lt;/td&gt;&#10;&lt;td&gt;Needs a steady connection and still lives outside my dotfiles comfort zone&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;iSH / Terminus SSH&lt;/td&gt;&#10;&lt;td&gt;Works without extra infrastructure&lt;/td&gt;&#10;&lt;td&gt;Laggy, no Mosh, and awkward keybindings&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;In-browser terminals&lt;/td&gt;&#10;&lt;td&gt;Instant access from anywhere&lt;/td&gt;&#10;&lt;td&gt;Poor copy/paste ergonomics and flaky mobile keyboards&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://apps.apple.com/us/app/happy-codex-claude-code-app/id6748571505" target="_blank" rel="noopener"&gt;Happy&lt;/a&gt; (Claude Code companion)&lt;/td&gt;&#10;&lt;td&gt;Push notifications, encrypted mobile UI for Claude Code&lt;/td&gt;&#10;&lt;td&gt;Requires wrapping every session with a separate CLI and still abstracts away my shell, and was quite buggy for me&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://omnara.com" target="_blank" rel="noopener"&gt;Omnara&lt;/a&gt; (agent command center)&lt;/td&gt;&#10;&lt;td&gt;Centralizes Claude Code/Codex sessions with terminal replay&lt;/td&gt;&#10;&lt;td&gt;Proxies via their servers; I already trust OpenAI for the model and don&amp;rsquo;t want another third‑party handling my code&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;The stack below gives me the resilience of Mosh, the ergonomics of Zellij, and full control over the AI layer.&lt;/p&gt;&#10;&lt;h2 id="3-layer-1-wireguard-from-the-router"&gt;3. Layer 1: WireGuard From the Router &lt;a class="anchor" href="#3-layer-1-wireguard-from-the-router" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://www.wireguard.com/" target="_blank" rel="noopener"&gt;WireGuard&lt;/a&gt; is a modern VPN that lets me securely access my home network from anywhere.&#10;I terminate WireGuard on my router so every device in the house (and on the road) can dial home with the same config.&#10;(If you want something easier to set up without opening ports, &lt;a href="https://tailscale.com/" target="_blank" rel="noopener"&gt;Tailscale&lt;/a&gt; is a more user-friendly option.)&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Server:&lt;/strong&gt; ASUS XT8 router with WireGuard enabled via the router UI.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Client:&lt;/strong&gt; The WireGuard iOS app with &lt;code&gt;On-Demand&lt;/code&gt; rules so the tunnel flips on whenever I am off trusted Wi-Fi.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;DNS:&lt;/strong&gt; All mobile sessions resolve through my home DNS, so &lt;code&gt;git.nijho.lt&lt;/code&gt; and internal services resolve instantly.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;This gives Blink a local-LAN address for my desktop &lt;code&gt;nixos&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="4-layer-2-blink-shell--mosh-for-durable-sessions"&gt;4. Layer 2: Blink Shell + Mosh for Durable Sessions &lt;a class="anchor" href="#4-layer-2-blink-shell--mosh-for-durable-sessions" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;What is &lt;a href="https://mosh.org/" target="_blank" rel="noopener"&gt;Mosh&lt;/a&gt;?&#10;It&amp;rsquo;s like SSH, but it stays connected when the network changes or the phone sleeps, and it feels faster on bad connections.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://blink.sh/" target="_blank" rel="noopener"&gt;Blink Shell&lt;/a&gt; is my daily driver on iOS because it pairs beautifully with Mosh and has great keyboard ergonomics.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;I launch sessions using &lt;code&gt;mosh bas@nixos -- zellij attach --create phone&lt;/code&gt; (&lt;a href="https://zellij.dev/" target="_blank" rel="noopener"&gt;Zellij&lt;/a&gt; is my terminal multiplexer—more on that in the next section).&lt;/li&gt;&#10;&lt;li&gt;Mosh smooths over spotty LTE and keeps my session alive when the phone sleeps.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Mosh keeps the session responsive across flaky networks and sleep.&lt;/p&gt;&#10;&lt;h2 id="5-layer-3-zellij-layouts"&gt;5. Layer 3: Zellij Layouts &lt;a class="anchor" href="#5-layer-3-zellij-layouts" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I use &lt;strong&gt;Zellij&lt;/strong&gt; as my terminal multiplexer for mobile work.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;I stick to the defaults and use a couple of predefined layouts (e.g., a &amp;ldquo;phone&amp;rdquo; layout with editor + shell panes).&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Ergonomics matter even more on a glass keyboard, so I lean on a few shell helpers:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://github.com/ajeetdsouza/zoxide" target="_blank" rel="noopener"&gt;&lt;code&gt;zoxide&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; means I can jump between repos with &lt;code&gt;z foo&lt;/code&gt; instead of pecking long paths.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Single-character aliases&lt;/strong&gt; like &lt;code&gt;p&lt;/code&gt; for &lt;code&gt;pytest&lt;/code&gt; and &lt;code&gt;gs&lt;/code&gt; for &lt;code&gt;git status&lt;/code&gt; live in &lt;a href="https://github.com/basnijholt/dotfiles/blob/8f6bf0b7219195a46a3e010d3538e1e449634db7/configs/shell/10_aliases.sh" target="_blank" rel="noopener"&gt;&lt;code&gt;configs/shell/10_aliases.sh&lt;/code&gt;&lt;/a&gt;, keeping command entry to a minimum.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Autocompletion plugins&lt;/strong&gt;—&lt;a href="https://github.com/zsh-users/zsh-autosuggestions" target="_blank" rel="noopener"&gt;&lt;code&gt;zsh-autosuggestions&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;zsh-syntax-highlighting&lt;/code&gt;—are wired up in &lt;a href="https://github.com/basnijholt/dotfiles/blob/8f6bf0b7219195a46a3e010d3538e1e449634db7/configs/shell/70_zsh_plugins.sh" target="_blank" rel="noopener"&gt;&lt;code&gt;configs/shell/70_zsh_plugins.sh&lt;/code&gt;&lt;/a&gt;, so I get inline hints and colour cues while typing.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I break these tricks down in more depth in &lt;a href="https://www.nijho.lt/post/terminal-ninja/"&gt;Terminal Ninja&lt;/a&gt;, explain how I sync them with my &lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;Dotfiles&lt;/a&gt;, and even package binaries like &lt;code&gt;zoxide&lt;/code&gt; with &lt;a href="https://www.nijho.lt/post/dotbins/"&gt;Dotbins&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="6-layer-4-agent-cli-server"&gt;6. Layer 4: &lt;code&gt;agent-cli&lt;/code&gt; Server &lt;a class="anchor" href="#6-layer-4-agent-cli-server" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt; runs as a small server on my NixOS machine.&#10;In this workflow I only use &lt;code&gt;transcribe&lt;/code&gt; from iOS Shortcuts: the phone records audio, the server transcribes and cleans it up, and I paste the text.&#10;(Agent-CLI has other features like &lt;code&gt;autocorrect&lt;/code&gt; and &lt;code&gt;chat&lt;/code&gt;, but I don&amp;rsquo;t use them for this workflow.)&#10;On this machine I run a long‑lived &lt;code&gt;agent-cli&lt;/code&gt; user service (systemd) so Shortcuts can POST audio to it and get cleaned text back—ready to paste into Codex CLI.&#10;The service is defined in my dotfiles—see: &lt;a href="https://github.com/basnijholt/dotfiles/blob/8f6bf0b7219195a46a3e010d3538e1e449634db7/configs/nixos/modules/user.nix#L29-L40" target="_blank" rel="noopener"&gt;&lt;code&gt;configs/nixos/modules/user.nix&lt;/code&gt; (agent-cli service)&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;For more background on why I built it and how I use it in practice, see &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;Local AI Journey&lt;/a&gt; (§3) and &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;Agentic Coding&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The models and services run on the same box:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;FasterWhisper&lt;/strong&gt; via &lt;a href="https://github.com/SYSTRAN/faster-whisper" target="_blank" rel="noopener"&gt;&lt;code&gt;faster-whisper-server&lt;/code&gt;&lt;/a&gt; for high‑accuracy streaming transcription.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Ollama&lt;/strong&gt; for on‑device rewrite/cleanup before sending prompts to the coding agent.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;My iOS Shortcut dictation workflow is slower than Apple&amp;rsquo;s built-in dictation, but the accuracy is much better—and that matters more.&lt;/p&gt;&#10;&lt;p&gt;For the actual coding agent, I use a &lt;a href="https://github.com/just-every/code" target="_blank" rel="noopener"&gt;fork&lt;/a&gt; of Codex CLI with OpenAI’s &lt;code&gt;gpt-5-codex-high&lt;/code&gt; model (here no open-source solution matches the frontier).&lt;/p&gt;&#10;&lt;h2 id="7-layer-5-the-ios-shortcut-pipeline"&gt;7. Layer 5: The iOS Shortcut Pipeline &lt;a class="anchor" href="#7-layer-5-the-ios-shortcut-pipeline" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My iPhone&amp;rsquo;s Action Button runs a Shortcut that records audio and sends my voice to agent-cli on my NixOS machine for transcription and cleanup.&lt;/p&gt;&#10;&lt;p&gt;For the full recipe, see the iOS Shortcut Guide: &lt;a href="https://github.com/basnijholt/agent-cli/blob/main/iOS_Shortcut_Guide.md" target="_blank" rel="noopener"&gt;agent-cli/iOS_Shortcut_Guide.md&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;In short: I press the Action Button, the Shortcut records a snippet, sends it to &lt;code&gt;agent-cli&lt;/code&gt;, and copies the cleaned text to my clipboard so I can paste it into Codex CLI.&lt;/p&gt;&#10;&lt;p&gt;The whole loop finishes fast enough that I can capture intent by voice and paste it into Codex CLI without typing.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;Dictation quality:&lt;/strong&gt; In my experience, the built‑in iOS dictation is absolute garbage compared to Whisper/FasterWhisper.&#10;Accuracy and punctuation are much better with the FasterWhisper server running at home; it’s a bit slower, but that trade‑off is worth it for clean, usable text.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="8-workflow-in-practice"&gt;8. Workflow in Practice &lt;a class="anchor" href="#8-workflow-in-practice" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Here&amp;rsquo;s the typical flow:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;WireGuard auto-connects when I&amp;rsquo;m off home WiFi&lt;/li&gt;&#10;&lt;li&gt;Open Blink—my Mosh session is still alive from last time, Zellij panes intact&lt;/li&gt;&#10;&lt;li&gt;Dictate the change, paste into Codex CLI, review the agent&amp;rsquo;s response&lt;/li&gt;&#10;&lt;li&gt;Check diffs in an adjacent Zellij pane or different Blink tab with separate Mosh session&lt;/li&gt;&#10;&lt;li&gt;The agent commits and pushes to a branch, then opens a PR via &lt;code&gt;gh&lt;/code&gt; for me to review and merge&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;On the phone, I aim for the smallest practical edits and initial implementations.&#10;Even on personal repos, I still open a PR—sometimes prompting alone gets me to a merge‑ready result.&#10;For open‑source with real users, I finish on the computer with a careful review and any final edits.&lt;/p&gt;&#10;&lt;h2 id="9-conclusion-and-further-reading"&gt;9. Conclusion and Further Reading &lt;a class="anchor" href="#9-conclusion-and-further-reading" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This phone setup extends my &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;Agentic Coding&lt;/a&gt; workflow to mobile.&#10;I connect to the same Zellij session on &lt;code&gt;nixos&lt;/code&gt;, so when an idea hits, I pick up exactly where I left off—same environment, same context, no setup.&#10;Everything runs on my hardware (voice processing and automation) except the coding model itself.&#10;It’s the most effective mobile workflow I’ve had so far.&#10;It’s mostly open‑source not out of dogma, but because those tools are the best options for my needs.&#10;The one exception is the coding model: there’s no true open equivalent right now, and it makes no sense to buy 20× H100s just to self‑host a frontier model even if such a high quality model would be available open-source.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re curious to dive deeper, here are a few related posts:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;Agentic Coding&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;Dotfiles: A Practical, Cross-Platform Terminal Setup&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;Local AI Journey: RTX 3090 Edition&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/terminal-ninja/"&gt;Terminal Ninja&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/using-llms/"&gt;Using LLMs Effectively&lt;/a&gt; (already outdated at this point!)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I&amp;rsquo;d love to hear what self-hosted tricks you&amp;rsquo;re using for mobile development—reach out if you adapt this stack or build something wild on top of it.&lt;/p&gt;&#10;</description></item><item><title>How a 3090 turned into two local‑first AI projects 🎮</title><link>https://www.nijho.lt/post/local-ai-journey/</link><pubDate>Mon, 14 Jul 2025 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/local-ai-journey/</guid><description>&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#1-introduction-the-unplayed-games"&gt;1. Introduction: The Unplayed Games&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-the-rocky-road-to-a-working-setup"&gt;2. The Rocky Road to a Working Setup&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-the-first-victory-agent-cli-"&gt;3. The First Victory: &lt;code&gt;agent-cli&lt;/code&gt; 🐍&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-the-grand-ambition-aibrain-"&gt;4. The Grand Ambition: &lt;code&gt;AIBrain&lt;/code&gt; 🧠&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#5-conclusion-the-journey-continues"&gt;5. Conclusion: The Journey Continues&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="1-introduction-the-unplayed-games"&gt;1. Introduction: The Unplayed Games &lt;a class="anchor" href="#1-introduction-the-unplayed-games" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I&amp;rsquo;ve wanted to dabble with local AI for a long time, mainly because of the possibilities it unlocks.&#10;Local AI allows me to use all of my data—financial records, personal messages, location history, even transcribing everything I say—without the privacy concerns of sending it to a cloud provider.&#10;This level of deep, personal data analysis is only something I&amp;rsquo;d be comfortable with on my own hardware.&lt;/p&gt;&#10;&lt;p&gt;However, I couldn&amp;rsquo;t justify buying a powerful GPU just for an imagined AI project that I might never really start.&#10;Then it hit me: I could also use it for gaming (duh).&#10;Because I really enjoyed &lt;em&gt;The Last of Us&lt;/em&gt; series, I thought, “&lt;em&gt;Okay, why not play &lt;strong&gt;The Last of Us&lt;/strong&gt; on PC?&lt;/em&gt;”&#10;This was the push I needed.&#10;So, a couple of weeks ago, I convinced myself to buy someone&amp;rsquo;s old gaming machine.&#10;After many deep research sessions with ChatGPT about the best bang-for-the-buck consumer-grade GPU, I landed on an NVIDIA 3090.&#10;I found an (old) beast of a machine, and my initial idea was set.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;Click here to see the full system specs ($1350 for all this!)&lt;/summary&gt;&#10; &lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;GPU:&lt;/strong&gt; ASUS ROG STRIX RTX 3090 OC (24GB)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;CPU:&lt;/strong&gt; AMD Ryzen 9 3900X (12-Core)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Motherboard:&lt;/strong&gt; ASUS ROG Crosshair VIII Hero&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Power Supply:&lt;/strong&gt; Be Quiet! 1200W Dark Power Pro&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;RAM:&lt;/strong&gt; 32GB G.Skill Trident Z Royal DDR4&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Storage:&lt;/strong&gt; 2TB Intel M.2 NVMe SSD&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Case:&lt;/strong&gt; Corsair 4000D Airflow&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;CPU Cooler:&lt;/strong&gt; Thermalright Peerless Assassin 120&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;p&gt;Fast forward four weeks.&#10;I haven&amp;rsquo;t played a single minute, but my machine is now indexing 200 GB of email archives and facing a serious performance bottleneck I&amp;rsquo;ll get to later.&#10;That &amp;ldquo;gaming&amp;rdquo; machine has been humming away, but not rendering virtual worlds.&#10;It&amp;rsquo;s been training, transcribing, and thinking.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;m writing this post simply because I am so enthusiastic about local AI.&#10;I&amp;rsquo;ve found that local models are surprisingly powerful for specific use cases.&#10;Of course, they aren&amp;rsquo;t going to replace Gemini or Claude for coding, but for many other applications, they work incredibly well.&#10;Lately, I&amp;rsquo;ve been so obsessed with this that I&amp;rsquo;m sleeping significantly less and spending all my free hours working on it.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; This post is the story of how a quest for gaming turned into a deep dive into local, private AI.&#10;I&amp;rsquo;ll share the two open-source Python packages that came out of it, &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/basnijholt/aibrain" target="_blank" rel="noopener"&gt;&lt;code&gt;AIBrain&lt;/code&gt;&lt;/a&gt;, and the lessons I learned along the way.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="2-the-rocky-road-to-a-working-setup"&gt;2. The Rocky Road to a Working Setup &lt;a class="anchor" href="#2-the-rocky-road-to-a-working-setup" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Before I could even get to the fun AI stuff, I had to wrestle with the machine itself.&#10;I started with Pop!_OS, which is supposed to be a great out of the box solution that &amp;ldquo;just works&amp;rdquo;.&#10;However, it&amp;rsquo;s been a long time since I used Linux on the desktop; my experience is almost exclusively with servers.&#10;I quickly installed some of the wrong NVIDIA drivers and ended up debugging stuff in Grub.&#10;I got frustrated with the different commands I had to run to set up everything and realized that reproducing this system was going to be a massive pain.&#10;Since I&amp;rsquo;ve grown to love Nix on my Mac with Nix-Darwin, I decided to switch the whole system to &lt;strong&gt;NixOS&lt;/strong&gt;.&#10;This has been an amazing decision.&#10;Even though there are many pain points with Nix, I think it is well worth it.&#10;For those interested, you can see the complete setup, including all the local AI services, in my &lt;a href="https://github.com/basnijholt/dotfiles/blob/841cd5fb94b65556e7a2cc7d8ea618f86b50bf1d/configs/nixos/configuration.nix" target="_blank" rel="noopener"&gt;NixOS configuration on GitHub&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="3-the-first-victory-agent-cli-"&gt;3. The First Victory: &lt;code&gt;agent-cli&lt;/code&gt; 🐍 &lt;a class="anchor" href="#3-the-first-victory-agent-cli-" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;With a stable system, I quickly got started with a few scripts.&#10;These soon evolved into my first real project of this adventure: &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;agent-cli&lt;/code&gt; is a collection of &lt;strong&gt;local-first&lt;/strong&gt;, AI-powered command-line agents that run entirely on your machine.&#10;The core philosophy is privacy; your data never leaves your computer.&#10;It&amp;rsquo;s designed for seamless integration with system-wide hotkeys.&#10;I&amp;rsquo;m now using it constantly.&lt;/p&gt;&#10;&lt;p&gt;Instead of typing, I now dictate almost everything.&#10;A quick hotkey, and &lt;code&gt;agent-cli transcribe&lt;/code&gt; turns my speech into text.&#10;Another hotkey, and &lt;code&gt;agent-cli autocorrect&lt;/code&gt; cleans it up using a local LLM running on &lt;strong&gt;Ollama&lt;/strong&gt;.&#10;It has genuinely streamlined my workflow.&#10;For example, I can pipe the tools together, like correcting text from my clipboard and then having it spoken aloud: &lt;code&gt;agent-cli autocorrect | agent-cli speak&lt;/code&gt;.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;p&gt;&lt;strong&gt;⚡ Surprising Speed: Local vs. Cloud&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;One of my biggest surprises was the raw speed of local models.&#10;While I added an OpenAI provider to &lt;code&gt;agent-cli&lt;/code&gt; for those without local hardware (someone I know wanted to try it), I consistently find that local execution is faster than waiting on a cloud API.&#10;For example, a local transcription with Whisper + Ollama lands in my clipboard in well under a second—often beating the network round-trip to a remote service.&lt;/p&gt;&#10;&lt;/div&gt;&lt;/aside&gt;&#10;&lt;p&gt;The toolkit includes:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;autocorrect&lt;/code&gt;: Fixes grammar and spelling.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;transcribe&lt;/code&gt;: Uses a local Whisper model for speech-to-text.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;speak&lt;/code&gt;: Converts text to speech with a local TTS engine.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;voice-edit&lt;/code&gt;: A voice-powered clipboard assistant.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;assistant&lt;/code&gt;: A hands-free voice assistant using a wake word.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;chat&lt;/code&gt;: A conversational AI with tool-calling capabilities.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;It&amp;rsquo;s been a fantastic success in my personal workflow, and it&amp;rsquo;s all open-source for you to try.&#10;Contributions and ideas welcome!&lt;/p&gt;&#10;&lt;p&gt;Once I trusted my toolchain, the obvious next step was to feed &lt;em&gt;all&lt;/em&gt; my data into it.&lt;/p&gt;&#10;&lt;h2 id="4-the-grand-ambition-aibrain-"&gt;4. The Grand Ambition: &lt;code&gt;AIBrain&lt;/code&gt; 🧠 &lt;a class="anchor" href="#4-the-grand-ambition-aibrain-" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My second package, &lt;a href="https://github.com/basnijholt/aibrain" target="_blank" rel="noopener"&gt;&lt;code&gt;AIBrain&lt;/code&gt;&lt;/a&gt;, is a much larger endeavor.&#10;The vision is to create a &amp;ldquo;life-OS&amp;rdquo;—a private, local AI that processes all my personal data (emails, calendar, files, photos, messages, browser history, location data) to generate summaries and answer questions, eventually allowing for queries like:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;aibrain ask &lt;span class="s2"&gt;&amp;#34;When did I get into landscape photography?&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;aibrain ask &lt;span class="s2"&gt;&amp;#34;How many countries did I visit between 2015-2025?&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This project led me to explore the landscape of agentic frameworks.&#10;I started with &lt;strong&gt;CrewAI&lt;/strong&gt;, but I quickly found that despite the hype and funding, it had some painful problems (specifically &lt;a href="https://github.com/crewAIInc/crewAI/issues/3031" target="_blank" rel="noopener"&gt;this issue&lt;/a&gt; where things just fail without any helpful error messages).&#10;I decided to switch to &lt;strong&gt;LangGraph&lt;/strong&gt;, which felt more robust.&#10;I also chose to not use &lt;strong&gt;PydanticAI&lt;/strong&gt; (which I used in &lt;code&gt;agent-cli&lt;/code&gt;) because its &lt;strong&gt;Ollama&lt;/strong&gt; support is just a wrapper around the OpenAI-compatible API, which doesn&amp;rsquo;t expose all the useful options (I even opened &lt;a href="https://github.com/ollama/ollama/pull/11249/" target="_blank" rel="noopener"&gt;a PR&lt;/a&gt; in Ollama to make its OpenAI compatible API more feature-complete, but it has not been merged yet).&lt;/p&gt;&#10;&lt;p&gt;The core infrastructure of &lt;code&gt;AIBrain&lt;/code&gt; is ready.&#10;It can index emails, calendar events, and files in various formats (PDF, Excel, PowerPoint, images).&#10;However, I&amp;rsquo;ve hit a significant challenge.&lt;/p&gt;&#10;&lt;aside class="callout callout-warning"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;p&gt;&lt;strong&gt;🔥 The Performance Bottleneck&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;Even with a 12-core CPU and 24GB GPU, processing just a few days’ worth of email made the fans scream for half an hour.&#10;Processing hundreds of gigabytes of personal data is a massive undertaking, and it&amp;rsquo;s clear that my current approach isn&amp;rsquo;t scalable enough.&lt;/p&gt;&#10;&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="5-conclusion-the-journey-continues"&gt;5. Conclusion: The Journey Continues &lt;a class="anchor" href="#5-conclusion-the-journey-continues" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This past month has been a whirlwind.&#10;I set out to play games and ended up with two open-source AI packages and a much deeper appreciation for the power sitting in a consumer-grade GPU.&#10;It&amp;rsquo;s incredible what you can achieve with local hardware today.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;agent-cli&lt;/code&gt; is a finished, polished tool that I use daily.&#10;&lt;code&gt;AIBrain&lt;/code&gt; is a much bigger dream, and I&amp;rsquo;m still figuring out how to solve the performance puzzle to make it truly practical.&#10;Next up: smarter chunking with LangGraph, vector-DB swap-outs, and GPU scheduling tricks.&lt;/p&gt;&#10;&lt;p&gt;This experience has reinforced my passion for local-first software and open-source development.&#10;The future of personal, private AI is being built right now, not just in large corporate labs, but in the homes of hobbyists tinkering with their gaming rigs.&lt;/p&gt;&#10;&lt;p&gt;⭐ If this resonates, star the repos &amp;amp; drop issues/questions.&lt;/p&gt;&#10;&lt;p&gt;And who knows, maybe I&amp;rsquo;ll even find the time to play &amp;lsquo;The Last of Us&amp;rsquo;.&lt;/p&gt;&#10;</description></item></channel></rss>