<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Self-Hosting | Bas Nijholt</title><link>https://www.nijho.lt/tag/self-hosting/</link><atom:link href="https://www.nijho.lt/tag/self-hosting/index.xml" rel="self" type="application/rss+xml"/><description>Self-Hosting</description><generator>Hugo</generator><language>en-us</language><copyright>© 2026</copyright><lastBuildDate>Sun, 18 Oct 2026 00:00:00 +0000</lastBuildDate><image><url>https://www.nijho.lt/media/icon_hu_408a8f6cef43e327.png</url><title>Self-Hosting</title><link>https://www.nijho.lt/tag/self-hosting/</link></image><item><title>Why Docker isn't an AI-safe sandbox anymore, and how I fixed it in MindRoom 🔒</title><link>https://www.nijho.lt/post/kernels-containers-vms/</link><pubDate>Sun, 18 Oct 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/kernels-containers-vms/</guid><description>&lt;p&gt;For years, a Docker container was my default answer to &amp;ldquo;where do I run this?&amp;rdquo;&#10;I never thought it was a perfect sandbox.&#10;It was good enough, because getting out of one usually meant finding a specific kind of bug in the Linux kernel itself, and those were hard to find.&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;&lt;/p&gt;&#10;&lt;p&gt;AI models have become very good at finding security bugs, including in Linux.&#10;Anthropic and OpenAI now keep their strongest security models, &lt;a href="https://www.anthropic.com/claude/mythos" target="_blank" rel="noopener"&gt;Claude Mythos&lt;/a&gt; and &lt;a href="https://openai.com/the-defense-factory/" target="_blank" rel="noopener"&gt;Daybreak Blue&lt;/a&gt;, behind identity checks and vetting.&#10;Open-weight models, which anyone can download and run, come with no such rules, and they trail the closed ones by only &lt;a href="https://epoch.ai/data-insights/open-closed-cyber-eci-gap" target="_blank" rel="noopener"&gt;six to seven months&lt;/a&gt;.&#10;Once there is an open model as good as Mythos, anyone can strip out its refusals with a tool like &lt;a href="https://github.com/p-e-w/heretic" target="_blank" rel="noopener"&gt;Heretic&lt;/a&gt; (people call this abliteration) and point it at the Linux kernel.&#10;The security company DepthFirst used its own model to find a way out of a container and titled the write-up &lt;a href="https://depthfirst.com/research/containers-are-no-longer-safe" target="_blank" rel="noopener"&gt;Containers Are No Longer a Security Boundary&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;I care about this because running untrusted code in containers is exactly what &lt;a href="https://www.nijho.lt/post/mindroom/"&gt;MindRoom&lt;/a&gt;, my open-source system for AI agents that live in Matrix chat rooms, does.&#10;Every agent can get its own Docker worker, and its shell, file, Python, and browser tools run there.&#10;It can install anything, start background processes, and drive a real browser.&#10;And as I wrote in &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;my ZFS post&lt;/a&gt;, I run coding agents in &lt;span class="hover-tip" data-tooltip="The agent runs every command without asking me for approval first." tabindex="0"&gt;YOLO mode&lt;/span&gt;, exclusively.&#10;I do not review the commands they run, I look at the results.&lt;/p&gt;&#10;&lt;p&gt;So both sides changed at once.&#10;The code inside the container is now written by an AI, and the bugs that let it out are now found by an AI.&#10;A &lt;a href="https://youtu.be/9-2CSJC114k" target="_blank" rel="noopener"&gt;video from the Low Level channel&lt;/a&gt; asking whether containers are still safe made me sit down and rethink my setup.&lt;/p&gt;&#10;&lt;p&gt;I am not an expert in any of this.&#10;I have used containers, KVM, and hypervisors for years, including Proxmox before I &lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;moved to NixOS&lt;/a&gt;, but I never deeply understood what happens underneath.&#10;Building this forced me to learn it, and writing this post is my exercise in understanding it all.&#10;All of this is very complicated (as is everything new), and I am still learning.&#10;I am sure I skip over a lot and simplify the rest; what is here are the parts that helped me understand it.&#10;I thought it would be nice to share.&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; A container is a process on your machine that the kernel dresses up as a separate computer.&#10;Every container shares that one kernel with the host, so a single bug in it can be a way out.&#10;MindRoom can now give each agent&amp;rsquo;s worker its own kernel, with one setting and six lines of code.&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="#what-the-kernel-actually-does"&gt;What the kernel actually does&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#a-container-is-a-process-in-a-costume"&gt;A container is a process in a costume&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-kernel-bugs-are-a-bigger-problem-now"&gt;Why kernel bugs are a bigger problem now&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#so-i-asked-a-model-to-break-out"&gt;So I asked a model to break out&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#stronger-walls-virtual-machines"&gt;Stronger walls: virtual machines&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#kata-containers-and-gvisor"&gt;Kata Containers and gVisor&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-pr-six-lines"&gt;The PR: six lines&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-it-costs"&gt;What it costs&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-this-does-not-fix"&gt;What this does not fix&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#conclusion"&gt;Conclusion&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="what-the-kernel-actually-does"&gt;What the kernel actually does &lt;a class="anchor" href="#what-the-kernel-actually-does" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;When people talk about Linux, they usually mean an operating system like Ubuntu, Fedora, or NixOS.&#10;Those differ in almost everything you see: package managers, desktops, config files.&#10;What they all have in common is the kernel, the part Linus Torvalds started in 1991 and still leads today.&#10;Strictly speaking, that kernel is the only part called Linux.&lt;/p&gt;&#10;&lt;p&gt;The kernel is the one program on a Linux machine that talks to the hardware directly.&#10;Everything else, your shell, Python, Chrome, Docker, runs on top of it and has to ask it for things.&#10;Open this file.&#10;Give me some memory.&#10;Send this packet.&#10;Start a new process.&#10;Those requests are called system calls, or syscalls for short.&lt;/p&gt;&#10;&lt;p&gt;There is one kernel per machine, and it decides who is allowed to do what.&#10;That makes it the most trusted code on the system.&#10;Most kernel bugs only crash the machine or cause glitches.&#10;But some let a normal program become root, the admin account, or even run its own code inside the kernel.&#10;At that point it can do anything on the machine, including leaving any container it was in.&lt;/p&gt;&#10;&lt;h2 id="a-container-is-a-process-in-a-costume"&gt;A container is a process in a costume &lt;a class="anchor" href="#a-container-is-a-process-in-a-costume" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;When you &lt;code&gt;docker run -it ubuntu&lt;/code&gt;, you get a root shell in what feels like a fresh computer.&#10;Under the hood it is one ordinary process.&#10;The kernel dresses it up using three features:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Namespaces&lt;/strong&gt; decide what the process can see. It gets its own list of processes (where it is process number 1), its own filesystem, and its own network. Root inside is still root on the host, with &lt;span class="hover-tip" data-tooltip="Docker drops most of root&amp;#39;s special powers, which Linux calls capabilities, and seccomp and AppArmor block more. Namespaces only change what root can see." tabindex="0"&gt;most of its powers taken away&lt;/span&gt;.&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;cgroups&lt;/strong&gt; decide how much it can use: CPU, memory, number of processes.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;seccomp&lt;/strong&gt; decides which syscalls it may make at all. It is a filter that blocks the ones a normal program never needs.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/kernels-containers-vms/container-kernel.svg" alt="Two containers and the host&amp;#39;s own processes all make syscalls into one shared Linux kernel, which keeps the containers apart with namespaces, cgroups and seccomp. One bug in that kernel reaches every container and the host." loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;p&gt;There is no second kernel.&#10;Every syscall from inside a container lands in the same kernel as every other container and the host itself.&#10;The kernel enforces the isolation, and it is also what gets attacked.&lt;/p&gt;&#10;&lt;p&gt;For most uses that is fine.&#10;Usually you put fixed software in a container: a database, a web app, some self-hosted service.&#10;You know where that code comes from, you keep your kernel patched, and that is good enough.&lt;/p&gt;&#10;&lt;p&gt;An AI agent is different.&#10;It writes new code on the spot and runs it, and nobody knows in advance what that code will do.&lt;/p&gt;&#10;&lt;p&gt;Since root in a container is root on the host, anyone who can use Docker can start a container as root and mount any folder from the host into it.&#10;They can then read and change every file in that folder, including files their own account may not open.&#10;Docker&amp;rsquo;s own documentation says the &lt;code&gt;docker&lt;/code&gt; group &lt;a href="https://docs.docker.com/engine/install/linux-postinstall/" target="_blank" rel="noopener"&gt;grants root-level privileges&lt;/a&gt;.&#10;I knew that in theory, but it only clicked when I watched an AI agent do it on a machine where I had no root, only Docker access.&#10;It needed to change files it was not allowed to touch, so it started a small container, mounted their folder, and edited them from inside.&#10;No kernel bug needed.&lt;/p&gt;&#10;&lt;h2 id="why-kernel-bugs-are-a-bigger-problem-now"&gt;Why kernel bugs are a bigger problem now &lt;a class="anchor" href="#why-kernel-bugs-are-a-bigger-problem-now" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Kernel bugs that let a normal program take over the whole machine used to take skilled people weeks or months to find and exploit.&#10;The famous ones, like &lt;a href="https://dirtycow.ninja" target="_blank" rel="noopener"&gt;Dirty COW&lt;/a&gt; in 2016, made the news every year or two, you patched, and that was it.&lt;/p&gt;&#10;&lt;p&gt;AI is speeding this up, and the DepthFirst write-up I mentioned is a good example.&#10;Their model found a bug in the kernel code for Unix sockets, which programs on the same machine use to talk to each other.&#10;The kernel freed a piece of memory and then kept using it, a so-called use-after-free, and from inside a container that was enough to get out.&#10;Their conclusion is that you should assume attackers can escape containers at will.&lt;/p&gt;&#10;&lt;p&gt;Blocking more syscalls, or taking more powers away from root, helps a little.&#10;But it only narrows which parts of the shared kernel a container can reach.&#10;This bug sat in Unix sockets, which almost every program uses, including the browser the agent drives, so blocking them would break the agent.&lt;/p&gt;&#10;&lt;p&gt;Now put an AI agent in that container.&#10;It reads web pages and repositories written by strangers, and one of those could talk it into running an exploit.&#10;On a MindRoom server, getting out of a worker means getting onto the machine that runs MindRoom itself.&#10;That is where the API keys, Matrix logins, and stored credentials live, which the workers are built to never see.&lt;/p&gt;&#10;&lt;h2 id="so-i-asked-a-model-to-break-out"&gt;So I asked a model to break out &lt;a class="anchor" href="#so-i-asked-a-model-to-break-out" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Before changing anything, I wanted to know how real this was for my own setup.&#10;I gave a MindRoom agent &lt;a href="https://openai.com/the-defense-factory/" target="_blank" rel="noopener"&gt;Daybreak Blue&lt;/a&gt; and asked it to escape its worker container using known kernel vulnerabilities.&#10;Daybreak Blue is OpenAI&amp;rsquo;s security model for verified users, so it does the offensive security work that other models refuse.&lt;/p&gt;&#10;&lt;p&gt;It did not get out, which was reassuring.&#10;But it only tried known bugs.&#10;A kernel a few weeks behind on patches, or a bug nobody has published yet, would be a different story.&#10;So the attempt tells me the easy path was closed that day, and not much more.&lt;/p&gt;&#10;&lt;h2 id="stronger-walls-virtual-machines"&gt;Stronger walls: virtual machines &lt;a class="anchor" href="#stronger-walls-virtual-machines" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The classic answer is a virtual machine.&#10;A container shares the host&amp;rsquo;s kernel, but a VM brings its own, and this is where the hardware comes in.&#10;Modern CPUs have a virtualization mode built in, called VT-x on Intel and AMD-V on AMD.&#10;In that mode, a second kernel, the guest kernel, runs directly on the real CPU and believes it is in charge, while the CPU quietly limits it.&#10;When the guest tries something it may not do on its own, like talking to a disk or a network card, the CPU pauses the guest and hands control to the host.&#10;This is called a VM exit.&#10;Despite the name, nothing escapes and nothing crashes.&#10;The host handles the request, for example by pretending to be that disk, and then resumes the guest where it stopped.&#10;From the guest&amp;rsquo;s point of view, the instruction simply finished.&lt;/p&gt;&#10;&lt;p&gt;Memory is fenced off too.&#10;The host gives the CPU a separate map of which real memory each guest may use, and the CPU checks every memory access against it.&#10;So even a guest kernel with full privileges cannot touch the host&amp;rsquo;s memory.&lt;/p&gt;&#10;&lt;p&gt;The CPU only provides the mechanism.&#10;The software that sets the rules and handles the VM exits is called the hypervisor.&#10;&amp;ldquo;Hypervisor&amp;rdquo; is a role, not one program, and on Linux it is two pieces:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;KVM&lt;/strong&gt;, a module in the Linux kernel, uses the CPU&amp;rsquo;s virtualization mode to run the guest, keeps that memory map, and catches its VM exits. It shows up as the device &lt;code&gt;/dev/kvm&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A VMM&lt;/strong&gt; (virtual machine monitor), a normal program on top, sets up the VM and handles the exits that KVM passes up, for example by pretending to be the guest&amp;rsquo;s disk and network card. &lt;a href="https://www.qemu.org" target="_blank" rel="noopener"&gt;QEMU&lt;/a&gt;, &lt;a href="https://firecracker-microvm.github.io" target="_blank" rel="noopener"&gt;Firecracker&lt;/a&gt;, and &lt;a href="https://www.cloudhypervisor.org" target="_blank" rel="noopener"&gt;Cloud Hypervisor&lt;/a&gt; are three VMMs you can pick from.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Proxmox, which I mentioned above, is KVM plus QEMU.&#10;AWS Lambda is KVM plus Firecracker.&lt;/p&gt;&#10;&lt;p&gt;Code in a VM talks to its own guest kernel.&#10;To reach the host, it usually has to break that kernel first and then the hypervisor.&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;&#10;That is still possible.&#10;Google Project Zero turned &lt;a href="https://googleprojectzero.blogspot.com/2021/06/an-epyc-escape-case-study-of-kvm.html" target="_blank" rel="noopener"&gt;a bug in how KVM runs VMs inside VMs on AMD&lt;/a&gt; into a full escape in 2021.&#10;Google&amp;rsquo;s &lt;a href="https://security.googleblog.com/2024/06/virtual-escape-real-reward-introducing.html" target="_blank" rel="noopener"&gt;kvmCTF&lt;/a&gt; pays up to &lt;span class="hover-tip" data-tooltip="The top reward in the current kvmCTF rules, for a full escape from the guest VM to the host." tabindex="0"&gt;$250,000&lt;/span&gt; for a new one.&#10;And this year, one researcher, Hyunwoo Kim, published &lt;a href="https://github.com/V4bel/Zapscape" target="_blank" rel="noopener"&gt;three KVM escapes&lt;/a&gt;, one of them hidden in KVM &lt;span class="hover-tip" data-tooltip="Januscape, CVE-2026-53359: a use-after-free in the shadow page tables of x86 KVM, added in August 2010 and fixed in June 2026." tabindex="0"&gt;since 2010&lt;/span&gt;.&lt;/p&gt;&#10;&lt;p&gt;The difference between a container and a VM is how much code an attacker can reach, and that gap is large.&#10;On x86 (the Intel and AMD chips in most PCs and servers), Linux has &lt;span class="hover-tip" data-tooltip="The x86_64 syscall table of Linux 7.3-rc7 has 386 entries for 64-bit programs, and 16 of them are empty slots that only return an error." tabindex="0"&gt;about 370 syscalls&lt;/span&gt;, and Docker&amp;rsquo;s &lt;a href="https://docs.docker.com/engine/security/seccomp/" target="_blank" rel="noopener"&gt;default seccomp filter&lt;/a&gt; blocks &lt;span class="hover-tip" data-tooltip="Docker&amp;#39;s documentation says its default profile disables around 44 system calls out of 300&amp;#43;. The current profile blocks a few more, like the io_uring calls." tabindex="0"&gt;a few dozen&lt;/span&gt; of them.&#10;Behind the rest sit the kernel&amp;rsquo;s filesystems, networking, memory management, and core code: &lt;span class="hover-tip" data-tooltip="The fs, net, mm and kernel folders of Linux 7.3-rc7, counted with tokei, comments and blank lines included. The whole tree is 43 million lines, 26.5 million of them drivers." tabindex="0"&gt;about 3.8 million lines&lt;/span&gt;, even without counting drivers.&#10;Not all of that is reachable from a container, but a lot of it is.&#10;KVM on x86 is &lt;span class="hover-tip" data-tooltip="arch/x86/kvm plus virt/kvm in the same Linux 7.3-rc7 tree, counted the same way: 113,720 lines." tabindex="0"&gt;about 114,000 lines&lt;/span&gt;.&#10;On top of KVM comes the VMM, but only the few devices it fakes for the guest.&#10;Most QEMU escapes came from its many emulated devices, which is why Firecracker and Cloud Hypervisor keep that list short.&lt;/p&gt;&#10;&lt;p&gt;Google&amp;rsquo;s bug bounties point the same way.&#10;&lt;a href="https://google.github.io/security-research/kernelctf/rules.html" target="_blank" rel="noopener"&gt;kernelCTF&lt;/a&gt; pays &lt;span class="hover-tip" data-tooltip="One fixed amount per accepted exploit, according to the current kernelCTF rules." tabindex="0"&gt;$71,337&lt;/span&gt; for each new kernel exploit that turns a normal user into root, and Google&amp;rsquo;s &lt;a href="https://github.com/google/security-research/tree/master/pocs/linux/kernelctf" target="_blank" rel="noopener"&gt;public repository&lt;/a&gt; holds exploits for &lt;span class="hover-tip" data-tooltip="119 exploit folders in pocs/linux/kernelctf, covering 93 unique CVEs, at commit 79f049dc from October 16, 2026." tabindex="0"&gt;93 different kernel bugs&lt;/span&gt;.&#10;kvmCTF pays more than three times as much, and the same repository holds &lt;span class="hover-tip" data-tooltip="The only folder in pocs/linux/kvmctf at the same commit: a NULL pointer dereference reached through nested virtualization. Januscape was also used in kvmCTF but is not in the repository yet." tabindex="0"&gt;one KVM exploit&lt;/span&gt;, which crashes the host instead of escaping it.&#10;kvmCTF started a year later, but the gap is still big.&#10;Most of those kernel exploits would not work as written from a default container, because they &lt;span class="hover-tip" data-tooltip="84 of the 119 exploit folders create a user namespace, which gives a normal process root&amp;#39;s powers over its own set of namespaces and opens up kernel code it could not reach otherwise. Docker&amp;#39;s default seccomp profile blocks that unless the container has the CAP_SYS_ADMIN capability." tabindex="0"&gt;first use a feature Docker blocks&lt;/span&gt;.&#10;Still, they show how many exploitable bugs sit behind the syscalls.&lt;/p&gt;&#10;&lt;h2 id="kata-containers-and-gvisor"&gt;Kata Containers and gVisor &lt;a class="anchor" href="#kata-containers-and-gvisor" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Regular VMs are heavy.&#10;They boot a full operating system and tend to use a lot of memory.&#10;A microVM is a stripped-down VM meant to run one workload and start quickly.&#10;That still leaves a practical problem.&#10;MindRoom, like most tools, speaks Docker: it hands Docker an image and some folders to mount, and gets a running container back.&#10;A VM does not take a Docker image.&#10;Switching to VMs would normally mean building disk images, booting them, and wiring up files and networking myself.&lt;/p&gt;&#10;&lt;p&gt;Docker does not start containers by itself, though.&#10;It takes care of images, networks, and volumes, and then hands the actual start to a small program called a runtime.&#10;The default runtime, &lt;code&gt;runc&lt;/code&gt;, does what I described earlier: it starts a process on the host kernel with namespaces, cgroups, and seccomp.&#10;But you can tell Docker to use a different runtime.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://katacontainers.io" target="_blank" rel="noopener"&gt;Kata Containers&lt;/a&gt; is such a runtime, and it starts a microVM instead.&#10;It picks one of the VMMs above (my test used QEMU), boots a small guest kernel, and starts the container inside it.&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;&#10;Docker, and MindRoom, still see an ordinary container.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://gvisor.dev" target="_blank" rel="noopener"&gt;gVisor&lt;/a&gt;, from Google, takes a different route, and it is not a VM.&#10;It is a reimplementation of the Linux kernel, written in Go, that runs as a normal program on the host.&#10;Go manages memory for the programmer, so the use-after-free mistake behind the DepthFirst escape is much harder to make in gVisor.&#10;The worker&amp;rsquo;s syscalls go to gVisor instead of the host kernel, and gVisor itself only makes a small, filtered set of host syscalls.&#10;That is the hardening idea taken all the way: gVisor answers the agent&amp;rsquo;s syscalls itself, so the filter on the host can be much stricter than Docker&amp;rsquo;s.&#10;It needs no &lt;code&gt;/dev/kvm&lt;/code&gt;, so it also runs inside cloud VMs that cannot start VMs of their own.&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt;&#10;My test machine, an ARM server (with the kind of chip phones use) running NixOS, had no &lt;code&gt;/dev/kvm&lt;/code&gt;, which is why I tried gVisor first.&#10;Its runtime is called &lt;code&gt;runsc&lt;/code&gt;, and it plugs into Docker the same way.&lt;/p&gt;&#10;&lt;figure id="figure-the-three-options-side-by-side"&gt;&lt;img src="https://www.nijho.lt/post/kernels-containers-vms/isolation-stacks.svg" alt="Three stacks side by side. Plain Docker: the agent&amp;#39;s code makes syscalls straight into the host kernel, so the usual way out is a host kernel bug, reached through any syscall seccomp allows. gVisor: the agent&amp;#39;s code makes syscalls into gVisor&amp;#39;s own kernel, written in Go, which makes a small set of syscalls to the host kernel, so the usual way out is a gVisor bug and then a host kernel bug. Kata Containers: the agent&amp;#39;s code runs on a guest kernel inside a microVM, and the CPU runs that guest kernel directly, next to the host kernel instead of on top of it. When the guest needs a device, a VM exit hands control to the KVM module in the host kernel, which passes the request up to the VMM, a normal program on the host that fakes the device. The VMM and KVM together are the hypervisor, so the usual way out is a guest kernel bug and then a hypervisor bug." loading="lazy" data-zoomable&gt;&lt;figcaption&gt;The three options side by side.&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;p&gt;Or as a table:&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;Isolation&lt;/th&gt;&#10;&lt;th&gt;What worker code talks to&lt;/th&gt;&#10;&lt;th&gt;Needs KVM&lt;/th&gt;&#10;&lt;th&gt;Usual escape route&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Plain Docker (&lt;code&gt;runc&lt;/code&gt;)&lt;/td&gt;&#10;&lt;td&gt;The host kernel, filtered by seccomp&lt;/td&gt;&#10;&lt;td&gt;No&lt;/td&gt;&#10;&lt;td&gt;A host kernel bug&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;gVisor (&lt;code&gt;runsc&lt;/code&gt;)&lt;/td&gt;&#10;&lt;td&gt;gVisor&amp;rsquo;s own kernel, written in Go&lt;/td&gt;&#10;&lt;td&gt;No&lt;/td&gt;&#10;&lt;td&gt;A gVisor bug, then a host kernel bug through its small filtered surface&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Kata Containers&lt;/td&gt;&#10;&lt;td&gt;Its own guest kernel in a microVM&lt;/td&gt;&#10;&lt;td&gt;Yes&lt;/td&gt;&#10;&lt;td&gt;A guest kernel bug, then a hypervisor bug&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Each option also changes how much code on the host an escape can pick its bug from.&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Lines of code on the host that deal with what the agent&amp;rsquo;s code asks for, counted with tokei, comments and blank lines included. For Docker, the core of Linux without drivers; for gVisor, its kernel and network stack without tests; for Kata, KVM alone, without the VMM and Kata&amp;rsquo;s other helpers. Not all of it is reachable. Hover for exact numbers." data-plot-module="/post/kernels-containers-vms/plots.js" data-plot-name="attackSurface"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Lines of code on the host that deal with what the agent&amp;rsquo;s code asks for, counted with tokei, comments and blank lines included. For Docker, the core of Linux without drivers; for gVisor, its kernel and network stack without tests; for Kata, KVM alone, without the VMM and Kata&amp;rsquo;s other helpers. Not all of it is reachable. Hover for exact numbers.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;h2 id="the-pr-six-lines"&gt;The PR: six lines &lt;a class="anchor" href="#the-pr-six-lines" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The change is &lt;a href="https://github.com/mindroom-ai/mindroom/pull/2886" target="_blank" rel="noopener"&gt;PR #2886&lt;/a&gt;, which is now merged.&#10;It adds one setting, &lt;code&gt;MINDROOM_DOCKER_WORKER_RUNTIME&lt;/code&gt;, and MindRoom passes its value to Docker as the runtime whenever it starts a worker.&#10;That is &lt;span class="hover-tip" data-tooltip="Three lines in docker.py and three in docker_config.py. The merged PR adds 98 lines: these 6, 62 lines of tests, and 30 lines of docs." tabindex="0"&gt;six lines of source code&lt;/span&gt;, plus 62 lines of tests and some docs.&#10;Changing the setting recreates each worker the next time it is used, and leaving it unset keeps everything as it was.&lt;/p&gt;&#10;&lt;p&gt;For gVisor, setting it up is two steps: register the runtime in Docker&amp;rsquo;s &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; and restart Docker, then point MindRoom at it.&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;runtimes&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;runsc&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;path&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/usr/local/bin/runsc&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;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="nv"&gt;MINDROOM_DOCKER_WORKER_RUNTIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;runsc&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Kata needs no entry in that file: once it is installed, &lt;code&gt;MINDROOM_DOCKER_WORKER_RUNTIME=io.containerd.kata.v2&lt;/code&gt; is enough.&#10;The full setup, and a few catches for each runtime, are in the &lt;a href="https://docs.mindroom.chat/deployment/sandbox-proxy/#security-considerations" target="_blank" rel="noopener"&gt;MindRoom docs&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The six lines are short because everything else already existed.&#10;gVisor and Kata do the isolating.&#10;Docker already gives each worker its folders, its credentials, and its network, and it already knows how to hand a container to a different runtime.&#10;On the MindRoom side, the hard part happened months earlier, when agent tools moved out of the main process into dedicated workers with explicit mounts and credentials.&#10;Once every worker is a single call to Docker&amp;rsquo;s &lt;code&gt;containers.run&lt;/code&gt;, swapping what runs underneath it is one argument.&#10;For comparison, Claude Opus estimated that building a microVM backend into MindRoom itself would take &lt;span class="hover-tip" data-tooltip="For a native Cloud Hypervisor backend that rebuilds image conversion, guest init, folder sharing, networking, and VM supervision. The estimate is in the PR description." tabindex="0"&gt;2,500 to 4,000 lines&lt;/span&gt;.&lt;/p&gt;&#10;&lt;h2 id="what-it-costs"&gt;What it costs &lt;a class="anchor" href="#what-it-costs" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I tested both runtimes with a real agent in a Matrix room: gVisor on that ARM server, and Kata &lt;span class="hover-tip" data-tooltip="Kata 4.2 with QEMU and Docker 29.9, inside an Incus VM with nested KVM on my desktop&amp;#39;s Ryzen 9 3900X." tabindex="0"&gt;in a throwaway VM on my desktop&lt;/span&gt;.&#10;Shell, Python, git, background processes, and the browser all worked, without any MindRoom code change.&#10;My Kata numbers below come from a VM inside a VM, so bare-metal numbers will differ.&lt;/p&gt;&#10;&lt;p&gt;Configuration is the cheap part: one environment variable, plus one entry in Docker&amp;rsquo;s config for gVisor.&#10;The other costs are the usual trade-offs between containers and VMs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Computation.&lt;/strong&gt; Code that only calculates, without asking the kernel for anything, runs at full speed with both. gVisor lets those instructions run directly on the CPU, according to its &lt;a href="https://gvisor.dev/docs/architecture_guide/performance/" target="_blank" rel="noopener"&gt;performance guide&lt;/a&gt;. A Kata guest runs on the CPU in its virtualization mode, and in &lt;a href="https://www.usenix.org/conference/nsdi20/presentation/agache" target="_blank" rel="noopener"&gt;AWS&amp;rsquo;s paper on Firecracker&lt;/a&gt;, the CPU overhead was &lt;span class="hover-tip" data-tooltip="The paper reports memory overhead as low as 3% and negligible CPU overhead." tabindex="0"&gt;negligible&lt;/span&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Syscalls, disk, and network.&lt;/strong&gt; The cost starts when a program does ask the kernel for something. Under gVisor, every syscall takes a detour through gVisor&amp;rsquo;s kernel, so programs that make many small syscalls, like ones that open thousands of small files, slow down the most. Under Kata, disk and network go through the VMM&amp;rsquo;s fake devices. In that same paper from 2020, a Firecracker guest reached &lt;span class="hover-tip" data-tooltip="iperf3 over a tap device on an EC2 m5d.metal host, one stream: 44.1 Gbit/s for the host, 15.6 for Firecracker, and 23.8 for QEMU." tabindex="0"&gt;15 Gbit/s of network traffic where the host reached 44&lt;/span&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Startup.&lt;/strong&gt; gVisor only has to start a program, so it starts quickly. Kata has to boot a kernel first, and my worker took &lt;span class="hover-tip" data-tooltip="From creating the container until the worker answered its health check, three runs each: 10.1 to 10.9 seconds with Kata, 2.4 to 3.3 with runc." tabindex="0"&gt;about 10 seconds to start instead of about 3&lt;/span&gt;. Booting a VM can be much faster than that: &lt;a href="https://firecracker-microvm.github.io" target="_blank" rel="noopener"&gt;Firecracker&lt;/a&gt; boots a minimal one in about 125 ms.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Memory.&lt;/strong&gt; gVisor adds a &lt;span class="hover-tip" data-tooltip="gVisor&amp;#39;s performance guide: the sandbox itself is responsible for a small, mostly fixed amount of memory overhead." tabindex="0"&gt;small, mostly fixed amount&lt;/span&gt; per sandbox. Each Kata VM runs a second kernel plus the VMM, which alone takes &lt;span class="hover-tip" data-tooltip="Figure 7 of the Firecracker paper: memory used by the VMM process on top of the guest&amp;#39;s own memory, about 3 MB for Firecracker, 13 MB for Cloud Hypervisor, and 131 MB for QEMU." tabindex="0"&gt;about 3 MB with Firecracker and 131 MB with QEMU&lt;/span&gt;. Kata gives each worker VM &lt;span class="hover-tip" data-tooltip="Kata&amp;#39;s default VM has 1 vCPU and 2 GiB, set by default_vcpus and default_memory in its configuration file. Kata adds a container&amp;#39;s memory limit on top, but MindRoom sets none for its workers. The worker&amp;#39;s 1 GiB /tmp counts against it." tabindex="0"&gt;2 GiB by default&lt;/span&gt;, and &lt;code&gt;/tmp&lt;/code&gt; lives in that RAM. The host only hands out memory the guest actually touches, so my idle worker used &lt;span class="hover-tip" data-tooltip="QEMU used 530 to 616 MiB and virtiofsd, which shares the folders, about 160 MiB for one idle worker VM. Both map the guest&amp;#39;s memory, so some pages count twice and the real total is lower." tabindex="0"&gt;under 0.8 GiB&lt;/span&gt; of host memory. Raising the VM size &lt;span class="hover-tip" data-tooltip="The guest kernel keeps 64 bytes of bookkeeping for every 4 KiB page, about 1.6% of the VM&amp;#39;s size, so an 8 GiB VM costs about 128 MiB up front." tabindex="0"&gt;costs little&lt;/span&gt; until a worker needs it. But a VM cannot grow past its size the way a container can, and memory the guest has used, for example to keep recently read files in its cache, &lt;span class="hover-tip" data-tooltip="Kata&amp;#39;s reclaim_guest_freed_memory option, off by default, returns pages the guest frees. File cache does not count as free, so it stays." tabindex="0"&gt;mostly stays with the VM&lt;/span&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Compatibility.&lt;/strong&gt; gVisor reimplements Linux, and its &lt;a href="https://gvisor.dev/docs/user_guide/compatibility/" target="_blank" rel="noopener"&gt;docs&lt;/a&gt; say there will always be unimplemented features and bugs, so now and then a program will not work. Kata runs a real Linux kernel, so programs behave as usual, with a few catches. For example, a file lock held on the host does not block the worker, because the lock lives in the guest kernel.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="what-this-does-not-fix"&gt;What this does not fix &lt;a class="anchor" href="#what-this-does-not-fix" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;A stronger sandbox protects the host.&#10;It does nothing about the tokens and network access you give the worker.&#10;An agent needs no kernel exploit to misuse a token that can push to your repositories, or to poke at every device on your home network.&#10;Scoped credentials and a firewall allowlist protect you at least as much, and MindRoom handles both separately: &lt;a href="https://docs.mindroom.chat/deployment/egress-broker/" target="_blank" rel="noopener"&gt;Agent Keys&lt;/a&gt; let agents use API keys without ever seeing them, and &lt;a href="https://docs.mindroom.chat/deployment/approved-egress/" target="_blank" rel="noopener"&gt;Approved Egress&lt;/a&gt; limits which servers a worker on Kubernetes can reach.&lt;/p&gt;&#10;&lt;p&gt;gVisor has had its own bugs, and it still talks to the host kernel, just through a much smaller door.&#10;KVM has bugs too, as this year&amp;rsquo;s escapes show.&#10;Both walls make an escape far harder, and neither makes it impossible.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion &lt;a class="anchor" href="#conclusion" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Once I started reading about this, I kept running into people saying containers were never a security boundary.&#10;That is true, and for years I did not worry about it.&#10;With AI finding kernel bugs faster and agents running code nobody reads, I do now.&lt;/p&gt;&#10;&lt;p&gt;No sandbox is perfectly safe, though.&#10;In &lt;a href="https://www.dwarkesh.com/p/noam-brown" target="_blank" rel="noopener"&gt;a conversation with Dwarkesh Patel&lt;/a&gt;, Noam Brown doubted that even computers with no network connection at all would be enough to contain an AI.&#10;He pointed to &lt;a href="https://arxiv.org/abs/1503.07919" target="_blank" rel="noopener"&gt;studies&lt;/a&gt; where two such computers next to each other &lt;span class="hover-tip" data-tooltip="BitWhisper, from Ben-Gurion University in 2015: 1 to 8 bits per hour between computers up to 40 cm apart, enough for a short command or a password." tabindex="0"&gt;talk through heat&lt;/span&gt;: one runs its CPU hot, and the other picks up the change with its temperature sensor.&#10;Every option in this post trades some speed or memory for safety.&#10;How far to go depends on your own risk tolerance and how paranoid you are.&#10;MindRoom can now move its agents off the shared kernel with one environment variable.&#10;I do not use it myself yet, but if you are more paranoid than I am, it is there.&lt;/p&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;The other way out is a bug in the program that starts the container. In 2024, &lt;a href="https://github.com/opencontainers/runc/security/advisories/GHSA-xr7r-f8xq-vfvv" target="_blank" rel="noopener"&gt;CVE-2024-21626&lt;/a&gt; in runc, the program Docker uses for that, leaked a handle to a host folder into new containers, so a malicious image could reach the host&amp;rsquo;s files without any kernel bug.&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;li id="fn:2"&gt;&#10;&lt;p&gt;Docker can map the container&amp;rsquo;s root to an ordinary user on the host with &lt;a href="https://docs.docker.com/engine/security/userns-remap/" target="_blank" rel="noopener"&gt;user namespace remapping&lt;/a&gt;, or run without root altogether in &lt;a href="https://docs.docker.com/engine/security/rootless/" target="_blank" rel="noopener"&gt;rootless mode&lt;/a&gt;, but neither is the default. Rootless mode would have stopped the agent in my Docker story, because Docker then has only my own rights. Remapping would not, because anyone who can use Docker can turn it off for a single container. Neither helps against a kernel bug, because code running inside the kernel is above every user.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:3"&gt;&#10;&lt;p&gt;A bug in the VMM&amp;rsquo;s fake devices, or in other VMM code that handles the guest&amp;rsquo;s traffic, can skip the first step, because a normal program in the guest can feed that code data through the network or a shared folder. &lt;a href="https://access.redhat.com/security/cve/cve-2020-8608" target="_blank" rel="noopener"&gt;CVE-2020-8608&lt;/a&gt;, a buffer overflow in QEMU&amp;rsquo;s user-mode networking, could be triggered that way with crafted network traffic.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:4"&gt;&#10;&lt;p&gt;Like runc for Docker, Kata&amp;rsquo;s own programs on the host are a way out too. In 2020, one bug (&lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2020-2023" target="_blank" rel="noopener"&gt;CVE-2020-2023&lt;/a&gt;) let a container take over the guest without a kernel bug, and a second (&lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2020-2026" target="_blank" rel="noopener"&gt;CVE-2020-2026&lt;/a&gt;) then let the guest trick the Kata runtime on the host into mounting files over any host path. Neither needed a KVM bug. virtiofsd, the program that shares folders with the guest, also runs on the host and handles the guest&amp;rsquo;s file requests.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:5"&gt;&#10;&lt;p&gt;gVisor can &lt;a href="https://gvisor.dev/docs/architecture_guide/platforms/" target="_blank" rel="noopener"&gt;use KVM too&lt;/a&gt;, as an option. By default it catches the agent&amp;rsquo;s syscalls without it, which is the setup this post describes. With KVM switched on, gVisor runs the agent&amp;rsquo;s code in the CPU&amp;rsquo;s virtualization mode so catching syscalls is faster, but gVisor&amp;rsquo;s own kernel still answers them, and there is no guest Linux and no fake devices. gVisor recommends that mode only on bare metal; inside a VM the default is usually faster.&amp;#160;&lt;a href="#fnref:5" 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>Self-hosting AI does not save money, and I do it anyway</title><link>https://www.nijho.lt/post/self-hosting-ai-is-not-cheaper/</link><pubDate>Fri, 02 Oct 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/self-hosting-ai-is-not-cheaper/</guid><description>&lt;p&gt;I have had this debate many times, so I am finally writing it down.&lt;/p&gt;&#10;&lt;p&gt;Whenever I say that self-hosting AI does not save money, people hear that I am against self-hosting.&#10;That is not the point at all.&#10;I am a massive fan of open-weight models.&#10;&lt;a href="https://github.com/basnijholt/dotfiles/blob/e63a3f341ff36b7b57bf31361c2844e1d8b78e95/configs/nixos/hosts/pc/ai.nix#L43" target="_blank" rel="noopener"&gt;Qwen3.8 27B runs on two RTX 3090s at home&lt;/a&gt; (and 15 more models), and my phone dictation goes to &lt;a href="https://www.nijho.lt/post/diction-agent-cli-qwen/"&gt;Qwen3-ASR on the same machine&lt;/a&gt;.&#10;I think open-source AI is the best thing since sliced bread.&lt;/p&gt;&#10;&lt;p&gt;I don&amp;rsquo;t pretend it saves money.&#10;I do it for fun, for sovereignty, and for privacy, which I come back to at the end.&lt;/p&gt;&#10;&lt;p&gt;All benchmark numbers and prices below come from &lt;a href="https://artificialanalysis.ai/" target="_blank" rel="noopener"&gt;Artificial Analysis&lt;/a&gt; and &lt;a href="https://openrouter.ai/" target="_blank" rel="noopener"&gt;OpenRouter&lt;/a&gt; as of September 30, 2026.&#10;I did the math for the hardware I own, and for the counterarguments I hear most.&#10;All assumptions are listed &lt;a href="#appendix-all-assumptions"&gt;at the end&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Benchmarks are not everything, and a high score does not reliably predict how a model does on real work.&#10;They are still the best we have: all models are benchmaxed, tuned to do well on the popular benchmarks, so the scores at least work as a reference frame for comparing them with each other.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;Update, October 2, 2026: after the discussion on r/LocalLLaMA, I added &lt;a href="#5-but-my-mac-dgx-spark-or-ai-max-does-save-money"&gt;section 5&lt;/a&gt; on Macs, DGX Sparks, and AMD AI Max machines.&lt;/em&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="#1-the-model-i-love-and-the-one-that-beats-it-on-price"&gt;1. The model I love, and the one that beats it on price&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-but-my-gpus-are-already-paid-for"&gt;2. &amp;ldquo;But my GPUs are already paid for&amp;rdquo;&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-why-datacenters-are-so-much-better-at-this"&gt;3. Why datacenters are so much better at this&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-but-i-have-solar-panels"&gt;4. &amp;ldquo;But I have solar panels&amp;rdquo;&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#5-but-my-mac-dgx-spark-or-ai-max-does-save-money"&gt;5. &amp;ldquo;But my Mac, DGX Spark, or AI Max does save money&amp;rdquo;&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#6-the-bar-keeps-moving"&gt;6. The bar keeps moving&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#7-a-small-company-has-it-worse"&gt;7. A small company has it worse&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#8-they-lose-money-on-inference-so-prices-will-go-up"&gt;8. &amp;ldquo;They lose money on inference, so prices will go up&amp;rdquo;&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#9-but-they-sell-your-data"&gt;9. &amp;ldquo;But they sell your data&amp;rdquo;&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#10-where-self-hosting-does-win-the-same-model"&gt;10. Where self-hosting does win: the same model&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#11-so-why-do-i-do-it"&gt;11. So why do I do it?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#appendix-all-assumptions"&gt;Appendix: all assumptions&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="1-the-model-i-love-and-the-one-that-beats-it-on-price"&gt;1. The model I love, and the one that beats it on price &lt;a class="anchor" href="#1-the-model-i-love-and-the-one-that-beats-it-on-price" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My favorite local model right now is &lt;a href="https://artificialanalysis.ai/models/qwen3-8-27b" target="_blank" rel="noopener"&gt;Qwen3.8 27B&lt;/a&gt;.&#10;It came out in August and it is Apache-2.0.&#10;People often say models like this run on a single gaming GPU, but that is only true after quantizing them.&#10;At full precision, Qwen3.8 27B needs about 54 GB of memory.&#10;Quantized to about 4 bits per weight, it fits on one 24 GB card, at some cost in quality.&#10;I still &lt;a href="https://github.com/basnijholt/dotfiles/blob/e63a3f341ff36b7b57bf31361c2844e1d8b78e95/configs/nixos/hosts/pc/ai.nix#L56-L65" target="_blank" rel="noopener"&gt;split it across both of my 3090s&lt;/a&gt;, because the second card leaves room for a larger context window.&lt;/p&gt;&#10;&lt;p&gt;I compare it with OpenAI&amp;rsquo;s &lt;a href="https://artificialanalysis.ai/models/gpt-6-luna" target="_blank" rel="noopener"&gt;GPT-6 Luna&lt;/a&gt;, the cheap tier released on September 22.&#10;Both models let you choose how long they think, but the settings do not mean the same thing for both.&#10;Luna&amp;rsquo;s token use grows more than 20 times from its lowest setting to its highest, while Qwen&amp;rsquo;s barely changes.&#10;Qwen on low already writes more tokens than Luna on xhigh.&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Average output tokens per Intelligence Index task at each reasoning setting. Qwen3.8 27B has no high or max setting. Hover for scores." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="effortTokens"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Average output tokens per Intelligence Index task at each reasoning setting. Qwen3.8 27B has no high or max setting. Hover for scores.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;So the name of a setting says little on its own.&#10;I compare both models at xhigh, the highest setting Qwen offers, and count the tokens each one actually uses.&#10;The extra tokens Qwen needs are part of what I am measuring.&#10;There, Qwen scores 33.7 on the &lt;a href="https://artificialanalysis.ai/methodology/intelligence-benchmarking" target="_blank" rel="noopener"&gt;Artificial Analysis Intelligence Index&lt;/a&gt; and Luna scores 34.6.&#10;For reference, Claude Opus 4.6, a frontier model from February, scores 31.9.&#10;That is an amazing feat in itself.&#10;When Opus 4.6 was the best model we had, I never thought that within the same year I could run essentially that level of intelligence in my own house.&lt;/p&gt;&#10;&lt;p&gt;Artificial Analysis also &lt;a href="https://artificialanalysis.ai/leaderboards/models" target="_blank" rel="noopener"&gt;publishes&lt;/a&gt; how many tokens each model used to run the index, and what that cost.&#10;So the question is simple: what does it cost to run the whole benchmark once?&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;&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Cost of one run of the Artificial Analysis Intelligence Index. The two bars for hardware at home are electricity only: measured on my 3090s, estimated for the RTX PRO 6000." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="runCost"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Cost of one run of the Artificial Analysis Intelligence Index. The two bars for hardware at home are electricity only: measured on my 3090s, estimated for the RTX PRO 6000.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;Luna runs the whole benchmark for $67.&#10;Qwen at full precision, through the &lt;a href="https://openrouter.ai/qwen/qwen3.8-27b/providers" target="_blank" rel="noopener"&gt;cheapest provider&lt;/a&gt; that does not keep your data (ZDR), costs $619.&#10;Two things cause that gap: providers charge almost four times as much per output token for Qwen, and Qwen generates almost three times as many tokens to get the same work done.&lt;/p&gt;&#10;&lt;p&gt;The second bar is my own machine: the electricity alone for running Qwen on my GPUs costs more than Luna&amp;rsquo;s entire bill.&lt;/p&gt;&#10;&lt;p&gt;The third bar is what it takes to match the API&amp;rsquo;s full precision at home: an RTX PRO 6000, a workstation card with 96 GB of memory, enough for the full model.&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;&#10;Its electricity alone costs twice Luna&amp;rsquo;s bill, and one run takes more than seven weeks.&#10;The card by itself sells for $10,000 to $20,000, so with a computer around it, you are paying for a pretty nice car.&#10;Buy a few of them to run agents in parallel, and you are building a small data center of your own.&lt;/p&gt;&#10;&lt;p&gt;The bars for hardware at home are electricity only, so they are the lowest these runs can cost: the cards come on top, and how much depends on how busy you keep them, which is what section 4 is about.&lt;/p&gt;&#10;&lt;p&gt;What I care about is the cheapest way to get answers of Qwen&amp;rsquo;s quality, from whichever model gives them.&lt;/p&gt;&#10;&lt;h2 id="2-but-my-gpus-are-already-paid-for"&gt;2. &amp;ldquo;But my GPUs are already paid for&amp;rdquo; &lt;a class="anchor" href="#2-but-my-gpus-are-already-paid-for" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This is the argument I hear most, so assume the hardware is free and only the electricity counts.&lt;/p&gt;&#10;&lt;p&gt;I measured my own machine.&#10;It runs Qwen in vLLM, split across both cards, at about 107 tokens per second.&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;&#10;The cards are capped at 270 W each,&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt; and the whole machine draws about 700 W.&#10;It then needs four weeks, running day and night, to get through the benchmark once.&#10;I also give my quantized copy the score Artificial Analysis measured through Alibaba&amp;rsquo;s API.&#10;I have not rerun the benchmark on it, and anything lost to quantization makes my machine look worse.&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Electricity for one benchmark run of Qwen3.8 27B on my two 3090s, by electricity price. The dashed line is what Luna charges for the same work, with OpenAI&amp;rsquo;s hardware and margin included. Hover for values." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="electricityCost"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Electricity for one benchmark run of Qwen3.8 27B on my two 3090s, by electricity price. The dashed line is what Luna charges for the same work, with OpenAI&amp;rsquo;s hardware and margin included. Hover for values.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;Above 14.5 cents per kWh, my electricity alone costs more than Luna&amp;rsquo;s API bill.&#10;Below it, I save a few dollars per run and wait four weeks instead of a few hours.&lt;/p&gt;&#10;&lt;p&gt;Those four weeks matter more than the few dollars.&#10;An API takes hundreds of requests at once, so even one benchmark run can finish in an hour or two.&#10;My setup works on one conversation at a time: when I sent it eight requests at once, seven of them waited in line.&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Wall-clock time for one benchmark run. The API times use Artificial Analysis&amp;rsquo;s measured time per task for Luna; the parallel case assumes 100 requests at once. Log scale." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="wallClock"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Wall-clock time for one benchmark run. The API times use Artificial Analysis&amp;rsquo;s measured time per task for Luna; the parallel case assumes 100 requests at once. Log scale.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;This is one more reason my coding agents run on APIs.&#10;When I &lt;a href="https://www.nijho.lt/post/parallel-agentic-coding/"&gt;run several agents in parallel&lt;/a&gt;, each of them should be as fast as if it were alone.&lt;/p&gt;&#10;&lt;h2 id="3-why-datacenters-are-so-much-better-at-this"&gt;3. Why datacenters are so much better at this &lt;a class="anchor" href="#3-why-datacenters-are-so-much-better-at-this" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;When my 3090s generate a token, they read all of the model&amp;rsquo;s weights from memory to produce one token for one conversation.&#10;Most of the chip&amp;rsquo;s compute sits idle while it waits for memory.&#10;A datacenter GPU reads the same weights once and produces a token for hundreds of conversations at the same time.&#10;This is called batching, and it is where most of the efficiency comes from.&lt;/p&gt;&#10;&lt;p&gt;Artificial Analysis measures this with &lt;a href="https://artificialanalysis.ai/hardware-inference-stack/datacenter" target="_blank" rel="noopener"&gt;AgentPerf&lt;/a&gt;, which replays real coding-agent sessions against datacenter hardware.&#10;It counts how many agents a system can serve while each one still gets at least 60 tokens per second.&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Coding agents served per kW of accelerator power, each getting at least 60 tokens per second, from AgentPerf. The datacenter systems all run DeepSeek V4 Pro. The 3090 bar is my machine running Qwen3.8 27B, a much smaller model, with GPU power only, so the comparison favors my machine." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="agentsPerKw"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Coding agents served per kW of accelerator power, each getting at least 60 tokens per second, from AgentPerf. The datacenter systems all run DeepSeek V4 Pro. The 3090 bar is my machine running Qwen3.8 27B, a much smaller model, with GPU power only, so the comparison favors my machine.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;The rack of 36 GB300s serves 20 times more agents per kW than the 8-GPU H200 server.&#10;Part of that is newer chips, and part is software: NVIDIA tuned the B300 and GB300 setups itself, while Artificial Analysis configured the H200 one.&#10;The B300 and the GB300 share both the chip generation and the software.&#10;Most of the gap between those two bars comes from the rack itself: 36 GPUs on one fast interconnect keep more than a thousand agents in flight at once.&#10;My two 3090s serve one conversation at a time.&lt;/p&gt;&#10;&lt;h2 id="4-but-i-have-solar-panels"&gt;4. &amp;ldquo;But I have solar panels&amp;rdquo; &lt;a class="anchor" href="#4-but-i-have-solar-panels" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Solar power is not free.&#10;With net metering, every kWh my GPUs burn is a kWh that does not get credited at the retail price.&#10;Without net metering, it is a kWh I do not sell, unless the surplus would go to waste anyway, which is the free-power case below.&#10;And the GPUs also run at night.&lt;/p&gt;&#10;&lt;p&gt;But fine, say the electricity is free and the only cost is the hardware.&#10;I was lucky and bought my two 3090s for about $750 each,&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt; and I write them off over three years.&#10;The GPUs only save money while they do work I would otherwise pay an API for, and while they sit idle they save nothing.&#10;So the question is how many hours a day they have to be busy before they pay for themselves.&lt;sup id="fnref:6"&gt;&lt;a href="#fn:6" class="footnote-ref" role="doc-noteref"&gt;6&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;&#10;&lt;p&gt;To make this fair to open models, imagine an open model that is exactly as efficient as Luna: the same quality from the same number of tokens, sold at Luna&amp;rsquo;s price.&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="How many hours a day my two $750 RTX 3090s have to be busy, every day for three years, before they cost less than a ZDR API. The host PC is not counted." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="breakEven"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;How many hours a day my two $750 RTX 3090s have to be busy, every day for three years, before they cost less than a ZDR API. The host PC is not counted.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;With free power, the GPUs pay for themselves if they are busy 4 hours a day, every day for three years.&#10;At 18 cents per kWh, they need 8.5.&#10;That uses what I paid for the cards; at today&amp;rsquo;s price of about $1,500 each, the 8.5 hours become 14.&lt;/p&gt;&#10;&lt;p&gt;And API prices keep falling.&#10;GPT-6 Luna costs 58% less per output token than GPT-5.6 Luna did in July.&#10;If the price halves again, no amount of use pays off at 18 cents per kWh.&#10;If it halves twice, the API costs less than my electricity alone.&lt;/p&gt;&#10;&lt;h2 id="5-but-my-mac-dgx-spark-or-ai-max-does-save-money"&gt;5. &amp;ldquo;But my Mac, DGX Spark, or AI Max does save money&amp;rdquo; &lt;a class="anchor" href="#5-but-my-mac-dgx-spark-or-ai-max-does-save-money" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Several readers told me their machines do save them money: a MacBook Pro with an M5 Max, an NVIDIA DGX Spark, and a computer with AMD&amp;rsquo;s Ryzen AI Max+ 395.&#10;If you would have bought the machine anyway, that is true, for the reason in section 2: their electricity costs little compared with the API.&#10;Counting the hardware is a different story.&lt;/p&gt;&#10;&lt;p&gt;With 128 GB of memory, all three can hold a better model than my 3090s: Qwen3.8-Flash-Next, a sparse model with 180 billion parameters, of which only 6 billion work on each token.&#10;It scores 39.8, close to GPT-6 Luna on its highest setting (38.1), although at the Q2 a reader uses on their Mac it probably loses a few points.&lt;sup id="fnref:7"&gt;&lt;a href="#fn:7" class="footnote-ref" role="doc-noteref"&gt;7&lt;/a&gt;&lt;/sup&gt;&#10;So how many years would each machine have to run nonstop before it pays for itself?&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Years of nonstop generation before each machine pays for itself, compared with the most efficient API model at a similar score. Each machine runs the best model it can hold." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="paybackYears"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Years of nonstop generation before each machine pays for itself, compared with the most efficient API model at a similar score. Each machine runs the best model it can hold.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;Even with free power, the 128 GB machines need six to ten years.&#10;The AMD machine draws only about 120 W, but it is slow too: one benchmark run of Qwen3.8 27B takes it four and a half months, and almost as much energy as my 3090s.&#10;The RTX PRO 6000 runs the same model about three times as fast but costs about twice as much, so it needs six to nine years.&#10;My 3090s pay off in under two years with free power, which is why the 3090 is still the value king, but at 18 cents per kWh they never do: the electricity for one run already costs more than Luna charges for it.&lt;/p&gt;&#10;&lt;p&gt;Ten years is a long time in AI.&#10;Better and more efficient open models will run on the same machines, but the frontier moves at the same pace, and API prices keep falling.&#10;Hardware gets more efficient too, in datacenters and at home, so a machine bought today also ends up competing with next year&amp;rsquo;s machines.&#10;These numbers only show how far apart the two sides start.&lt;/p&gt;&#10;&lt;h2 id="6-the-bar-keeps-moving"&gt;6. The bar keeps moving &lt;a class="anchor" href="#6-the-bar-keeps-moving" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Section 4&amp;rsquo;s eight and a half hours a day would be easy for me: I run agents for longer than that, just not on Qwen.&#10;When Claude Opus 4.6 came out in February, I was perfectly happy with it.&#10;I thought it was all I would ever need, and I could not have imagined how much better models would get in half a year.&#10;Qwen3.8 27B now scores about the same as Opus 4.6, and I would no longer accept it for coding.&#10;Until GPT-6 Astra came out at the start of September, my go-to was GPT-5.6 Sol, which scores ten points higher than Qwen.&#10;I then used Astra until Claude Opus 5.5 came out less than three weeks later.&#10;Now I don&amp;rsquo;t even accept what was considered the best model a month ago.&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Intelligence Index of the best model available on each date, of the models I used as my go-to, and of Qwen&amp;rsquo;s 27B models, which fit on a single 3090. Scores use the xhigh reasoning setting where a model has it." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="frontierGap"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Intelligence Index of the best model available on each date, of the models I used as my go-to, and of Qwen&amp;rsquo;s 27B models, which fit on a single 3090. Scores use the xhigh reasoning setting where a model has it.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;The models that fit on my 3090s keep improving, but they stay about half a year behind the frontier, and my standard moves with the frontier.&lt;/p&gt;&#10;&lt;h2 id="7-a-small-company-has-it-worse"&gt;7. A small company has it worse &lt;a class="anchor" href="#7-a-small-company-has-it-worse" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For me, this is a hobby, and a hobby is allowed to be inefficient.&#10;It gets worse once you try to use self-hosted models seriously, say for a team of ten developers.&lt;/p&gt;&#10;&lt;p&gt;If you stay fully self-hosted and want fast responses at peak, you have to buy hardware for the busiest hour of the year, not for the average one.&#10;The rest of the time, it sits idle.&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="An illustrative week of coding agents for a ten-person team. The hardware has to cover the busiest hour of the year, but a typical week uses only a fraction of it." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="peakLoad"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;An illustrative week of coding agents for a ten-person team. The hardware has to cover the busiest hour of the year, but a typical week uses only a fraction of it.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;In this made-up but realistic week, the team uses 17% of what it paid for, so every token costs about six times more than it would at full load.&#10;You also need a spare GPU for when one dies, and someone who gets paged when it does.&#10;You could queue work or send the peaks to an API, but then you are paying for an API anyway.&lt;/p&gt;&#10;&lt;p&gt;An API provider has the opposite situation.&#10;It serves thousands of customers across every time zone, so its load curve is much flatter than yours.&#10;It fills the nights with discounted batch jobs (Luna&amp;rsquo;s batch tier is half price) and training runs.&#10;And more concurrent requests mean bigger batches, which is where the efficiency from section 3 comes from.&lt;/p&gt;&#10;&lt;h2 id="8-they-lose-money-on-inference-so-prices-will-go-up"&gt;8. &amp;ldquo;They lose money on inference, so prices will go up&amp;rdquo; &lt;a class="anchor" href="#8-they-lose-money-on-inference-so-prices-will-go-up" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Another argument I hear is that API prices are subsidized by investors and will go up once those investors want their money back.&#10;I don&amp;rsquo;t think that holds for inference.&lt;/p&gt;&#10;&lt;p&gt;AgentPerf also reports how many tokens each GPU serves.&#10;On the GB300 rack, one GPU serving DeepSeek V4 Pro handles about 4 million output tokens and 530 million input tokens per hour.&#10;Most of that input is conversation history that coding agents send again with every step.&#10;Providers keep it in a cache and charge less than 1% of the normal input price for it.&lt;/p&gt;&#10;&lt;p&gt;At &lt;a href="https://artificialanalysis.ai/models/deepseek-v4-pro-0424" target="_blank" rel="noopener"&gt;DeepSeek V4 Pro&amp;rsquo;s list prices&lt;/a&gt;, that GPU brings in at least $5.70 per hour, even if every input token is billed at the cache price.&#10;If 5% of the input misses the cache, it brings in about $17.&#10;Renting a B200, the closest GPU with a &lt;a href="https://artificialanalysis.ai/hardware-inference-stack/datacenter" target="_blank" rel="noopener"&gt;published rental price&lt;/a&gt;, costs $3.50 to $5.90 per hour from smaller cloud providers, and that price already includes their profit.&lt;/p&gt;&#10;&lt;p&gt;So the tokens pay for the hardware that serves them, even in the worst case.&#10;This leaves out costs like staff and training, so it does not tell you whether a lab makes money overall.&lt;/p&gt;&#10;&lt;p&gt;The money goes to training, research, free users, and flat-rate subscriptions for heavy users like me.&#10;When I wrote that I used &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;$10,000 worth of API tokens for $200&lt;/a&gt;, that was list price, not what those tokens cost to serve.&lt;/p&gt;&#10;&lt;p&gt;And prices go down, not up.&#10;This is what the same level of intelligence has cost this year:&lt;/p&gt;&#10;&lt;figure class="plot-figure"&gt;&#10; &lt;div class="plot-container" role="img" aria-label="Output price per million tokens for models scoring between 30 and 35 on the Intelligence Index, by release date, as listed by Artificial Analysis. Log scale." data-plot-module="/post/self-hosting-ai-is-not-cheaper/plots.js" data-plot-name="priceDrop"&gt;&lt;/div&gt;&#10; &lt;figcaption&gt;Output price per million tokens for models scoring between 30 and 35 on the Intelligence Index, by release date, as listed by Artificial Analysis. Log scale.&lt;/figcaption&gt;&#10; &lt;noscript&gt;This chart needs JavaScript.&lt;/noscript&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/plot.7cb86dc2e3ef25b56bc93730b2620745a7f02b5715de87f94e61908f60e51690.js" integrity="sha256-fLhtwuPvJbVryTcwsmIHRafwK1cV3of5TmGQj2DlFpA=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&#10;&#10;&lt;p&gt;From Claude Opus 4.6 in February to GPT-6 Luna in September, the output price for this level of intelligence dropped by a factor of 50.&#10;Labs are in a race to the bottom on price.&lt;/p&gt;&#10;&lt;h2 id="9-but-they-sell-your-data"&gt;9. &amp;ldquo;But they sell your data&amp;rdquo; &lt;a class="anchor" href="#9-but-they-sell-your-data" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Some people say APIs are cheap because the provider trains on your data or sells it.&#10;That is why I only used prices from providers that OpenRouter lists as &lt;a href="https://openrouter.ai/docs/features/zdr" target="_blank" rel="noopener"&gt;zero data retention&lt;/a&gt; (ZDR).&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;Model&lt;/th&gt;&#10;&lt;th&gt;Provider&lt;/th&gt;&#10;&lt;th&gt;ZDR&lt;/th&gt;&#10;&lt;th&gt;Output ($ per million tokens)&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;GPT-6 Luna&lt;/td&gt;&#10;&lt;td&gt;OpenAI&lt;/td&gt;&#10;&lt;td&gt;no&lt;/td&gt;&#10;&lt;td&gt;0.50&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;GPT-6 Luna&lt;/td&gt;&#10;&lt;td&gt;Azure&lt;/td&gt;&#10;&lt;td&gt;yes&lt;/td&gt;&#10;&lt;td&gt;0.50&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Qwen3.8 27B&lt;/td&gt;&#10;&lt;td&gt;AkashML (FP8)&lt;/td&gt;&#10;&lt;td&gt;yes&lt;/td&gt;&#10;&lt;td&gt;1.78&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Qwen3.8 27B&lt;/td&gt;&#10;&lt;td&gt;DeepInfra (full precision)&lt;/td&gt;&#10;&lt;td&gt;yes&lt;/td&gt;&#10;&lt;td&gt;1.88&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Qwen3.8 27B&lt;/td&gt;&#10;&lt;td&gt;Alibaba&lt;/td&gt;&#10;&lt;td&gt;no&lt;/td&gt;&#10;&lt;td&gt;2.55&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Qwen3.8 27B&lt;/td&gt;&#10;&lt;td&gt;Cloudflare&lt;/td&gt;&#10;&lt;td&gt;no&lt;/td&gt;&#10;&lt;td&gt;3.20&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Azure serves GPT-6 Luna with zero data retention at the same price as OpenAI.&#10;For Qwen3.8 27B, the cheapest endpoints are all ZDR, and the endpoints without ZDR cost more.&#10;The &lt;code&gt;:free&lt;/code&gt; tier is where you pay with your data.&lt;/p&gt;&#10;&lt;h2 id="10-where-self-hosting-does-win-the-same-model"&gt;10. Where self-hosting does win: the same model &lt;a class="anchor" href="#10-where-self-hosting-does-win-the-same-model" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;If you compare running Qwen3.8 27B yourself with paying for Qwen3.8 27B through an API, self-hosting wins.&lt;/p&gt;&#10;&lt;p&gt;DeepInfra, the cheapest full-precision ZDR provider, charges $619 for one benchmark run, while my electricity costs $83.&#10;That compares the full model with my quantized copy, so the gap overstates my advantage by whatever quantization costs in quality.&#10;My machine pays for itself if it is busy 2.4 hours a day, or 1.5 hours with free power (the first group in the chart in section 4).&lt;/p&gt;&#10;&lt;p&gt;Providers charge a lot for a dense 27B model, because every token runs through all 27 billion parameters.&#10;Sparse open models, which use only a small part of their weights for each token, can be much cheaper to serve.&#10;If you specifically need Qwen and use it heavily, self-hosting it pays off.&#10;But you don&amp;rsquo;t need Qwen to get answers of Qwen&amp;rsquo;s quality.&#10;Luna gives you those for $67, with zero data retention.&lt;/p&gt;&#10;&lt;h2 id="11-so-why-do-i-do-it"&gt;11. So why do I do it? &lt;a class="anchor" href="#11-so-why-do-i-do-it" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;First, it is fun.&#10;I like knowing how the whole stack works, from the drivers in &lt;a href="https://www.nijho.lt/post/llama-nixos/"&gt;my NixOS configuration&lt;/a&gt; to how the layers are split between my two GPUs.&lt;/p&gt;&#10;&lt;p&gt;Second, nobody can take it away.&#10;Artificial Analysis already lists GPT-5.6 Luna as deprecated, less than three months after its release.&#10;My Qwen weights will still be on my disk in ten years, and no company can change their license or &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;close their build system&lt;/a&gt; on me.&lt;/p&gt;&#10;&lt;p&gt;Third, some data should not leave my house.&#10;I would never send 200 GB of email, my messages, and my location history to an API, zero data retention or not.&#10;That is what &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;my local AI projects&lt;/a&gt; are for.&lt;/p&gt;&#10;&lt;p&gt;Those are good reasons.&#10;Saving money is not one of them.&lt;/p&gt;&#10;&lt;h2 id="appendix-all-assumptions"&gt;Appendix: all assumptions &lt;a class="anchor" href="#appendix-all-assumptions" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;Show all assumptions, and which side each one favors&lt;/summary&gt;&#10; &lt;p&gt;&lt;strong&gt;Assumptions that favor self-hosting&lt;/strong&gt;&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The host PC around the GPUs is not counted, and my 3090s count at the $750 each I paid, not the $1,500 they sell for today.&lt;/li&gt;&#10;&lt;li&gt;Quantized local copies get the score Artificial Analysis measured through an API, with no penalty for quantization.&lt;/li&gt;&#10;&lt;li&gt;The payback chart assumes each machine is busy 24 hours a day, with no idle time.&lt;/li&gt;&#10;&lt;li&gt;API prices stay where they are, although GPT-6 Luna&amp;rsquo;s output price is 58% lower than GPT-5.6 Luna&amp;rsquo;s, two and a half months earlier.&lt;/li&gt;&#10;&lt;li&gt;My time to set up and maintain the machine is not counted.&lt;/li&gt;&#10;&lt;li&gt;Every machine also gets a case with free electricity.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;&lt;strong&gt;Assumptions that favor the API&lt;/strong&gt;&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The example electricity price is 18 cents per kWh, and some people pay less.&lt;/li&gt;&#10;&lt;li&gt;My machine serves one request at a time; batching several requests could raise its total throughput.&lt;/li&gt;&#10;&lt;li&gt;The hardware has no resale value at the end.&lt;/li&gt;&#10;&lt;li&gt;Waste heat that replaces heating in winter is not counted.&lt;/li&gt;&#10;&lt;li&gt;API outages, rate limits, and deprecated models are not counted.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;&lt;strong&gt;Inputs&lt;/strong&gt;&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Benchmark: the Artificial Analysis Intelligence Index on September 30, 2026, at the xhigh reasoning setting, or the highest setting where noted. Its token counts per model are the unit of work.&lt;/li&gt;&#10;&lt;li&gt;API prices: the cheapest zero-data-retention endpoints on OpenRouter, with cached input billed at the cache price.&lt;/li&gt;&#10;&lt;li&gt;My machine: two RTX 3090s capped at 270 W, running Qwen3.8 27B in vLLM with AutoRound INT4 and DFlash2. Measured: 107 tokens per second, 1,600 tokens per second of prefill, and 540 W for both GPUs. Estimated: about 700 W for the whole machine under load and 150 W idle.&lt;/li&gt;&#10;&lt;li&gt;Break-even: the cards are written off over three years; busy hours save what the API would charge minus the electricity, and idle hours cost the idle power.&lt;/li&gt;&#10;&lt;li&gt;Other machines: prices, power, and speeds as in the footnotes. The M5 Max and RTX PRO 6000 numbers come from readers, and the DGX Spark and AMD Ryzen AI Max+ 395 speeds are estimated from Artificial Analysis measurements.&lt;/li&gt;&#10;&lt;li&gt;Datacenter: AgentPerf measures accelerator power only, and the B200 rental price stands in for the GB300, which has no published rental price.&lt;/li&gt;&#10;&lt;li&gt;Small company: the peak-load week is made up.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;/details&gt;&#10;&#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;For each model, Artificial Analysis publishes how many input and output tokens the whole index took, and how many of the input tokens were read from a cache. I multiplied those by each provider&amp;rsquo;s prices, with cached input at the cache price. For Qwen that is 198 million output tokens and 820 million input tokens that miss the cache. For the bars at home, the same tokens divided by the machine&amp;rsquo;s speed give the run time, with the uncached input processed at the 1,600 tokens per second I measured on my 3090s and an estimated 2,000 on the RTX PRO 6000, and the run time times the machine&amp;rsquo;s power and the price per kWh gives the electricity.&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;li id="fn:2"&gt;&#10;&lt;p&gt;The RTX PRO 6000 has the same memory bandwidth as an RTX 5090, and at full precision every token reads three times as many bytes as in Q4_K_M. Scaling Artificial Analysis&amp;rsquo;s 5090 measurement gives about 48 tokens per second. I assume 600 W for the whole machine.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:3"&gt;&#10;&lt;p&gt;I measured the model as I normally run it: vLLM with an AutoRound INT4 quant and DFlash2 speculative decoding, split across both cards. Single 1,500-token answers came out at 105 to 109 tokens per second, and reading a 22,000-token prompt ran at 1,600 tokens per second. Sending 2, 4, or 8 requests at once did not raise the total above about 110 tokens per second. The two GPUs drew 540 W together, each at its 270 W power limit; the 700 W adds my estimate for the rest of the machine, since I could not measure the CPU. The setup follows the recipes from &lt;a href="https://github.com/noonghunna/club-3090" target="_blank" rel="noopener"&gt;club-3090&lt;/a&gt;, a community project that tunes LLM serving for RTX 3090s and publishes measured numbers, so I think it is about as fast as these cards get. Its benchmarks for this configuration at a similar power limit match mine: about 110 tokens per second for prose, and up to about 195 for code, where speculative decoding guesses more tokens right. Most of Qwen&amp;rsquo;s output on the benchmark is reasoning, which runs at the prose speed.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:4"&gt;&#10;&lt;p&gt;The stock limit is 350 W. My cards sit in a &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;normal desktop case with normal fans&lt;/a&gt;, and I worry that running both at full power for hours would shorten their life. The cap is &lt;a href="https://github.com/basnijholt/dotfiles/blob/e63a3f341ff36b7b57bf31361c2844e1d8b78e95/configs/nixos/hosts/pc/nvidia-undervolt.nix#L1-L7" target="_blank" rel="noopener"&gt;a few lines in my NixOS configuration&lt;/a&gt;, and measurements from Puget Systems and r/LocalLLaMA put a 3090 at about 95% of its speed at 270 W. That is 77% of the power for 95% of the speed, so the cap also lowers my electricity cost per token.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:5"&gt;&#10;&lt;p&gt;The 3090 is still the value king for VRAM per dollar, and $750 badly understates what mine are worth. That is what I paid more than a year ago; today a used 3090 sells for about $1,500. Even at that price it costs $62.50 per GB of VRAM, the same as an RTX 5090 at its $2,000 launch price, which is not what a 5090 sells for today. The right number for this calculation is what I could sell my cards for, and at $1,500 each the break-even points rise by about two thirds: at 18 cents per kWh, the same-model case moves from 2.4 to 4.0 hours per day, and the Luna-efficient case from 8.5 to 14.2 hours per day.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:6"&gt;&#10;&lt;p&gt;While the cards are busy, they save what the API would have charged for the same work, minus the electricity at 700 W. While they are idle, the machine still draws about 150 W, of which the two GPUs with the model loaded take 85 W. The break-even point is the number of busy hours per day at which those savings cover the price of the cards, written off over three years, plus the idle power.&amp;#160;&lt;a href="#fnref:6" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li id="fn:7"&gt;&#10;&lt;p&gt;The M5 Max numbers come from the reader: Qwen3.8-Flash-Next at Q2 (about 2.7 bits per weight), about 45 tokens per second, 1,250 tokens per second of prefill, and about 90 W, on a $7,000 laptop. The RTX PRO 6000 numbers come from another reader, &lt;a href="https://www.reddit.com/r/LocalLLaMA/comments/1ww2jsu/comment/pdhs3ea/" target="_blank" rel="noopener"&gt;u/TheAILegend&lt;/a&gt;, who measured Qwen3.8-Flash-Next at NVFP4 on theirs. For a single request, it stays at about 150 tokens per second from 4,000 to 200,000 tokens of context, with about 10,000 tokens per second of prefill and 266 W for the GPU plus about 160 W for the rest of the PC. With 32 short requests in parallel, the card reaches 590 tokens per second and pays off in about two years, which works for short batch jobs. At the long contexts of this benchmark, parallel requests are slower than one, so the chart uses the single-request speed. I use $15,000, the middle of the card&amp;rsquo;s price range. The DGX Spark and the AMD machine run Qwen3.8-Flash-Next at about 4.5 bits. For their speed, I took Artificial Analysis&amp;rsquo;s measurements of Qwen3.8 27B on each machine and doubled them, because the owner of the AMD machine finds Qwen3.8-Flash-Next about twice as fast as Qwen3.8 27B. That gives about 55 tokens per second on the DGX Spark ($6,950, about 150 W) and 47 on the AMD Ryzen AI Max+ 395 ($4,000, the price Artificial Analysis lists, about 120 W). All of them are compared with GPT-6 Luna on its highest setting, which runs the whole benchmark for $122. The 3090s run Qwen3.8 27B, as in the rest of this post, and are compared with Luna on xhigh. Artificial Analysis measured Qwen3.8 27B on the AMD machine at 23 tokens per second, so one benchmark run takes it 134 days and about 390 kWh, against about 460 kWh on my 3090s. Running Qwen3.8-Flash-Next on the 3090s with CPU expert offload, which club-3090 measured at 37 tokens per second, does not change their result: one run then takes 82 days, and at 18 cents per kWh its electricity costs more than Luna&amp;rsquo;s $122 unless the whole machine draws less than about 350 W.&amp;#160;&lt;a href="#fnref:7" 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>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>Frontier-level dictation on your iPhone keyboard, self-hosted</title><link>https://www.nijho.lt/post/diction-agent-cli-qwen/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/diction-agent-cli-qwen/</guid><description>&lt;p&gt;On a recent month-long trip visiting family in Europe, I did a lot of work from my phone, using &lt;a href="https://www.nijho.lt/post/agentic-mobile-workflow/"&gt;my mobile coding workflow&lt;/a&gt;.&#10;It still boggles my mind that real, productive work from a phone is possible now.&#10;Most of that work is talking to coding agents, and the built-in iOS dictation is garbage for that.&#10;I had an iOS Shortcut that sent recordings to &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;Agent CLI&lt;/a&gt;, &lt;a href="https://www.nijho.lt/post/auto-install-extras/"&gt;my local AI toolbox&lt;/a&gt;, running at home instead.&#10;The problem was that the recording screen takes over the whole display, so I could not see the thing I was commenting on.&#10;The transcript then landed in my clipboard, and I had to paste it myself.&lt;/p&gt;&#10;&lt;p&gt;So I threw my principles overboard and installed &lt;a href="https://wisprflow.ai" target="_blank" rel="noopener"&gt;Wispr Flow&lt;/a&gt;, a voice keyboard for iOS.&#10;I loved it instantly.&#10;iOS does not let keyboards use the microphone, so the keyboard&amp;rsquo;s button hands off to the Wispr Flow app, which keeps recording in the background.&#10;I was less happy about that, since it meant a closed-source app always had the microphone open and sent my voice to their servers.&#10;I was on the free plan, so I was probably the product.&#10;I also hit the free weekly word limit very quickly, and started looking for an alternative.&lt;/p&gt;&#10;&lt;p&gt;That is how I found &lt;a href="https://apps.apple.com/app/id6759807364" target="_blank" rel="noopener"&gt;Diction&lt;/a&gt;, which has since become my favorite app when I&amp;rsquo;m away from my laptop.&#10;It works the same way, background recording included.&#10;The difference is that it has a self-hosted mode with an open-source gateway, so my voice goes to a machine I own instead of their servers.&lt;/p&gt;&#10;&lt;p&gt;I run the &lt;a href="https://github.com/DictionLabs/Diction" target="_blank" rel="noopener"&gt;gateway&lt;/a&gt; on my home machine in the U.S., reachable only over my private network.&#10;The gateway streams audio to Agent CLI, which runs Alibaba&amp;rsquo;s &lt;a href="https://huggingface.co/Qwen/Qwen3-ASR-1.7B-hf" target="_blank" rel="noopener"&gt;&lt;code&gt;Qwen/Qwen3-ASR-1.7B-hf&lt;/code&gt;&lt;/a&gt; on an RTX 3090 behind an OpenAI-compatible transcription endpoint.&#10;There is no LLM cleanup step, so Diction&amp;rsquo;s Writing Style and Tones features do not work with this setup.&#10;I don&amp;rsquo;t miss them: Qwen&amp;rsquo;s raw transcription is already good enough for me.&lt;/p&gt;&#10;&lt;h2 id="1-why-qwen"&gt;1. Why Qwen? &lt;a class="anchor" href="#1-why-qwen" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For the longest time I used &lt;a href="https://github.com/SYSTRAN/faster-whisper" target="_blank" rel="noopener"&gt;Faster-Whisper&lt;/a&gt; with Whisper large-v3.&#10;Whisper is still the default choice for most people, but many newer models beat it.&#10;I tried some of them, including NVIDIA&amp;rsquo;s &lt;a href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3" target="_blank" rel="noopener"&gt;Parakeet&lt;/a&gt;.&#10;Parakeet is fast, but it cannot take custom instructions.&#10;Those instructions are how Diction&amp;rsquo;s &lt;strong&gt;My Words&lt;/strong&gt; feature biases a transcription toward specialized names.&lt;/p&gt;&#10;&lt;p&gt;At the start of September, I looked at the &lt;a href="https://huggingface.co/spaces/hf-audio/open_asr_leaderboard" target="_blank" rel="noopener"&gt;Hugging Face Open ASR Leaderboard&lt;/a&gt;, which tests speech-to-text models on the same recordings and ranks them.&#10;The score is the word error rate (WER): the share of words a model gets wrong, counting words it swaps, drops, or makes up.&#10;Lower is better, and a WER of 4% means about one wrong word in every 25.&#10;The test recordings range from audiobooks and podcasts to meetings and earnings calls, so the average says more than any single clean benchmark.&lt;/p&gt;&#10;&lt;p&gt;These are the averages on the public English test sets as of September 19, 2026:&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;Model&lt;/th&gt;&#10;&lt;th&gt;WER&lt;/th&gt;&#10;&lt;th&gt;Can I run it at home?&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Zoom Scribe v2 Pro&lt;/td&gt;&#10;&lt;td&gt;3.6%&lt;/td&gt;&#10;&lt;td&gt;No, paid API (#1 overall)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;ElevenLabs Scribe v2&lt;/td&gt;&#10;&lt;td&gt;4.0%&lt;/td&gt;&#10;&lt;td&gt;No, paid API&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Qwen3-ASR 1.7B&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;strong&gt;4.3%&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;strong&gt;Yes, Apache-2.0&lt;/strong&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;AssemblyAI Universal-3.5 Pro&lt;/td&gt;&#10;&lt;td&gt;4.3%&lt;/td&gt;&#10;&lt;td&gt;No, paid API&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;NVIDIA Parakeet TDT 0.6B v3&lt;/td&gt;&#10;&lt;td&gt;4.9%&lt;/td&gt;&#10;&lt;td&gt;Yes&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;OpenAI Whisper large-v3&lt;/td&gt;&#10;&lt;td&gt;5.8%&lt;/td&gt;&#10;&lt;td&gt;Yes&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;I picked the best model on the board whose weights you can download: Qwen3-ASR 1.7B.&#10;It lands within a point of the best paid API, which is what I mean by frontier-level.&#10;Since I maintain Agent CLI, I told an agent to add a Qwen backend, and shortly afterward &lt;a href="https://github.com/basnijholt/agent-cli/pull/636" target="_blank" rel="noopener"&gt;it had landed&lt;/a&gt; and was running on my server.&lt;/p&gt;&#10;&lt;p&gt;Agent CLI&amp;rsquo;s transcription server loads a model on the first request and unloads it after an idle timeout, so the model only takes VRAM while I use it.&#10;The same process serves an OpenAI-compatible API, which the Diction gateway talks to, and the &lt;a href="https://www.home-assistant.io/integrations/wyoming/" target="_blank" rel="noopener"&gt;Wyoming protocol&lt;/a&gt; that Home Assistant uses for local voice.&lt;/p&gt;&#10;&lt;h2 id="2-the-recipe"&gt;2. The recipe &lt;a class="anchor" href="#2-the-recipe" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;You need an NVIDIA GPU with the &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html" target="_blank" rel="noopener"&gt;NVIDIA Container Toolkit&lt;/a&gt;, Docker Compose 2.30 or newer, and a private route from your iPhone to the server.&#10;I use Headscale, but Tailscale or plain WireGuard works just as well.&#10;Do not expose the gateway to the public internet.&lt;/p&gt;&#10;&lt;h3 id="write-the-compose-file"&gt;Write the Compose file &lt;a class="anchor" href="#write-the-compose-file" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Save the following as &lt;code&gt;compose.yaml&lt;/code&gt; in an empty directory.&#10;Agent CLI&amp;rsquo;s CI publishes a CUDA image with the Transformers backend and Qwen as the default model, so there is nothing to build.&#10;The file points Diction&amp;rsquo;s gateway at Agent CLI&amp;rsquo;s transcription port.&#10;&lt;code&gt;WHISPER_TTL&lt;/code&gt; raises the idle timeout from the default five minutes to a day, so dictation rarely waits for a reload:&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;asr&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/basnijholt/agent-cli-whisper:0.112.0-cuda-transformers&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;WHISPER_TTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;86400&amp;#34;&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;model-cache:/home/whisper/.cache&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;gpus&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;&#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;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="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;gateway&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;dictionlabs/gateway:v14.0&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;depends_on&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;asr&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;CUSTOM_BACKEND_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://asr:10301&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;CUSTOM_BACKEND_MODEL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Qwen/Qwen3-ASR-1.7B-hf&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;CUSTOM_BACKEND_CANONICAL_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Qwen/Qwen3-ASR-1.7B-hf&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;CUSTOM_BACKEND_NEEDS_WAV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;true&amp;#34;&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="s2"&gt;&amp;#34;8080:8080&amp;#34;&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;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;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="nt"&gt;model-cache&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;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="start-it-and-connect-the-app"&gt;Start it and connect the app &lt;a class="anchor" href="#start-it-and-connect-the-app" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Start both services:&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;docker compose up -d&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first transcription downloads Qwen into the &lt;code&gt;model-cache&lt;/code&gt; volume, so that request can take a few minutes.&#10;&lt;code&gt;docker compose logs -f&lt;/code&gt; shows the progress.&lt;/p&gt;&#10;&lt;p&gt;On the iPhone, go to &lt;strong&gt;Settings → General → Keyboard → Keyboards → Add New Keyboard → Diction&lt;/strong&gt;.&#10;Tap Diction in the keyboard list, enable &lt;strong&gt;Allow Full Access&lt;/strong&gt;, and grant microphone access when prompted.&#10;Then open Diction, go to &lt;strong&gt;Preferences → Mode → Self-Hosted&lt;/strong&gt;, enter &lt;code&gt;http://&amp;lt;private-server-ip&amp;gt;:8080&lt;/code&gt;, and tap &lt;strong&gt;Test connection&lt;/strong&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;strong&gt;A small upstream detour:&lt;/strong&gt; Diction&amp;rsquo;s &lt;strong&gt;My Words&lt;/strong&gt; feature was not reaching the ASR backend during streaming, so I opened &lt;a href="https://github.com/DictionLabs/Diction/pull/20" target="_blank" rel="noopener"&gt;Diction PR #20&lt;/a&gt; to forward those words as the transcription prompt.&#10;With the fix, package names such as &lt;code&gt;PipeFunc&lt;/code&gt; and &lt;code&gt;MindRoom&lt;/code&gt; come back with the correct spelling and capitalization.&#10;It shipped in gateway v13.0, which is why the Compose file needs at least that version.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;p&gt;On my machine, Qwen3-ASR 1.7B uses about 4.7 GiB of GPU memory and roughly 3 GiB of system memory while loaded.&lt;/p&gt;&#10;&lt;h2 id="3-the-bar-test"&gt;3. The bar test &lt;a class="anchor" href="#3-the-bar-test" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;A day later, I met a friend in a bar in the Netherlands and wanted to let him try it.&lt;/p&gt;&#10;&lt;p&gt;He was already on my tailnet for some of my other self-hosted services, so his phone only needed an ACL change to reach the gateway.&#10;I opened the terminal app on my phone, connected to an agent, and dictated that change.&#10;In less than a minute, Diction was connected and working on his phone.&#10;His speech crossed the Atlantic, was transcribed by Qwen on my home GPU, and appeared back in the app with barely any delay.&lt;/p&gt;&#10;&lt;p&gt;That moment sold the whole setup to me.&#10;I was in a bar without a laptop, and I used dictation to grant access to the dictation service.&lt;/p&gt;&#10;</description></item><item><title>MindRoom: AI agents that live in Matrix and work everywhere 🧠</title><link>https://www.nijho.lt/post/mindroom/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/mindroom/</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-the-problem-your-ai-is-trapped-in-apps"&gt;1. The problem: your AI is trapped in apps&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-what-is-mindroom"&gt;2. What is MindRoom?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-how-it-actually-works"&gt;3. How it actually works&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-building-on-matrix-the-good-and-the-tricky"&gt;4. Building on Matrix: the good and the tricky&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#5-memory-that-follows-you"&gt;5. Memory that follows you&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#6-100-built-in-tool-integrations"&gt;6. 100+ built-in tool integrations&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#7-teams-agents-that-collaborate"&gt;7. Teams: agents that collaborate&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#8-hot-reload-change-config-and-plugins-without-downtime"&gt;8. Hot-reload: change config and plugins without downtime&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#9-voice-scheduling-and-other-features"&gt;9. Voice, scheduling, and other features&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#10-the-obsession-the-saas-dream-and-the-burnout"&gt;10. The obsession, the SaaS dream, and the burnout&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#11-openclaw-and-why-it-matters"&gt;11. OpenClaw and why it matters&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#12-whats-next"&gt;12. What&amp;rsquo;s next&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#links-and-resources"&gt;Links and resources&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="1-the-problem-your-ai-is-trapped-in-apps"&gt;1. The problem: your AI is trapped in apps &lt;a class="anchor" href="#1-the-problem-your-ai-is-trapped-in-apps" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Programming is my biggest passion in life.&#10;I&amp;rsquo;ve been actively involved in open source for over ten years now, maintaining &lt;a href="https://github.com/basnijholt" target="_blank" rel="noopener"&gt;40+ installable packages&lt;/a&gt; across Python, JavaScript, and Rust.&#10;Some of these have over 100,000 users; others have exactly one—me.&#10;I mention this because it matters for what comes next: when AI coding tools arrived, I didn&amp;rsquo;t start from zero.&#10;As I wrote about in my &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;agentic coding post&lt;/a&gt;, AI has completely transformed how I work—when I&amp;rsquo;m AFK, I regularly build full features or entire projects by &lt;a href="https://www.nijho.lt/post/agentic-mobile-workflow/"&gt;dictating to my phone&lt;/a&gt; and routing it through my own &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;transcription software&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Through all of this, I became deeply convinced that AI agents are the future.&#10;Not just a useful tool for programmers—a fundamental shift in how &lt;em&gt;everyone&lt;/em&gt; will interact with computers.&lt;/p&gt;&#10;&lt;p&gt;But if that&amp;rsquo;s true, then the way agents work today is broken.&lt;/p&gt;&#10;&lt;p&gt;We&amp;rsquo;ve built artificial barriers between artificial intelligences.&#10;ChatGPT knows you in one tab, Claude knows you in another, your Slack bot knows you in a third.&#10;Your email AI can&amp;rsquo;t tell your calendar AI about that meeting invitation.&#10;Your coding assistant has no idea about the project specs sitting in your document AI.&#10;Switch platforms, and your AI starts from scratch.&lt;/p&gt;&#10;&lt;p&gt;Most agent frameworks require you to &lt;em&gt;program&lt;/em&gt; them.&#10;That works for developers like me, but if agents are truly the future, they need to work for everyone.&#10;Non-programmers don&amp;rsquo;t want to write code—they just want a chat.&lt;/p&gt;&#10;&lt;p&gt;And then there&amp;rsquo;s privacy.&#10;I&amp;rsquo;m fine sharing my email with Gemini—Google already owns my Gmail.&#10;But do I want to send my financial data, my health records, or my personal notes to a cloud provider?&#10;Not really.&#10;I&amp;rsquo;d rather use a local model for sensitive tasks and pick the best, cheapest Chinese model for general-purpose deep research.&lt;/p&gt;&#10;&lt;p&gt;So I built &lt;strong&gt;MindRoom&lt;/strong&gt; on a chat protocol—if people just want a chat, give them a chat.&#10;Agents live in the same rooms as you, collaborate in threads, and follow you across every platform.&#10;The first commit landed on July 29, 2025 (four months before OpenClaw&amp;rsquo;s first commit 😅), so by the time I&amp;rsquo;m publishing this MindRoom has been with me for nearly eleven months: first as an obsessive side project, then, since March, with much more sustained focus.&#10;At first I got so obsessed with it that I eventually burned out and had to step away completely.&lt;/p&gt;&#10;&lt;p&gt;Recently, seeing &lt;a href="https://openclaw.ai" target="_blank" rel="noopener"&gt;OpenClaw&lt;/a&gt; gain massive traction solving a similar problem reminded me that what I&amp;rsquo;d been building wasn&amp;rsquo;t just a niche obsession.&#10;So here I am, dusting off MindRoom and writing about what it is, how it works, and why I think the approach still matters.&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; MindRoom is an open-source system that creates AI agents living inside the &lt;a href="https://matrix.org/" target="_blank" rel="noopener"&gt;Matrix protocol&lt;/a&gt;.&#10;Because Matrix has bridges to Slack, Telegram, Discord, WhatsApp, and everything else, your agents can follow you across every platform—with persistent memory, 100+ built-in tool integrations, and multi-agent collaboration.&#10;Think of it as &amp;ldquo;your AI assistant, but it lives in Matrix and works everywhere.&amp;rdquo;&lt;/div&gt;&lt;/aside&gt;&#10;&lt;figure id="figure-live-chatmindroomchat-personal-thread-with-the-prompt-explain-how-mindroom-and-what-you-can-do-mind-answers-in-the-real-matrix-room-using-tool-calls-and-the-codex-backed-local-mindroom-runtime"&gt;&lt;img src="https://www.nijho.lt/post/mindroom/mindroom-in-action_hu_5c1001a61c1ce0a1.webp" srcset="https://www.nijho.lt/post/mindroom/mindroom-in-action_hu_cba228417596d894.webp 400w, https://www.nijho.lt/post/mindroom/mindroom-in-action_hu_5c1001a61c1ce0a1.webp 760w, https://www.nijho.lt/post/mindroom/mindroom-in-action_hu_46631ca52a25e910.webp 1200w" sizes="auto, (max-width: 760px) 100vw, 720px" width="760" height="489" alt="Live MindRoom Personal thread showing the prompt Explain how MindRoom and what you can do, followed by Mind explaining Matrix agents, tools, memory, and skills." loading="lazy" data-zoomable&gt;&lt;figcaption&gt;Live chat.mindroom.chat Personal thread with the prompt: “Explain how MindRoom and what you can do.” Mind answers in the real Matrix room, using tool calls and the Codex-backed local MindRoom runtime.&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;h2 id="2-what-is-mindroom"&gt;2. What is MindRoom? &lt;a class="anchor" href="#2-what-is-mindroom" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;MindRoom is an open-source system that creates AI agents living inside the &lt;a href="https://matrix.org/" target="_blank" rel="noopener"&gt;Matrix protocol&lt;/a&gt;.&#10;If you&amp;rsquo;re not familiar with Matrix—it&amp;rsquo;s a federated, end-to-end encrypted communication standard.&#10;The same protocol used by the French government for 5.5 million civil servants, by German healthcare for 150K+ organizations, and by the Element app that millions of people use daily.&lt;/p&gt;&#10;&lt;p&gt;The key insight: Matrix has &lt;a href="https://matrix.org/ecosystem/bridges/" target="_blank" rel="noopener"&gt;bridges&lt;/a&gt; to many major platforms—Slack, Telegram, Discord, WhatsApp, IRC, email, even SMS.&lt;/p&gt;&#10;&lt;p&gt;So if your AI agent lives in Matrix, it can reach you on any platform.&#10;One agent, every platform, continuous memory.&lt;/p&gt;&#10;&lt;p&gt;One practical caveat: Matrix bridges vary in maturity. Some, like the &lt;a href="https://github.com/mautrix/telegram" target="_blank" rel="noopener"&gt;Telegram bridge&lt;/a&gt;, work very well, while others can be finicky. Your mileage may vary depending on which platforms you need.&lt;/p&gt;&#10;&lt;p&gt;Here&amp;rsquo;s what a typical setup looks like in &lt;code&gt;config.yaml&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;agents&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;code&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;display_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;CodeAgent&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Generate code, manage files, execute shell commands&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;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;opus-4.8&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;tools&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="l"&gt;file, shell, github]&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;instructions&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;Always read files before modifying 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="nt"&gt;rooms&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="l"&gt;lobby, dev]&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="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;research&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;display_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;ResearchAgent&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Search the web, summarize papers, find information&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;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gpt-5.5&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;tools&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="l"&gt;tavily, arxiv, wikipedia]&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;rooms&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="l"&gt;lobby, research]&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;teams&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;super_team&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;display_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;Super Team&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;agents&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="l"&gt;code, research]&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;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;collaborate&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;Define your agents, give them tools and rooms, and they show up in Matrix as real users—with avatars, typing indicators, and online status.&lt;/p&gt;&#10;&lt;figure id="figure-mindroom-agents-are-real-matrix-users-with-their-own-accounts-generated-avatars-display-names-room-memberships-and-online-presence"&gt;&lt;img src="https://www.nijho.lt/post/mindroom/mindroom-members_hu_5090cd21f23bad91.webp" srcset="https://www.nijho.lt/post/mindroom/mindroom-members_hu_fbc884b2330f0fe3.webp 400w, https://www.nijho.lt/post/mindroom/mindroom-members_hu_5090cd21f23bad91.webp 760w, https://www.nijho.lt/post/mindroom/mindroom-members_hu_e6a566cfcdf2d915.webp 1200w" sizes="auto, (max-width: 760px) 100vw, 720px" width="760" height="489" alt="Live MindRoom room showing RouterAgent, Mind, and Bas as Matrix users with generated avatars." loading="lazy" data-zoomable&gt;&lt;figcaption&gt;MindRoom agents are real Matrix users with their own accounts, generated avatars, display names, room memberships, and online presence.&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;h2 id="3-how-it-actually-works"&gt;3. How it actually works &lt;a class="anchor" href="#3-how-it-actually-works" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;At the core sits what I call the &lt;strong&gt;MultiAgentOrchestrator&lt;/strong&gt;—a class in &lt;code&gt;bot.py&lt;/code&gt; that boots every configured entity (router, agents, teams), provisions Matrix user accounts for each one via &lt;a href="https://github.com/matrix-nio/matrix-nio" target="_blank" rel="noopener"&gt;matrix-nio&lt;/a&gt;, and keeps sync loops alive.&#10;The agents themselves are powered by the &lt;a href="https://github.com/agno-agi/agno" target="_blank" rel="noopener"&gt;Agno&lt;/a&gt; framework, which provides a unified interface across AI model providers.&lt;/p&gt;&#10;&lt;p&gt;When someone sends a message in a Matrix room:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Explicit mentions&lt;/strong&gt; go directly to the named agent (&lt;code&gt;@mindroom_code help me debug this&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Thread continuations&lt;/strong&gt; keep the same agent responding (no weird context switches mid-conversation)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;New conversations&lt;/strong&gt; hit the &lt;strong&gt;router&lt;/strong&gt;—an AI that analyzes the message and picks the best agent based on capabilities&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;All conversations happen in threads, which keeps rooms organized.&#10;Agents stream their responses in real-time, editing a single message as they think rather than spamming new ones.&#10;You see tool calls happening live:&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;🔧 Tool Call: search_web(query=&amp;#34;matrix protocol bridges&amp;#34;)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[waiting for result...]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✅ search_web result:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[results here]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The recording below starts in the main Personal room, sends the prompt &lt;code&gt;Explain how MindRoom and what you can do.&lt;/code&gt;, and then follows MindRoom as it opens the new thread and streams the reply to completion. It is sped up 2.5x to keep the flow skimmable.&lt;/p&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/reduced-motion.3dfea0061c17c60a9a200c61ef1e3b3e98d5bdc4eaa4e0a64b3ed2a4d13c72fd.js" integrity="sha256-Pf6gBhwXxgqaIAxh7x47PpjVvcTqpOCmSz7SpNE8cv0=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&lt;video controls autoplay muted playsinline loop preload="metadata"&gt;&#10;&lt;source src="https://www.nijho.lt/post/mindroom/mindroom-agent-response.mp4" type="video/mp4"&gt;&#10;&lt;/video&gt;&#10;&#10;&lt;h2 id="4-building-on-matrix-the-good-and-the-tricky"&gt;4. Building on Matrix: the good and the tricky &lt;a class="anchor" href="#4-building-on-matrix-the-good-and-the-tricky" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;One of the best things about building on Matrix is what you get for free.&#10;End-to-end encryption, a deeply interoperable chat protocol with bridges to many platforms, a choice of clients, federation between organizations—all of that comes with the protocol.&#10;You don&amp;rsquo;t have to build any of it yourself.&lt;/p&gt;&#10;&lt;p&gt;But because Matrix has such a tight specification, it also brings challenges.&lt;/p&gt;&#10;&lt;p&gt;The protocol doesn&amp;rsquo;t support streaming.&#10;AI agents that think for 30 seconds before dumping a wall of text make for a terrible chat experience, so I hacked streaming in by rapidly editing the same message as new tokens arrive.&#10;An &lt;code&gt;⋯&lt;/code&gt; marker shows while the agent is still thinking—a small touch, but it makes the experience feel responsive and alive.&lt;/p&gt;&#10;&lt;p&gt;There&amp;rsquo;s also a size limit on message content.&#10;That&amp;rsquo;s fine for human chat, but AI responses can get long—especially when tool calls and their results are included.&#10;I worked around this by using Matrix&amp;rsquo;s attachment feature: when a response exceeds the limit, the content continues in an attachment that gets updated as the message keeps streaming in.&#10;This required forking the &lt;a href="https://cinny.in/" target="_blank" rel="noopener"&gt;Cinny&lt;/a&gt; chat client so that attachments display inline rather than as downloadable files, making the whole thing seamless.&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;These workarounds are the kind of thing you don&amp;rsquo;t anticipate when you pick a protocol.&#10;Matrix gives you an incredible foundation, but making it work well for AI required bending it in ways it wasn&amp;rsquo;t designed for.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="5-memory-that-follows-you"&gt;5. Memory that follows you &lt;a class="anchor" href="#5-memory-that-follows-you" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;MindRoom currently supports two memory implementations.&lt;/p&gt;&#10;&lt;p&gt;The first is the more traditional semantic memory system built on &lt;a href="https://mem0.ai/" target="_blank" rel="noopener"&gt;Mem0&lt;/a&gt;&amp;rsquo;s &lt;code&gt;AsyncMemory&lt;/code&gt;, with configurable embedding providers (&lt;a href="https://platform.openai.com/docs/guides/embeddings" target="_blank" rel="noopener"&gt;OpenAI&lt;/a&gt;, &lt;a href="https://ollama.com/" target="_blank" rel="noopener"&gt;Ollama&lt;/a&gt;, or HuggingFace) and vector storage via &lt;a href="https://www.trychroma.com/" target="_blank" rel="noopener"&gt;ChromaDB&lt;/a&gt;.&#10;This gives agents searchable memory for preferences, project context, decisions, and recurring facts.&lt;/p&gt;&#10;&lt;p&gt;The second is a file-based memory system inspired by the simplicity of OpenClaw-style agent files.&#10;Instead of burying identity in a database, durable context can live in plain Markdown files that travel with the agent.&#10;That portability matters: I can move the same agent identity from OpenClaw to Hermes to MindRoom without starting from scratch.&lt;/p&gt;&#10;&lt;p&gt;MindRoom also has a memory flush step that periodically extracts durable facts from conversations and writes them back into memory.&#10;So the chat transcript remains chat, while stable preferences, decisions, and identity details become reusable context for future turns.&lt;/p&gt;&#10;&lt;figure id="figure-a-live-personal-memory-demo-mindroom-stores-a-harmless-preference-in-one-turn-and-retrieves-it-later-from-the-same-matrix-conversation"&gt;&lt;img src="https://www.nijho.lt/post/mindroom/mindroom-memory_hu_5090cd21f23bad91.webp" srcset="https://www.nijho.lt/post/mindroom/mindroom-memory_hu_fbc884b2330f0fe3.webp 400w, https://www.nijho.lt/post/mindroom/mindroom-memory_hu_5090cd21f23bad91.webp 760w, https://www.nijho.lt/post/mindroom/mindroom-memory_hu_e6a566cfcdf2d915.webp 1200w" sizes="auto, (max-width: 760px) 100vw, 720px" width="760" height="489" alt="Live MindRoom memory thread showing the generated agent avatars and a public demo where the agent remembers teal as the blog demo accent color." loading="lazy" data-zoomable&gt;&lt;figcaption&gt;A live Personal memory demo: MindRoom stores a harmless preference in one turn and retrieves it later from the same Matrix conversation.&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;h2 id="6-100-built-in-tool-integrations"&gt;6. 100+ built-in tool integrations &lt;a class="anchor" href="#6-100-built-in-tool-integrations" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Agents can use over 100 built-in integrations:&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;Category&lt;/th&gt;&#10;&lt;th&gt;Examples&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;Communication&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;Gmail, Slack, Telegram, Discord&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Development&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;GitHub, Shell, Python, Docker&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Search&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;Tavily, Wikipedia, Arxiv, DuckDuckGo&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Productivity&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;Google Calendar, Jira, Linear, Todoist&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Smart Home&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;Home Assistant&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;AI/ML&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;DALL-E, ElevenLabs, Replicate&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Data&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;Pandas, SQL, DuckDB, Yahoo Finance&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Web&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;Firecrawl, Crawl4ai, Browser automation&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Tools are lazy-loaded and credential-managed, so an agent only loads what it needs.&lt;/p&gt;&#10;&lt;h2 id="7-teams-agents-that-collaborate"&gt;7. Teams: agents that collaborate &lt;a class="anchor" href="#7-teams-agents-that-collaborate" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Single agents are useful, but sometimes you need a team.&#10;MindRoom supports two collaboration modes:&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Coordinate mode&lt;/strong&gt;: A lead agent orchestrates others.&#10;You ask a question, the lead delegates subtasks, collects results, and synthesizes a unified response.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Collaborate mode&lt;/strong&gt;: All agents work on the same task in parallel, each providing their independent analysis.&#10;The system then merges their responses with a consensus summary.&lt;/p&gt;&#10;&lt;p&gt;In practice, you might have a research team where one agent searches academic papers, another checks industry news, and a third validates claims—all triggered by a single message.&#10;The live room view below shows a real team response in the same Matrix thread surface normal agents use: threads, routed agents, and room history are all first-class chat objects rather than a separate dashboard.&lt;/p&gt;&#10;&lt;figure id="figure-live-lobby-thread-in-chatmindroomchat-showing-a-real-team-response-from-homeassistant-and-agentbuilder-team-collaboration-uses-the-same-matrix-native-interface-as-regular-agent-chats-with-routed-agents-and-room-membership-visible-beside-the-conversation"&gt;&lt;img src="https://www.nijho.lt/post/mindroom/mindroom-team_hu_afbbb6ff5ac54e3f.webp" srcset="https://www.nijho.lt/post/mindroom/mindroom-team_hu_bf973e9c13f8cf6.webp 400w, https://www.nijho.lt/post/mindroom/mindroom-team_hu_afbbb6ff5ac54e3f.webp 760w, https://www.nijho.lt/post/mindroom/mindroom-team_hu_ee227208566f8d1.webp 1200w" sizes="auto, (max-width: 760px) 100vw, 720px" width="760" height="533" alt="Live MindRoom Lobby thread showing a Team Response from HomeAssistant and AgentBuilder, with the member list showing Super Team and other agents." loading="lazy" data-zoomable&gt;&lt;figcaption&gt;Live Lobby thread in chat.mindroom.chat showing a real Team Response from HomeAssistant and AgentBuilder. Team collaboration uses the same Matrix-native interface as regular agent chats, with routed agents and room membership visible beside the conversation.&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;h2 id="8-hot-reload-change-config-and-plugins-without-downtime"&gt;8. Hot-reload: change config and plugins without downtime &lt;a class="anchor" href="#8-hot-reload-change-config-and-plugins-without-downtime" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;config.yaml&lt;/code&gt; is watched at runtime.&#10;When you edit it—add an agent, change a model, update instructions—MindRoom diffs the old and new config, gracefully restarts only the affected agents, and has them rejoin their rooms.&#10;No downtime, no full runtime restart required.&lt;/p&gt;&#10;&lt;p&gt;The same development loop applies to plugins.&#10;MindRoom has a rich Python hook system, and plugins can be live-developed while the system is running.&#10;Change a plugin file, save it, and MindRoom automatically reloads the plugin without restarting the Python runtime.&lt;/p&gt;&#10;&lt;p&gt;This sounds minor, but when you&amp;rsquo;re iterating on agent behavior or plugin hooks, being able to tweak code and config and see results in seconds significantly improves the development loop.&lt;/p&gt;&#10;&#10;&lt;script type="module" src="https://www.nijho.lt/js/reduced-motion.3dfea0061c17c60a9a200c61ef1e3b3e98d5bdc4eaa4e0a64b3ed2a4d13c72fd.js" integrity="sha256-Pf6gBhwXxgqaIAxh7x47PpjVvcTqpOCmSz7SpNE8cv0=" crossorigin="anonymous"&gt;&lt;/script&gt;&#10;&lt;video controls autoplay muted playsinline loop preload="metadata"&gt;&#10;&lt;source src="https://www.nijho.lt/post/mindroom/mindroom-hot-reload.mp4" type="video/mp4"&gt;&#10;&lt;/video&gt;&#10;&#10;&lt;h2 id="9-voice-scheduling-and-other-features"&gt;9. Voice, scheduling, and other features &lt;a class="anchor" href="#9-voice-scheduling-and-other-features" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Some features I built purely because I wanted them for myself (a recurring theme in my projects, as anyone who&amp;rsquo;s read my &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;local AI journey&lt;/a&gt; can attest).&#10;I even built &lt;a href="https://github.com/basnijholt/matty" target="_blank" rel="noopener"&gt;Matty&lt;/a&gt;, a Matrix CLI client, from bed at midnight because I needed a way to interact with my agents from the terminal:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Voice messages&lt;/strong&gt;: Audio messages in Matrix are auto-transcribed via Whisper and treated as regular text input.&#10;You can talk to your agents.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Scheduled tasks&lt;/strong&gt;: Natural language scheduling (&lt;code&gt;!schedule &amp;quot;check my email every morning at 9 AM&amp;quot;&lt;/code&gt;) backed by cron jobs.&#10;Agents can run tasks in the background and escalate to you when needed.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;DM support&lt;/strong&gt;: Agents respond naturally in 1:1 conversations without needing mentions.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Cross-organization federation&lt;/strong&gt;: Because Matrix is federated, two companies&amp;rsquo; AI agents can collaborate in a shared room—something that&amp;rsquo;s hard to do on proprietary platforms.&lt;/li&gt;&#10;&lt;/ul&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 voice feature pairs nicely with my &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; tool—local Whisper transcription on my RTX 3090 means I can talk to my Matrix agents without any cloud dependency.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="10-the-obsession-the-saas-dream-and-the-burnout"&gt;10. The obsession, the SaaS dream, and the burnout &lt;a class="anchor" href="#10-the-obsession-the-saas-dream-and-the-burnout" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I need to be honest about what actually happened with MindRoom, because &amp;ldquo;life got in the way&amp;rdquo; is a sanitized version of the story.&lt;/p&gt;&#10;&lt;p&gt;What really happened is that I got completely, utterly obsessed.&#10;Every single second I wasn&amp;rsquo;t working at my day job or sleeping, I was working on MindRoom.&#10;Many hundreds of hours went into it.&#10;I seriously considered quitting my job and starting an AI startup around it.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;ve read Armin Ronacher&amp;rsquo;s post &lt;a href="https://lucumr.pocoo.org/2026/1/18/agent-psychosis/" target="_blank" rel="noopener"&gt;Agent Psychosis: Are We Going Insane?&lt;/a&gt;, you&amp;rsquo;ll know exactly what I&amp;rsquo;m talking about.&#10;The dopamine hit from building with AI agents is incredibly real.&#10;As Armin writes: &amp;ldquo;You feel productive, you feel like everything is amazing, and if you hang out just with people that are into that stuff too, without any checks, you go deeper and deeper into the belief that this all makes perfect sense.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;That was me.&#10;I was building and building, shipping feature after feature, and it felt incredible.&#10;The codebase grew to over 1,000 commits.&#10;I built the core system, then a React dashboard, then I started on a full SaaS platform—Kubernetes deployments on Hetzner Cloud, a FastAPI backend, a Next.js 15 frontend, Stripe integration, Supabase auth, Helm charts for multi-tenant isolation.&lt;/p&gt;&#10;&lt;p&gt;And that&amp;rsquo;s where the enthusiasm started to erode.&#10;Not because the core idea was bad, but because the work shifted from building interesting things (agent orchestration, memory systems, routing intelligence) to grinding through SaaS boilerplate: GDPR compliance, payment processing, automated Kubernetes deployments, terms of service, cookie banners.&#10;The dopamine loop that kept me going at 2 AM broke when the work stopped being creative and started being compliance paperwork.&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;Looking back, I think this is a pattern worth recognizing.&#10;Agentic coding makes it so easy to &lt;em&gt;start&lt;/em&gt; things that you can build yourself into a scope that would normally require a team.&#10;The gap between &amp;ldquo;I can build this&amp;rdquo; and &amp;ldquo;I can maintain and ship this as a product&amp;rdquo; is larger than the tools make it feel.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;p&gt;The hosted control plane lives at &lt;a href="https://app.mindroom.chat" target="_blank" rel="noopener"&gt;app.mindroom.chat&lt;/a&gt;.&#10;I am deliberately not turning this section into a dashboard tour; the interesting part, and the part I still care about most, is the Matrix-native runtime underneath it.&lt;/p&gt;&#10;&lt;h2 id="11-openclaw-and-why-it-matters"&gt;11. OpenClaw and why it matters &lt;a class="anchor" href="#11-openclaw-and-why-it-matters" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://openclaw.ai" target="_blank" rel="noopener"&gt;OpenClaw&lt;/a&gt; takes a different approach to the same core problem.&#10;Where MindRoom builds on Matrix federation as the backbone, OpenClaw runs a local Gateway on your machine and connects to messaging platforms directly (WhatsApp via Baileys, Telegram via grammY, Discord via discord.js, and so on).&lt;/p&gt;&#10;&lt;p&gt;The similarities are striking:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Both give you a single AI assistant across all your messaging platforms&lt;/li&gt;&#10;&lt;li&gt;Both have persistent memory&lt;/li&gt;&#10;&lt;li&gt;Both support multiple tools and integrations&lt;/li&gt;&#10;&lt;li&gt;Both are open-source and privacy-focused&lt;/li&gt;&#10;&lt;li&gt;Both let you choose your own AI model&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The differences are interesting too.&#10;OpenClaw is local-first and TypeScript-based with 380K+ stars and a massive community.&#10;MindRoom is Python-based, federation-first, and&amp;hellip; well, let&amp;rsquo;s just say my GitHub star count is a few orders of magnitude lower.&lt;/p&gt;&#10;&lt;p&gt;But seeing OpenClaw validate the core idea—that people want AI assistants that aren&amp;rsquo;t trapped in apps—reminded me that what I&amp;rsquo;d been building matters.&#10;The federation angle is something OpenClaw doesn&amp;rsquo;t have: the ability for agents from different organizations to collaborate natively, with end-to-end encryption (Olm/Megolm), on a protocol that governments already trust and deploy at scale.&lt;/p&gt;&#10;&lt;figure id="figure-mindroom-uses-matrix-as-the-federated-backbone-openclaw-takes-a-local-gateway-direct-connector-approach"&gt;&lt;img src="https://www.nijho.lt/post/mindroom/mindroom-architecture_hu_aaf71cef609b8340.webp" srcset="https://www.nijho.lt/post/mindroom/mindroom-architecture_hu_2cca5883511aed87.webp 400w, https://www.nijho.lt/post/mindroom/mindroom-architecture_hu_aaf71cef609b8340.webp 760w, https://www.nijho.lt/post/mindroom/mindroom-architecture_hu_afb78d90679bf4a4.webp 1200w" sizes="auto, (max-width: 760px) 100vw, 720px" width="760" height="507" alt="Architecture diagram comparing MindRoom and OpenClaw. MindRoom flows from user to Matrix room to federated homeserver to bridges to messaging platforms. OpenClaw flows from user to local gateway to direct connectors to platform APIs." loading="lazy" data-zoomable&gt;&lt;figcaption&gt;MindRoom uses Matrix as the federated backbone; OpenClaw takes a local-gateway, direct-connector approach.&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;p&gt;And then there&amp;rsquo;s &lt;a href="https://moltbook.com" target="_blank" rel="noopener"&gt;Moltbook&lt;/a&gt;—a social network built exclusively for AI agents that the internet was losing its mind over for a couple of days.&#10;I&amp;rsquo;m not personally convinced it&amp;rsquo;s the next big thing, but what caught my attention is that MindRoom could support something like it &lt;em&gt;natively&lt;/em&gt;.&#10;Matrix is already federated—agents on different servers can already interact, join shared rooms, and collaborate across organizational boundaries.&#10;A bridge from a Reddit-like platform to Matrix and your agents could participate without any special integration.&lt;/p&gt;&#10;&lt;p&gt;Building agent-to-agent infrastructure from scratch also means building security from scratch—and that&amp;rsquo;s where new platforms tend to struggle.&#10;Matrix has spent years hardening its E2E encryption, and it&amp;rsquo;s already deployed by governments and healthcare organizations at scale.&#10;Building on that foundation means you inherit those security properties rather than hoping to get them right yourself.&lt;/p&gt;&#10;&lt;h2 id="12-whats-next"&gt;12. What&amp;rsquo;s next &lt;a class="anchor" href="#12-whats-next" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I first drafted this post a couple of months ago, when MindRoom was still something I was trying to come back to.&#10;That part is already out of date.&#10;Since January I&amp;rsquo;ve been spending nearly every free hour on it again, and since March it has effectively been my full-time work.&#10;The codebase supports 8+ AI model providers—OpenAI, Anthropic, Ollama, Groq, Google, OpenRouter, DeepSeek, Cerebras—and the core architecture is solid.&#10;It also has real users now.&#10;My rough guess is that something like 30-100 people have tried MindRoom in one form or another, which is still early, but meaningfully different from &amp;ldquo;only I have touched this.&amp;rdquo;&#10;There&amp;rsquo;s already a Docker Compose file and a &lt;a href="https://github.com/mindroom-ai/mindroom-stack" target="_blank" rel="noopener"&gt;starter repository&lt;/a&gt; to get started, but I want to make it even simpler.&lt;/p&gt;&#10;&lt;p&gt;What I&amp;rsquo;m focusing on now:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Skills system&lt;/strong&gt;: Building an ecosystem of reusable agent behaviors (already partially implemented with OpenClaw-compatible format)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Even easier onboarding&lt;/strong&gt;: A &lt;code&gt;docker compose up&lt;/code&gt; gets you running today, but I want a wizard that gets you from zero to working agents without touching a YAML file&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Community&lt;/strong&gt;: Turning the small group of early users into a real operator and contributor community&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Scope discipline&lt;/strong&gt;: Keeping the work sustainable now that MindRoom is effectively full-time, not slipping back into the obsessive 2 AM marathon from before&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I&amp;rsquo;m deliberately leaving the SaaS ambitions aside for now.&#10;The core system is what matters, and that&amp;rsquo;s where the energy is going.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re interested in AI agents that live in Matrix and work everywhere, check out &lt;a href="https://github.com/mindroom-ai/mindroom" target="_blank" rel="noopener"&gt;MindRoom on GitHub&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Sometimes seeing someone else succeed with a similar idea is enough to remember why you started building in the first place.&#10;And sometimes the most important lesson from a project isn&amp;rsquo;t technical—it&amp;rsquo;s learning when to step away and when to come back.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;Are you running AI agents on any messaging platforms? Have you tried Matrix for anything beyond regular chat? I&amp;rsquo;d love to hear about your setups!&lt;/em&gt;&lt;/p&gt;&#10;&lt;h2 id="links-and-resources"&gt;Links and resources &lt;a class="anchor" href="#links-and-resources" 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/mindroom-ai/mindroom" target="_blank" rel="noopener"&gt;MindRoom on GitHub&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/mindroom-ai/mindroom-stack" target="_blank" rel="noopener"&gt;MindRoom Stack (starter repository)&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://matrix.org/" target="_blank" rel="noopener"&gt;Matrix protocol&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://openclaw.ai" target="_blank" rel="noopener"&gt;OpenClaw&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://mem0.ai/" target="_blank" rel="noopener"&gt;Mem0 (memory system inspiration)&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/agno-agi/agno" target="_blank" rel="noopener"&gt;Agno framework&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/matty" target="_blank" rel="noopener"&gt;Matty (Matrix CLI client)&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://lucumr.pocoo.org/2026/1/18/agent-psychosis/" target="_blank" rel="noopener"&gt;Agent Psychosis: Are We Going Insane?&lt;/a&gt; by Armin Ronacher&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;My agentic coding post&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;My local AI journey&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/agentic-mobile-workflow/"&gt;Coding from my phone&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>My LLM workflow: local frontends, (mostly) remote models 💻</title><link>https://www.nijho.lt/post/using-llms/</link><pubDate>Fri, 07 Mar 2025 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/using-llms/</guid><description>&lt;h1 id="how-i-use-llms-in-my-daily-workflow"&gt;How I Use LLMs in My Daily Workflow&lt;/h1&gt;&#10;&lt;p&gt;Over the past 1.5 years, I&amp;rsquo;ve developed a workflow for integrating Large Language Models into my daily life, particularly for open source software development.&#10;I wanted to share my setup and recent discoveries that have improved my experience.&#10;Setting it up takes a little technical know-how, but once it&amp;rsquo;s up and running, using it is easy, my wife even uses 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;tl;dr: I use &lt;a href="https://www.librechat.ai/" target="_blank" rel="noopener"&gt;LibreChat&lt;/a&gt;, with &lt;a href="https://openrouter.ai/" target="_blank" rel="noopener"&gt;OpenRouter&lt;/a&gt;, and &lt;a href="https://github.com/basnijholt/clip-files" target="_blank" rel="noopener"&gt;&lt;code&gt;clip-files&lt;/code&gt;&lt;/a&gt;, and currently Claude 3.7 is my favorite model.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="my-golden-rule-for-ai-generated-code"&gt;My Golden Rule for AI-Generated Code &lt;a class="anchor" href="#my-golden-rule-for-ai-generated-code" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;While AI can generate impressive amounts of code quickly, I follow one critical principle in my workflow: &lt;strong&gt;I never commit code I don&amp;rsquo;t fully understand&lt;/strong&gt;.&#10;With the ease of generating solutions comes the responsibility to ensure quality and correctness.&lt;/p&gt;&#10;&lt;p&gt;My approach involves:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Understanding every line&lt;/strong&gt;: Before committing any AI-generated code, I carefully review and understand each line. This helps avoid introducing subtle bugs that might be hidden in seemingly working code.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;100% test coverage&lt;/strong&gt;: I ensure comprehensive test coverage for AI-generated implementations. This serves two purposes: it verifies the code works as expected and creates concrete examples that document the intended behavior.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Critical evaluation&lt;/strong&gt;: I assess whether the proposed solution aligns with project architecture and best practices. Often, I&amp;rsquo;ll ask the AI to refactor or explain its approach before accepting it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Incremental adoption&lt;/strong&gt;: Rather than accepting large code blocks wholesale, I typically integrate smaller, well-understood portions that can be thoroughly tested.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;This mindset transforms AI from a potential shortcut that could introduce technical debt into a powerful assistant that accelerates development while maintaining quality standards.&lt;/p&gt;&#10;&lt;h2 id="starting-with-openwebui"&gt;Starting with OpenWebUI &lt;a class="anchor" href="#starting-with-openwebui" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For most of the last year, I&amp;rsquo;ve relied on &lt;a href="https://openwebui.com/" target="_blank" rel="noopener"&gt;OpenWebUI&lt;/a&gt; as my primary interface for accessing various commercial LLMs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;OpenAI&amp;rsquo;s models&lt;/li&gt;&#10;&lt;li&gt;Anthropic&amp;rsquo;s Claude models&lt;/li&gt;&#10;&lt;li&gt;Google&amp;rsquo;s Gemini models&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;OpenWebUI is primarily designed for running local models, but it can work with remote APIs through what it calls &lt;a href="https://openwebui.com/functions" target="_blank" rel="noopener"&gt;&amp;ldquo;functions&amp;rdquo;&lt;/a&gt; - essentially Python modules that establish connections to these external APIs.&#10;While functional, this approach requires additional setup and configuration to get working properly.&lt;/p&gt;&#10;&lt;h2 id="discovering-librechat-a-major-upgrade"&gt;Discovering LibreChat: A Major Upgrade &lt;a class="anchor" href="#discovering-librechat-a-major-upgrade" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Recently, I discovered &lt;a href="https://www.librechat.ai/" target="_blank" rel="noopener"&gt;LibreChat&lt;/a&gt;, which has dramatically improved my LLM experience:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Speed&lt;/strong&gt;: Pages load in under a second, compared to 5-10 seconds with OpenWebUI.&#10;This performance difference is especially noticeable when accessing either interface from my iPhone.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;API Support&lt;/strong&gt;: Native integration with virtually all major AI company APIs you can think of, right out of the box&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Convenience&lt;/strong&gt;: No need to implement custom Python functions - all popular models are supported by default&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Reasoning Support&lt;/strong&gt;: LibreChat works much better with the new reasoning models, properly placing the reasoning part of replies in a dropdown.&#10;The amount of reasoning tokens is also easily parameterized.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="the-rise-of-free--nearly-free-powerful-models"&gt;The Rise of Free &amp;amp; Nearly-Free Powerful Models &lt;a class="anchor" href="#the-rise-of-free--nearly-free-powerful-models" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The landscape has changed dramatically in just the last month.&#10;Several extremely capable models are now either completely free or essentially free:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Google&amp;rsquo;s &lt;a href="https://ai.google.dev/gemini-api/docs/models/experimental-models" target="_blank" rel="noopener"&gt;gemini-exp-1206&lt;/a&gt;&lt;/strong&gt;: Impressively capable with a huge context window for code&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.deepseek.com/" target="_blank" rel="noopener"&gt;DeepSeek&lt;/a&gt;&lt;/strong&gt;: DeepSeek v3 is one of the best in this category, offering remarkable performance at minimal cost&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://huggingface.co/Qwen/QwQ-32B-Preview" target="_blank" rel="noopener"&gt;Qwen QwQ 32B&lt;/a&gt;&lt;/strong&gt;: Another powerful option that won&amp;rsquo;t break the bank&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;This democratization of access to high-quality models has substantially improved my development workflow.&lt;/p&gt;&#10;&lt;h2 id="claude-37-worth-every-penny"&gt;Claude 3.7: Worth Every Penny &lt;a class="anchor" href="#claude-37-worth-every-penny" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My current go-to model is Anthropic&amp;rsquo;s Claude 3.7.&#10;I&amp;rsquo;m completely blown away by its capabilities and find myself happy to pay the premium price for what it delivers.&#10;The quality of responses, understanding of complex contexts, and overall reliability have made it an indispensable tool in my workflow.&lt;/p&gt;&#10;&lt;h2 id="openrouter-hundreds-of-models-one-api"&gt;OpenRouter: Hundreds of Models, One API &lt;a class="anchor" href="#openrouter-hundreds-of-models-one-api" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;A particularly valuable discovery has been &lt;a href="https://openrouter.ai/" target="_blank" rel="noopener"&gt;OpenRouter.ai&lt;/a&gt;.&#10;With just one API key, I can access hundreds of different models, many of which are completely free to use.&#10;This has dramatically expanded my toolkit without increasing costs.&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;Note that OpenRouter doesn&amp;rsquo;t charge anything extra for using their interface, however, they very likely keep your data.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="easy-deployment-with-docker-compose"&gt;Easy Deployment with Docker Compose &lt;a class="anchor" href="#easy-deployment-with-docker-compose" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Both OpenWebUI and LibreChat are extremely easy to deploy using Docker Compose.&#10;As someone who self-hosts numerous services, I appreciate how straightforward it is to get either of these interfaces up and running with a simple docker-compose file.&#10;This accessibility makes it easy for even those with limited server experience to set up their own LLM workstation.&lt;/p&gt;&#10;&lt;h2 id="why-i-dont-run-local-models"&gt;Why I Don&amp;rsquo;t Run Local Models &lt;a class="anchor" href="#why-i-dont-run-local-models" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Despite being a self-hosting enthusiast (I run over 50 services at home!), I&amp;rsquo;ve stuck with commercial API models for my work.&#10;Since I mainly develop open source software, data privacy concerns are minimal—the code I work with is already publicly available.&#10;The commercial models also provide superior performance for my specific needs.&lt;/p&gt;&#10;&lt;h2 id="avoiding-web-interfaces--subscriptions"&gt;Avoiding Web Interfaces &amp;amp; Subscriptions &lt;a class="anchor" href="#avoiding-web-interfaces--subscriptions" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I deliberately avoid using ChatGPT or Claude through their native web interfaces.&#10;Why? Simple economics.&#10;By accessing these models through my own interfaces with API keys, I don&amp;rsquo;t have to pay separate subscription fees (≈$20) for each service.&#10;This consolidated approach saves money while providing more flexibility.&lt;/p&gt;&#10;&lt;h2 id="code-editor-experiments"&gt;Code Editor Experiments &lt;a class="anchor" href="#code-editor-experiments" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I&amp;rsquo;ve tried &lt;a href="https://www.cursor.com/" target="_blank" rel="noopener"&gt;Cursor editor&lt;/a&gt;, which offers an interesting AI-integrated coding experience.&#10;While many features are impressive, I encountered limitations—particularly with the Python debugger, which is &lt;a href="https://forum.cursor.com/t/python-debugger-doesnt-launch/1661/19" target="_blank" rel="noopener"&gt;a known issue&lt;/a&gt;.&#10;Despite its promise, these limitations pushed me back to my custom workflow.&lt;/p&gt;&#10;&lt;h2 id="my-diy-solution-clip-files"&gt;My DIY Solution: clip-files &lt;a class="anchor" href="#my-diy-solution-clip-files" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;To compensate, I heavily rely on a CLI tool I created called &amp;ldquo;&lt;a href="https://github.com/basnijholt/clip-files/" target="_blank" rel="noopener"&gt;clip-files&lt;/a&gt;.&amp;rdquo; This simple utility lets me pass filenames as arguments, and it copies their contents to my clipboard along with useful metadata.&#10;This makes it effortless to share code with LLMs regardless of which interface I&amp;rsquo;m using—simple, fast, and effective.&#10;Together with the &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; plugin–which I talked about in &lt;a href="../terminal-ninja/"&gt;another post&lt;/a&gt;–generating the right prompt is a breeze.&lt;/p&gt;&#10;&lt;h2 id="my-current-setup"&gt;My Current Setup &lt;a class="anchor" href="#my-current-setup" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Today, I maintain access to multiple top-tier models through LibreChat&amp;rsquo;s interface and OpenRouter.ai&amp;rsquo;s API.&#10;This gives me flexibility to choose the right model for specific tasks while enjoying a responsive, feature-rich environment without unnecessary subscription costs.&lt;/p&gt;&#10;&lt;p&gt;For fellow self-hosters and developers looking to optimize their LLM workflow, I highly recommend this combination, especially if you&amp;rsquo;re currently using web interfaces or struggling with OpenWebUI&amp;rsquo;s performance.&lt;/p&gt;&#10;&lt;p&gt;What LLM setups are you using in your workflow? I&amp;rsquo;d love to hear about your experiences in the comments!&lt;/p&gt;&#10;</description></item><item><title>tiller-streamlit 💰</title><link>https://www.nijho.lt/post/advent-of-open-source/22-tiller-streamlit/</link><pubDate>Sun, 22 Dec 2024 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/advent-of-open-source/22-tiller-streamlit/</guid><description>&lt;p&gt;(See my &lt;a href="../"&gt;intro post&lt;/a&gt;)&lt;/p&gt;&#10;&lt;p&gt;Tracking personal finances is crucial, especially when working towards financial independence (see my &lt;a href="../04-wenfire"&gt;WenFIRE post on Day 4&lt;/a&gt;). Today&amp;rsquo;s project is &lt;a href="https://github.com/basnijholt/tiller-streamlit" target="_blank" rel="noopener"&gt;&lt;code&gt;tiller-streamlit&lt;/code&gt;&lt;/a&gt;, a Streamlit app that visualizes your financial data from &lt;a href="https://www.tillerhq.com/" target="_blank" rel="noopener"&gt;Tiller&lt;/a&gt;. I host it on my local network under &lt;code&gt;money.local&lt;/code&gt; using Traefik for easy access.&lt;/p&gt;&#10;&lt;h2 id="-origin-story"&gt;📖 Origin Story &lt;a class="anchor" href="#-origin-story" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Used to use Intuit Mint, but when they shut down, I needed an alternative with full data control.&lt;/li&gt;&#10;&lt;li&gt;Tiller automatically populates your financial data into a Google Sheet that you own forever.&lt;/li&gt;&#10;&lt;li&gt;With 20+ accounts, manual updates are tedious – Tiller automates it.&lt;/li&gt;&#10;&lt;li&gt;Combined Tiller&amp;rsquo;s data with Streamlit&amp;rsquo;s ease of use, creating a personal finance dashboard.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="-technical-highlights"&gt;🔧 Technical Highlights &lt;a class="anchor" href="#-technical-highlights" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Powered by Streamlit: Turns Python into a web app with minimal code.&lt;/li&gt;&#10;&lt;li&gt;Tiller Integration: Connects to Tiller&amp;rsquo;s Google Sheets for automated data retrieval.&lt;/li&gt;&#10;&lt;li&gt;Interactive Visualizations: Uses Plotly and Altair for dynamic charts.&lt;/li&gt;&#10;&lt;li&gt;Customizable Analysis: Filter by categories, time periods, and more.&lt;/li&gt;&#10;&lt;li&gt;Net Worth Tracking: Calculates and visualizes net worth over time.&lt;/li&gt;&#10;&lt;li&gt;Dockerized Deployment: Easy deployment with Docker, and served locally with Traefik.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="-impact"&gt;📊 Impact &lt;a class="anchor" href="#-impact" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Primarily for personal use, it has significantly improved my understanding of my finances.&lt;/li&gt;&#10;&lt;li&gt;Great way to learn Streamlit and explore its capabilities.&lt;/li&gt;&#10;&lt;li&gt;Visualizing spending patterns helps identify areas to cut back and make informed financial decisions on the path to financial independence.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="-challenges-and-solutions"&gt;🎯 Challenges and Solutions &lt;a class="anchor" href="#-challenges-and-solutions" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Learning Streamlit: Excellent documentation and community support made it a smooth process.&lt;/li&gt;&#10;&lt;li&gt;Data Wrangling: Transforming Tiller&amp;rsquo;s raw data into a format suitable for visualization.&lt;/li&gt;&#10;&lt;li&gt;Deployment: Docker and Traefik simplified local deployment, making it accessible on my home network.&lt;/li&gt;&#10;&lt;/ul&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;Data Ownership Matters: Owning your financial data gives you control and flexibility.&lt;/li&gt;&#10;&lt;li&gt;Visualization is Key: Seeing your finances visually makes a huge difference.&lt;/li&gt;&#10;&lt;li&gt;Streamlit is Powerful: Simplifies web app development.&lt;/li&gt;&#10;&lt;li&gt;Automation is Essential: Tiller&amp;rsquo;s automatic data population makes the whole process sustainable.&lt;/li&gt;&#10;&lt;li&gt;Docker and Traefik Simplify Local Deployment: Making it easy to access the app on my home network.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="-future-plans"&gt;🔮 Future Plans &lt;a class="anchor" href="#-future-plans" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Budgeting Features: Integrate budget tracking and forecasting.&lt;/li&gt;&#10;&lt;li&gt;Goal Setting: Set financial goals and track progress.&lt;/li&gt;&#10;&lt;li&gt;Investment Tracking: Incorporate investment data for a complete picture.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Want to visualize your own finances with Tiller and Streamlit? Check out &lt;a href="https://github.com/basnijholt/tiller-streamlit" target="_blank" rel="noopener"&gt;&lt;code&gt;tiller-streamlit&lt;/code&gt; on GitHub&lt;/a&gt;!&lt;/p&gt;&#10;&lt;p&gt;#OpenSource #Python #Streamlit #PersonalFinance #DataVisualization #Tiller #Traefik #Docker&lt;/p&gt;&#10;</description></item><item><title>My homelab: from a single Raspberry Pi to a Proxmox cluster and a dedicated NAS 🔧</title><link>https://www.nijho.lt/post/homelab/</link><pubDate>Sat, 06 Jul 2024 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/homelab/</guid><description>&lt;p&gt;In 2020, I began my homelab journey (&lt;em&gt;even though I never heard of the term &amp;ldquo;homelab&amp;rdquo; at that point&lt;/em&gt;) with a simple yet powerful setup: Home Assistant running on Proxmox on a NUC8i3BEH.&#10;My Home Assistant instance, with over 100 automations and about 20 addons (docker containers), had outgrown the capabilities of my Raspberry Pi 4, leading me to upgrade to the NUC8i3BEH.&#10;You can check out my &lt;a href="https://github.com/basnijholt/home-assistant-config/" target="_blank" rel="noopener"&gt;Home Assistant configuration&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;This system was more than sufficient for running Home Assistant—arguably overkill.&#10;However, because I passionately develop addons and custom components, I needed the ability to quickly restart and reload everything while debugging.&#10;For a long time, I ran Proxmox, mainly because the Home Assistant community recommended it, but I only used it to host one extra Ubuntu VM and Home Assistant OS.&lt;/p&gt;&#10;&lt;p&gt;In 2022, I stumbled upon the &lt;a href="https://github.com/tteck/Proxmox" target="_blank" rel="noopener"&gt;Proxmox VE Helper-Scripts&lt;/a&gt; by tteck, which got me interested in the whole homelabber/self-hosting community.&#10;These scripts opened a new world of possibilities.&#10;With them, I expanded my Proxmox environment to run 25 different VMs and &lt;a href="https://linuxcontainers.org/lxc/introduction/" target="_blank" rel="noopener"&gt;LXC containers&lt;/a&gt;, each performing a variety of tasks, from DNS servers and reverse proxies to local dashboards and backup software.&lt;/p&gt;&#10;&lt;p&gt;Initially, I used an external 2 TB drive for storage, but it quickly became insufficient as my data needs grew.&#10;After some research, I found the &lt;a href="https://support.hp.com/id-en/document/c06047207" target="_blank" rel="noopener"&gt;HP EliteDesk 800 G4 SFF&lt;/a&gt;, a renewed enterprise-grade desktop with slots for two 3.5-inch drives.&#10;At approximately $160 on Amazon, it offered excellent specs for a budget-friendly price.&#10;I decided to create a TrueNAS VM on this machine using Proxmox and passed through the two 16TB renewed enterprise-level HDDs (via the PCIe SATA controller), which I had purchased from serverpartdeals.com.&#10;I chose these drives based on their reliability as reported in the &lt;a href="https://www.backblaze.com/blog/backblaze-drive-stats-for-2023/" target="_blank" rel="noopener"&gt;BackBlaze drive stats&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;When I got the HP, I created a Proxmox cluster and was very excited about how easy it was to migrate containers and VMs from one machine to the other.&#10;However, I soon encountered a significant drawback: enterprise drives are notably loud.&#10;Since I had my Proxmox &amp;ldquo;cluster&amp;rdquo; (just the two machines) running behind the TV, the loudness was noticeable and disruptive.&#10;This noise was just the beginning of my troubles.&#10;The NFS server on the TrueNAS VM often crashed, causing the entire Proxmox system to lock up.&#10;Initially, I mounted the NFS drives directly on the Proxmox host and used bind-mounts to pass through the drives.&#10;Due to the locking behavior of NFS, any problem would cause the entire system to lock up.&#10;To prevent this, I started to use &lt;a href="https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/7/html/storage_administration_guide/nfs-autofs" target="_blank" rel="noopener"&gt;autofs&lt;/a&gt; to mount the drives in the LXC containers and VMs instead.&#10;This worked fine until Proxmox started its periodic backups.&#10;Any issue with the NFS during backup caused the whole process to hang, locking up the VMs and LXC containers.&#10;This could be partially avoided by using the &amp;ldquo;stop&amp;rdquo; backup mode, but it still resulted in almost daily debugging sessions where I had to restart Proxmox-related services.&lt;/p&gt;&#10;&lt;p&gt;After running all services as LXC containers, I realized that updating them and keeping up with all security updates was labor-intensive.&#10;So, I started to look into migrating everything to Docker Compose.&#10;This transition turned out great because I could manage my config files in a git repo and keep the relevant data separate, streamlining updates and maintenance.&lt;/p&gt;&#10;&lt;p&gt;To resolve these issues and because I was having a lot of fun, I decided to build a dedicated NAS running TrueNAS Scale, not that I &lt;em&gt;really&lt;/em&gt; needed one, but mostly because I could.&#10;This new setup included high-performance components, ensuring both stability and quiet operation.&#10;I used &amp;ldquo;Manufacturer Recertified&amp;rdquo; &lt;a href="https://www.westerndigital.com/products/internal-drives/data-center-drives/ultrastar-dc-hc550-hdd?sku=0F38356" target="_blank" rel="noopener"&gt;Western Digital Ultrastar DC HC550 HDDs&lt;/a&gt;, known for their reliability and performance.&#10;I have 6 x 16TB = 96TB now with the option to add 2 more 3.5&amp;quot; drives, 2 nvme M.2 drives, and 1 SATA 2.5&amp;quot; SSD.&#10;Now, I run all services that previously relied on NFS locally on TrueNAS via a Debian &lt;a href="https://wiki.debian.org/nspawn" target="_blank" rel="noopener"&gt;systemd-nspawn container&lt;/a&gt; managed with a new script called &lt;a href="https://github.com/Jip-Hop/jailmaker" target="_blank" rel="noopener"&gt;Jailmaker&lt;/a&gt;, which leverages the systemd-nspawn program in TrueNAS Scale (not to be confused with FreeBSD jails).&#10;Inside this systemd-nspawn container, I run Debian with Docker.&#10;For managing Docker Compose files, I use &lt;a href="https://github.com/louislam/dockge" target="_blank" rel="noopener"&gt;Dockge&lt;/a&gt;, which provides a nice web UI for easy management.&lt;/p&gt;&#10;&lt;p&gt;Along the way, I got familiar with ZFS and grew to love it for its amazing snapshotting and data integrity features.&#10;ZFS has been a game-changer, providing robust data protection and ease of management.&#10;Initially, &lt;a href="https://jrs-s.net/2015/02/06/zfs-you-should-use-mirror-vdevs-not-raidz/" target="_blank" rel="noopener"&gt;I used mirrored vdevs&lt;/a&gt;, but I recently switched to RAIDZ2 for the added storage capacity and &lt;a href="https://github.com/openzfs/zfs/pull/15022#issuecomment-1802428899" target="_blank" rel="noopener"&gt;the promise of OpenZFS 2.3 supporting expansion of RAIDZ vdevs&lt;/a&gt;.&#10;I also use ZFS for my Proxmox VMs and LXC containers, whose backups I store on the NAS via rsync in a cronjob to another jail running plain Debian (no more NFS on the Proxmox host for me&amp;hellip;)&lt;/p&gt;&#10;&lt;p&gt;This journey from a simple NUC running Home Assistant to a Proxmox cluster and dedicated NAS has been very time-consuming but a lot of fun.&#10;The lessons learned and the improvements in stability and performance have made the effort worthwhile.&#10;If you&amp;rsquo;re considering building your own homelab, investing in reliable hardware and being prepared for a bit of trial and error can make all the difference.&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;Run your services in Docker containers, not LXC containers, for easier management and updates.&lt;/li&gt;&#10;&lt;li&gt;Do &lt;strong&gt;not&lt;/strong&gt; have virtualized NAS (TrueNAS VM) on Proxmox, it&amp;rsquo;s a bad idea if you are also sharing the drives with Proxmox via NFS. There are many folks on the internet who run their NAS in a VM on Proxmox, seemingly without issues, but I was not one of them.&lt;/li&gt;&#10;&lt;li&gt;Do not encrypt the root ZFS dataset! Encrypt its children instead.&lt;/li&gt;&#10;&lt;li&gt;Use passphrase encryption instead of a generated key. It allows you to use the UI and rotating keys.&lt;/li&gt;&#10;&lt;li&gt;Do &lt;strong&gt;not&lt;/strong&gt; remove the physical drive that is passed through in a Proxmox VM, because Proxmox will &lt;em&gt;not&lt;/em&gt; boot properly afterwards.&lt;/li&gt;&#10;&lt;li&gt;Do &lt;strong&gt;not&lt;/strong&gt; use random 100 character passwords for your Proxmox, you have only 60 seconds to enter it during boot.&lt;/li&gt;&#10;&lt;li&gt;When making Proxmox backups, send them to a different physical machine, not only the same machine that is being backed up.&lt;/li&gt;&#10;&lt;li&gt;When building a NAS or home server, consider noise levels, especially if the equipment will be in a living area. Enterprise-grade drives can be surprisingly loud.&lt;/li&gt;&#10;&lt;li&gt;When using Intel I226-V 2.5GbE controllers, be prepared for potential issues.&lt;/li&gt;&#10;&lt;li&gt;When planning your NAS storage size needs, remember to account for the extra space needed for redundancy (e.g., in RAIDZ2). Additionally, keep in mind that ZFS performs optimally when drives aren&amp;rsquo;t filled to capacity. It&amp;rsquo;s wise to plan for more storage than you think you&amp;rsquo;ll immediately need.&lt;/li&gt;&#10;&lt;li&gt;Buy as many used enterprise-grade hardware components as you can, they are often cheaper and more reliable than consumer-grade components.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="components"&gt;Components &lt;a class="anchor" href="#components" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For completeness, here are the components for each machine in my homelab:&lt;/p&gt;&#10;&lt;h3 id="nuc-components"&gt;NUC Components &lt;a class="anchor" href="#nuc-components" 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;Name&lt;/th&gt;&#10;&lt;th&gt;Full Name&lt;/th&gt;&#10;&lt;th&gt;Server&lt;/th&gt;&#10;&lt;th&gt;Price&lt;/th&gt;&#10;&lt;th&gt;Quantity&lt;/th&gt;&#10;&lt;th&gt;Cost&lt;/th&gt;&#10;&lt;th&gt;Date&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;NUC8i3BEH&lt;/td&gt;&#10;&lt;td&gt;Intel NUC Kit NUC8i3BEH&lt;/td&gt;&#10;&lt;td&gt;NUC&lt;/td&gt;&#10;&lt;td&gt;278.3&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;278.3&lt;/td&gt;&#10;&lt;td&gt;2020-02-22&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;500GB M2 SSD&lt;/td&gt;&#10;&lt;td&gt;Samsung 970 EVO M.2 500GB&lt;/td&gt;&#10;&lt;td&gt;NUC&lt;/td&gt;&#10;&lt;td&gt;94.99&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;94.99&lt;/td&gt;&#10;&lt;td&gt;2020-02-21&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;32 GB RAM&lt;/td&gt;&#10;&lt;td&gt;TEAMGROUP 32GB DDR4 3200MHz&lt;/td&gt;&#10;&lt;td&gt;NUC&lt;/td&gt;&#10;&lt;td&gt;58.99&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;58.99&lt;/td&gt;&#10;&lt;td&gt;2024-05-01&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;4TB SSD&lt;/td&gt;&#10;&lt;td&gt;Crucial P3 Plus 4TB NVMe&lt;/td&gt;&#10;&lt;td&gt;NUC&lt;/td&gt;&#10;&lt;td&gt;226.99&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;226.99&lt;/td&gt;&#10;&lt;td&gt;2024-04-27&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;!-- | 16 GB DDR4 | Crucial 8GB DDR4 2400 MT/s | NUC | 25.52 | 2 | 51.04 | 2020-02-21 | --&gt;&#10;&lt;h3 id="hp-components"&gt;HP Components &lt;a class="anchor" href="#hp-components" 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;Name&lt;/th&gt;&#10;&lt;th&gt;Full Name&lt;/th&gt;&#10;&lt;th&gt;Server&lt;/th&gt;&#10;&lt;th&gt;Price&lt;/th&gt;&#10;&lt;th&gt;Quantity&lt;/th&gt;&#10;&lt;th&gt;Cost&lt;/th&gt;&#10;&lt;th&gt;Date&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;64 GB RAM&lt;/td&gt;&#10;&lt;td&gt;TEAMGROUP 32GB DDR4 3200MHz&lt;/td&gt;&#10;&lt;td&gt;HP&lt;/td&gt;&#10;&lt;td&gt;57.99&lt;/td&gt;&#10;&lt;td&gt;2&lt;/td&gt;&#10;&lt;td&gt;115.98&lt;/td&gt;&#10;&lt;td&gt;2024-05-02&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;HP EliteDesk 800 G4 SFF&lt;/td&gt;&#10;&lt;td&gt;HP EliteDesk 800 G4 SFF&lt;/td&gt;&#10;&lt;td&gt;HP&lt;/td&gt;&#10;&lt;td&gt;164&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;164&lt;/td&gt;&#10;&lt;td&gt;2024-04-27&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;!-- | SSD enclosure | UGREEN SSD Enclosure | HP | 16.98 | 1 | 16.98 | 2024-04-27 | --&gt;&#10;&lt;p&gt;Notes on the HP components:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Initially, I planned to use this machine as a NAS because it has two 3.5&amp;quot; drive bays, 2 M.2 slots, and the disc drive bay can be converted to a 2.5&amp;quot; drive bay. However, the 2 x 16TB drives I bought quickly filled up because I used a mirrored vdev, so I decided to build a dedicated NAS instead.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="nas-components"&gt;NAS Components &lt;a class="anchor" href="#nas-components" 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;Name&lt;/th&gt;&#10;&lt;th&gt;Full Name&lt;/th&gt;&#10;&lt;th&gt;Server&lt;/th&gt;&#10;&lt;th&gt;Price&lt;/th&gt;&#10;&lt;th&gt;Quantity&lt;/th&gt;&#10;&lt;th&gt;Cost&lt;/th&gt;&#10;&lt;th&gt;Date&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;16 TB HDD&lt;/td&gt;&#10;&lt;td&gt;Western Digital Ultrastar DC HC550 16TB&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;159.99&lt;/td&gt;&#10;&lt;td&gt;2&lt;/td&gt;&#10;&lt;td&gt;319.98&lt;/td&gt;&#10;&lt;td&gt;2024-05-01&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;16 TB HDD&lt;/td&gt;&#10;&lt;td&gt;Western Digital Ultrastar DC HC550 16TB&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;187.5&lt;/td&gt;&#10;&lt;td&gt;4&lt;/td&gt;&#10;&lt;td&gt;750&lt;/td&gt;&#10;&lt;td&gt;2024-06-21&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Motherboard&lt;/td&gt;&#10;&lt;td&gt;Q670 8-bay NAS motherboard&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;203.21&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;203.21&lt;/td&gt;&#10;&lt;td&gt;2024-06-22&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;CPU i5 13500T&lt;/td&gt;&#10;&lt;td&gt;Intel Core i5-13500T&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;180.88&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;180.88&lt;/td&gt;&#10;&lt;td&gt;2024-06-22&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;PSU SFX&lt;/td&gt;&#10;&lt;td&gt;FSP Dagger Pro 650W&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;87.13&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;87.13&lt;/td&gt;&#10;&lt;td&gt;2024-06-21&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Jonsbo N3 case&lt;/td&gt;&#10;&lt;td&gt;JONSBO N3 Mini-ITX NAS Case&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;187.51&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;187.51&lt;/td&gt;&#10;&lt;td&gt;2024-06-16&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;CPU cooler&lt;/td&gt;&#10;&lt;td&gt;Thermalright AXP90 X36 CPU cooler&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;21.83&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;21.83&lt;/td&gt;&#10;&lt;td&gt;2024-06-25&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;64 GB DDR5 RAM&lt;/td&gt;&#10;&lt;td&gt;CORSAIR VENGEANCE 64GB DDR5&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;162.13&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;162.13&lt;/td&gt;&#10;&lt;td&gt;2024-06-29&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Notes on the NAS components:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The 13500T is a 35W TDP CPU I bought on eBay, which will help keep the system cool and quiet.&lt;/li&gt;&#10;&lt;li&gt;I bought the motherboard from AliExpress because it seems to be the only mini-ITX motherboard that supports 8 SATA drives, and also comes with 2x2.5GbE ports, 3xM.2 slots, and a full sized PCIe slot.&lt;/li&gt;&#10;&lt;li&gt;The motherboard uses Intel I225-V 2.5GbE controllers, which are notoriously bad, initially &lt;code&gt;iperf3&lt;/code&gt; maxed out at 100Mbps. The internet is full with people complaining about these controllers auto-negotiating to 100Mbps, however, for me it showed 1Gbps. After trying all suggestions I could find (there are few), as a last resort I connecting the NAS directly to my ASUS XT8 router instead of the NETGEAR GS305 switch, and now I got 1Gbps speeds. So I bought a new managed TL-SG108E switch, which now also gives me 1Gbps speeds. I&amp;rsquo;m not sure what the issue was, but I&amp;rsquo;m happy it&amp;rsquo;s resolved.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item></channel></rss>