<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Security | Bas Nijholt</title><link>https://www.nijho.lt/tag/security/</link><atom:link href="https://www.nijho.lt/tag/security/index.xml" rel="self" type="application/rss+xml"/><description>Security</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>Security</title><link>https://www.nijho.lt/tag/security/</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>Friend-to-friend ZFS backups, version two</title><link>https://www.nijho.lt/post/zfs-tenant/</link><pubDate>Mon, 28 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/zfs-tenant/</guid><description>&lt;p&gt;Two years ago I wrote about &lt;a href="https://www.nijho.lt/post/truenas-remote-backups/"&gt;friend-to-friend backups with TrueNAS&lt;/a&gt;.&#10;A friend and I store each other&amp;rsquo;s ZFS snapshots, which is the cheapest off-site backup there is.&#10;TrueNAS&amp;rsquo;s replication tasks wanted root on the receiving system, and neither of us wanted to give the other root on the machine that holds our family photos.&#10;So each of us ran a TrueNAS VM on a hypervisor, backed by an iSCSI zvol on the real NAS, and gave the other person root inside that VM instead.&#10;Sounds complicated?&#10;It was.&#10;It worked, but it meant running ZFS on a zvol on ZFS, inside a VM that each of us had to keep updated.&lt;/p&gt;&#10;&lt;p&gt;Since then I &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;replaced TrueNAS with NixOS&lt;/a&gt;, and my friend plans to move his machines to NixOS too.&#10;This time we looked for the simplest solution, built from standard Linux and ZFS features wherever possible.&#10;He wanted plain &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt;, with his data still encrypted and his keys never on my server.&#10;I agreed, on one condition: neither of us should be able to destroy the other&amp;rsquo;s datasets.&lt;/p&gt;&#10;&lt;p&gt;The result is &lt;a href="https://github.com/basnijholt/zfs-tenant" target="_blank" rel="noopener"&gt;zfs-tenant&lt;/a&gt;, which is now &lt;a href="https://pypi.org/project/zfs-tenant/" target="_blank" rel="noopener"&gt;on PyPI&lt;/a&gt; and has &lt;a href="https://zfs-tenant.nijho.lt" target="_blank" rel="noopener"&gt;documentation&lt;/a&gt;.&lt;/p&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/zfs-tenant/logo.svg" width="220" alt="The zfs-tenant logo: a storage pool with two dim compartments for the host and one locked amber compartment for a friend, filled up to a dashed quota line" loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#why-zfs"&gt;Why ZFS&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-we-wanted"&gt;What we wanted&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#zfs-already-had-most-of-it"&gt;ZFS already had most of it&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#rehearsing-the-permissions-in-a-vm"&gt;Rehearsing the permissions in a VM&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#a-gate-that-speaks-syncoid"&gt;A gate that speaks syncoid&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#zones-a-second-wall-in-the-kernel"&gt;Zones: a second wall in the kernel&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#who-stops-what"&gt;Who stops what&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-it-cannot-hide"&gt;What it cannot hide&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#setting-it-up"&gt;Setting it up&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#knowing-when-it-stops-working"&gt;Knowing when it stops working&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#restoring"&gt;Restoring&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-first-real-push"&gt;The first real push&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#tested-like-everything-else"&gt;Tested like everything else&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#ai-scope-creep-or-useful"&gt;AI scope creep or useful?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="why-zfs"&gt;Why ZFS &lt;a class="anchor" href="#why-zfs" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://openzfs.org/" target="_blank" rel="noopener"&gt;OpenZFS&lt;/a&gt; is a filesystem and a volume manager in one.&#10;You add disks to a pool, and it behaves like one large partition: every dataset you create draws from the same free space, and each can still have its own quota and encryption.&#10;ZFS checksums every block it writes, so it notices when a disk hands back something else, and with a mirror or RAIDZ it repairs the block from a good copy.&#10;That protection costs disk space: a mirror of two disks holds one disk&amp;rsquo;s worth of data.&lt;/p&gt;&#10;&lt;p&gt;For me, snapshots are one of the coolest parts.&#10;ZFS never overwrites data in place, so a snapshot only has to hold on to the blocks that existed at that moment.&#10;Taking one is instant, and it costs no space until the data changes.&#10;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-send.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs send&lt;/code&gt;&lt;/a&gt; turns a snapshot, or the difference between two snapshots, into a stream that &lt;code&gt;zfs receive&lt;/code&gt; writes into a pool on another machine, usually piped through SSH.&#10;It moves blocks, not files: an incremental send contains only the blocks that changed, and ZFS knows which ones those are without visiting a single file.&#10;A bajillion one-kilobyte files do not slow it down, because nothing has to look at them one by one.&#10;For comparison, restic on my PC needed &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;an hour and a half per run&lt;/a&gt; just to check close to a hundred million files for changes.&lt;/p&gt;&#10;&lt;p&gt;Two tools from the same project automate this.&#10;&lt;a href="https://github.com/jimsalterjrs/sanoid" target="_blank" rel="noopener"&gt;sanoid&lt;/a&gt; takes snapshots on a schedule and prunes them by policy, for example hourly ones for a day and daily ones for a month.&#10;syncoid wraps &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt; over SSH: it finds the newest snapshot both sides share and sends everything after it.&lt;/p&gt;&#10;&lt;h2 id="what-we-wanted"&gt;What we wanted &lt;a class="anchor" href="#what-we-wanted" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;We wrote down the rules before anything else:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;He cannot see any of my data, including metadata like dataset names and sizes.&lt;/li&gt;&#10;&lt;li&gt;He cannot change or destroy anything of mine.&lt;/li&gt;&#10;&lt;li&gt;I cannot read his data, because his keys stay at his house.&lt;/li&gt;&#10;&lt;li&gt;He gets one dataset where he can create and remove nested datasets as he likes.&lt;/li&gt;&#10;&lt;li&gt;That dataset has a size limit.&lt;/li&gt;&#10;&lt;li&gt;No new replication tool on either side: plain &lt;code&gt;zfs send&lt;/code&gt; or syncoid.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Until then his NAS runs TrueNAS, so whatever I built also had to work on a machine without NixOS.&lt;/p&gt;&#10;&lt;h2 id="zfs-already-had-most-of-it"&gt;ZFS already had most of it &lt;a class="anchor" href="#zfs-already-had-most-of-it" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;OpenZFS has a permission system that most people never touch: &lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-allow.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs allow&lt;/code&gt;&lt;/a&gt;.&#10;It delegates individual operations like &lt;code&gt;receive&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, and &lt;code&gt;destroy&lt;/code&gt; on one dataset to an unprivileged user.&#10;Put a root-owned &lt;code&gt;quota&lt;/code&gt; on that dataset and the user cannot store more than you agreed.&#10;Raw sends (&lt;code&gt;zfs send -w&lt;/code&gt;) ship the encrypted blocks as they are, so the receiving side never needs the key.&lt;/p&gt;&#10;&lt;p&gt;On paper, that covers five of the six rules.&#10;The gap is visibility.&#10;Any local user can run &lt;code&gt;zfs list&lt;/code&gt; and see every dataset on the machine, with names, sizes, and properties.&#10;And an SSH account for replication is, by default, also a shell.&lt;/p&gt;&#10;&lt;p&gt;There are tools that would sidestep some of this.&#10;&lt;a href="https://zrepl.github.io/" target="_blank" rel="noopener"&gt;zrepl&lt;/a&gt; has a sink mode with a subtree per client, but it replaces sanoid and syncoid on both sides and runs as root on the receiver.&#10;restic or borg to a friend&amp;rsquo;s box would also work, but everything else on my network is ZFS: my machines &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/replication.nix#L40-L49" target="_blank" rel="noopener"&gt;replicate to the NAS with syncoid&lt;/a&gt;, and a restore is &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt;.&#10;Adding another backup format means one more thing to understand when something breaks.&#10;My current off-site copy goes to &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/docker-lxc/rclone-b2-backup.nix" target="_blank" rel="noopener"&gt;Backblaze B2 with rclone&lt;/a&gt;.&#10;Ideally that would be ZFS as well, and some hosting services accept &lt;code&gt;zfs send&lt;/code&gt;, but they cost far more than object storage.&#10;A friend with a ZFS box costs nothing.&lt;/p&gt;&#10;&lt;h2 id="rehearsing-the-permissions-in-a-vm"&gt;Rehearsing the permissions in a VM &lt;a class="anchor" href="#rehearsing-the-permissions-in-a-vm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Before writing any code, I had an agent build a throwaway NixOS VM test with a real pool, a delegated user, and a list of probes.&#10;Each probe tried one thing the friend should not be able to do and recorded what ZFS answered.&lt;/p&gt;&#10;&lt;p&gt;The first surprise came from the most obvious setup.&#10;With a plain &lt;code&gt;zfs allow -u joe create,receive,destroy,... tank/friends/joe&lt;/code&gt;, the delegated user could run &lt;code&gt;zfs destroy -r tank/friends/joe&lt;/code&gt; and delete the dataset I had created for him, quota included.&#10;Delegated rights apply to the dataset itself as well as everything below it, unless you say otherwise.&#10;The fix is to split them: on the root itself only &lt;code&gt;create,mount,receive&lt;/code&gt; with &lt;code&gt;zfs allow -l&lt;/code&gt;, and the full set only for descendants with &lt;code&gt;zfs allow -d&lt;/code&gt;.&#10;After that change, destroying, snapshotting, re-delegating, or changing the quota of the root all failed with &lt;code&gt;permission denied&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;A send stream can carry properties too, such as a hostile &lt;code&gt;mountpoint=/etc&lt;/code&gt;.&#10;ZFS applies them with the receiving user&amp;rsquo;s rights, so in the VM that property failed with &lt;code&gt;permission denied&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The probes also confirmed the gap: the delegated user saw my &lt;code&gt;tank/host&lt;/code&gt; dataset in &lt;code&gt;zfs list&lt;/code&gt;, and &lt;code&gt;zfs get used tank/host&lt;/code&gt; returned its size.&lt;/p&gt;&#10;&lt;h2 id="a-gate-that-speaks-syncoid"&gt;A gate that speaks syncoid &lt;a class="anchor" href="#a-gate-that-speaks-syncoid" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The shell part has a standard answer.&#10;An &lt;code&gt;authorized_keys&lt;/code&gt; line with &lt;code&gt;restrict,command=&amp;quot;...&amp;quot;&lt;/code&gt; runs one fixed program for every login, and passes whatever the client asked for in &lt;code&gt;SSH_ORIGINAL_COMMAND&lt;/code&gt;.&#10;&lt;code&gt;zfs-tenant gate&lt;/code&gt; is that program.&#10;It tokenizes the request, accepts only the handful of command shapes a backup needs, checks that every dataset name is inside the friend&amp;rsquo;s dataset, and runs &lt;code&gt;zfs&lt;/code&gt; with an argument list it builds itself.&#10;It never starts a shell, so there is nothing to inject into.&lt;/p&gt;&#10;&lt;p&gt;The hard part was knowing exactly which commands syncoid sends, so an agent read &lt;a href="https://github.com/jimsalterjrs/sanoid/blob/v2.3.0/syncoid" target="_blank" rel="noopener"&gt;syncoid&amp;rsquo;s Perl&lt;/a&gt; and logged every remote command it ran.&#10;Most are the &lt;code&gt;zfs&lt;/code&gt; commands you would expect.&#10;One surprise: before each receive, syncoid runs &lt;code&gt;ps -Ao args=&lt;/code&gt; on the receiving machine, to check that no other transfer is already writing to the same dataset.&#10;That command prints every running process with its full command line.&#10;On my NAS, it would show my friend everything I run, so the gate answers with an empty list.&lt;/p&gt;&#10;&lt;p&gt;Reading syncoid that closely also turned up a remote code execution (RCE) bug in version 2.3.0, outside zfs-tenant itself.&#10;In push mode, syncoid asks the receiving host for a ZFS resume token.&#10;It passed that answer unescaped into shell commands for estimating and resuming the send, which run on the source machine.&#10;A malicious or compromised backup host could therefore return shell syntax instead of a token and execute arbitrary commands on the sender with the privileges of the syncoid process.&lt;/p&gt;&#10;&lt;p&gt;I reproduced this in two isolated NixOS VMs using real ZFS and SSH: a harmless command embedded in the receiver&amp;rsquo;s answer created a marker file on the sender.&#10;I reported it to the maintainer and opened &lt;a href="https://github.com/jimsalterjrs/sanoid/pull/1114" target="_blank" rel="noopener"&gt;sanoid PR #1114&lt;/a&gt; with a small fix that shell-quotes the token at both uses.&#10;Even with that fix, the sending side should run syncoid as an unprivileged user that may only &lt;code&gt;send&lt;/code&gt; and &lt;code&gt;hold&lt;/code&gt;.&#10;nixpkgs&amp;rsquo; &lt;code&gt;services.syncoid&lt;/code&gt; already works that way, so my friend needs nothing from zfs-tenant at all.&lt;/p&gt;&#10;&lt;h2 id="zones-a-second-wall-in-the-kernel"&gt;Zones: a second wall in the kernel &lt;a class="anchor" href="#zones-a-second-wall-in-the-kernel" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;At that point the gate was the only thing hiding my datasets.&#10;Then my friend sent me a message: ZFS has a feature called zones, which restricts a dataset tree to a Linux user namespace.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-zone.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs zone&lt;/code&gt;&lt;/a&gt; attaches a dataset to one user namespace, and inside that namespace the ZFS kernel module answers &lt;code&gt;dataset does not exist&lt;/code&gt; for everything that is not attached.&#10;Even if my gate had a bug that let arbitrary &lt;code&gt;zfs&lt;/code&gt; commands through, &lt;code&gt;tank/host&lt;/code&gt; would stay invisible.&#10;The zone does not replace the gate, though: it only filters ZFS, and it only applies once the gate has put the SSH session inside it.&#10;&lt;a href="#who-stops-what"&gt;Who stops what&lt;/a&gt; shows how the two divide the work.&lt;/p&gt;&#10;&lt;p&gt;A user namespace can give its processes different user IDs than they have outside.&#10;Containers usually use that to make the user root inside the namespace, while it stays an ordinary user outside.&#10;ZFS treats root inside a zone as the zone&amp;rsquo;s administrator, allowed to do anything to the attached datasets, whatever &lt;code&gt;zfs allow&lt;/code&gt; says.&#10;In the VM test, that let the friend destroy the dataset I had created for him.&#10;So zfs-tenant keeps him as his own ordinary user inside the namespace: he has no special rights there, &lt;code&gt;zfs allow&lt;/code&gt; decides what he may do, and the kernel still hides everything else.&lt;/p&gt;&#10;&lt;p&gt;Root inside a user namespace also turned up in a real OpenZFS bug in August: before 2.4.4, 2.3.9, and 2.2.11, several pool operations, such as destroying a pool, accepted it as host root (&lt;a href="https://github.com/openzfs/zfs/security/advisories/GHSA-mhf5-q8gw-qg9v" target="_blank" rel="noopener"&gt;CVE-2026-79619&lt;/a&gt;).&#10;Joe cannot reach that through the gate, but zfs-tenant needs unprivileged user namespaces, so a host running it should load a patched module: &lt;code&gt;cat /sys/module/zfs/version&lt;/code&gt; shows the loaded one, which only changes after a reboot.&lt;/p&gt;&#10;&lt;p&gt;A zone needs a running namespace to attach to, so zfs-tenant runs a small service per friend that holds one.&#10;The gate joins that namespace before it looks at the command, drops the capabilities that joining grants, and refuses to run if the service is down.&lt;/p&gt;&#10;&lt;h2 id="who-stops-what"&gt;Who stops what &lt;a class="anchor" href="#who-stops-what" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;When Joe and I talked it through, I found it hard to say clearly which part protects against what, so I made this diagram.&lt;/p&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/zfs-tenant/layers.svg" alt="Diagram of seven things Joe&amp;#39;s key tries and the layer that stops each one: the network, the SSH gate, the zone, and zfs allow with the quota. Only his raw encrypted backup reaches my pool, where it stays unreadable to me." loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;p&gt;Each column answers one question: who can connect, what he can run, what ZFS shows him, and what he can change.&#10;For the network, our tailnet only lets his router reach port 22 on my NAS, and &lt;code&gt;from=&lt;/code&gt; in &lt;code&gt;authorized_keys&lt;/code&gt; only accepts his key from that address.&#10;Raw sends then keep whatever reaches my pool unreadable to me.&lt;/p&gt;&#10;&lt;p&gt;The gate and the zone overlap in one job: hiding my other datasets.&#10;The gate does it first, by refusing any name outside his root before &lt;code&gt;zfs&lt;/code&gt; runs.&#10;The zone repeats that check in the kernel, so hiding my datasets would take a bug in both.&#10;Everything else the gate does, the zone cannot:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Without the gate, his key would be a normal account on my NAS. The zone only filters ZFS; it would not stop him from reading &lt;code&gt;/etc&lt;/code&gt; or running programs.&lt;/li&gt;&#10;&lt;li&gt;An SSH login does not start inside a user namespace. The gate joins the zone before it runs anything, so without the gate the zone would never apply.&lt;/li&gt;&#10;&lt;li&gt;The zone leaves some things visible: &lt;code&gt;zpool status&lt;/code&gt; still shows my disks and how full the pool is, and the datasets above his root still show their sizes. User namespaces do not hide processes either, hence the empty answer to &lt;code&gt;ps&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;It deletes any unencrypted dataset he pushes by mistake, so I never keep his plaintext.&lt;/li&gt;&#10;&lt;li&gt;It logs every command, which is how I saw Joe try &lt;code&gt;bash&lt;/code&gt; below.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The &lt;a href="https://zfs-tenant.nijho.lt/security/" target="_blank" rel="noopener"&gt;security model&lt;/a&gt; in the documentation lists every promise with the mechanism behind it.&lt;/p&gt;&#10;&lt;h2 id="what-it-cannot-hide"&gt;What it cannot hide &lt;a class="anchor" href="#what-it-cannot-hide" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;ZFS encryption protects file contents, not structure.&#10;In the words of &lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-load-key.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-load-key(8)&lt;/code&gt;&lt;/a&gt;, ZFS &amp;ldquo;will not encrypt metadata related to the pool structure, including dataset and snapshot names, dataset hierarchy, properties, file size, file holes, and deduplication tables.&amp;rdquo;&#10;So I can see the names, sizes, and snapshot times of whatever my friend sends me.&#10;Boring dataset names help, and sending without &lt;code&gt;-p&lt;/code&gt; keeps properties out of the stream.&lt;/p&gt;&#10;&lt;p&gt;I can also always delete his copy, because I am root on my own machine.&#10;What I can never do is read it.&#10;My &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/storage.nix#L71-L81" target="_blank" rel="noopener"&gt;monthly scrubs&lt;/a&gt; still verify his data without his key, because ZFS checksums the encrypted blocks.&lt;/p&gt;&#10;&lt;p&gt;What we protect against is a dead machine: a failed pool, a fire, a flood.&#10;His key may destroy anything below his root, which is what lets syncoid mirror his snapshot retention, so someone who steals that key can also delete his backups on my NAS.&#10;Covering that would take holds that I place as root and release on a schedule.&#10;We left them out on purpose, although I am &lt;a href="#ai-scope-creep-or-useful"&gt;reconsidering&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="setting-it-up"&gt;Setting it up &lt;a class="anchor" href="#setting-it-up" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;On a NixOS host, giving a friend space takes this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zfs-tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;tenants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;joe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tank/friends/joe&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;quota&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2T&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;authorizedKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... joe-nas&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;allowedFrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;100.64.0.12&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;# his tailnet address&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That creates the user, pins the key to the gate, applies the dataset, properties, and delegation on every boot, and runs the zone service.&#10;My own setup is in &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L43-L50" target="_blank" rel="noopener"&gt;&lt;code&gt;friend-backups.nix&lt;/code&gt;&lt;/a&gt;, with his real key and address.&#10;If your sanoid snapshots the whole pool, &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L52-L59" target="_blank" rel="noopener"&gt;exclude the tenant tree&lt;/a&gt;: your sanoid must neither snapshot nor prune it, or his next incremental push finds snapshots on the target that he never sent.&#10;Once my friend is on NixOS, he can push with nixpkgs&amp;rsquo; own module:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;programs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;knownHosts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bas-nas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publicKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;syncoid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sshKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/var/lib/syncoid/id_ed25519&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;localSourceAllow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;send&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;hold&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;commonArgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--no-sync-snap&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--compress=none&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--delete-target-snapshots&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--sshoption=StrictHostKeyChecking=yes&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;tank/offsite&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;zfs-tenant-joe@bas-nas:tank/friends/joe/offsite&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;recursive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sendOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;w&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My NAS will push to his with the &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L66-L91" target="_blank" rel="noopener"&gt;same configuration&lt;/a&gt; once he hosts a root for me.&lt;/p&gt;&#10;&lt;p&gt;For his TrueNAS box, every &lt;a href="https://github.com/basnijholt/zfs-tenant/releases" target="_blank" rel="noopener"&gt;release&lt;/a&gt; has a single &lt;code&gt;zfs-tenant.pyz&lt;/code&gt; that runs with the Python TrueNAS already ships, and the &lt;a href="https://zfs-tenant.nijho.lt/getting-started/" target="_blank" rel="noopener"&gt;getting started guide&lt;/a&gt; covers both setups.&#10;Hosting on TrueNAS has only run in VMs so far (&lt;a href="https://github.com/basnijholt/zfs-tenant/issues/4" target="_blank" rel="noopener"&gt;issue #4&lt;/a&gt;).&#10;Sending from TrueNAS is where its built-in tools stop working: replication tasks wrap every remote command in &lt;code&gt;sh -c&lt;/code&gt;, which the gate refuses, so he pushes with &lt;code&gt;zfs send&lt;/code&gt; or syncoid instead.&lt;/p&gt;&#10;&lt;h2 id="knowing-when-it-stops-working"&gt;Knowing when it stops working &lt;a class="anchor" href="#knowing-when-it-stops-working" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;last btrfs machine&lt;/a&gt; taught me a fifth question for any backup: how do I find out when it stops working?&#10;A failed push shows up in &lt;code&gt;systemctl status&lt;/code&gt; on the sending side, which nobody reads.&#10;The check belongs on the receiving side, and it has to look at every pushed dataset separately, because one healthy dataset can hide another that stopped replicating.&#10;That is how I watch my own machines: the NAS &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/replication.nix#L332-L352" target="_blank" rel="noopener"&gt;checks the newest snapshot of every dataset it replicates&lt;/a&gt; every hour and alerts my phone when one is too old.&#10;zfs-tenant does not alert yet; &lt;a href="https://github.com/basnijholt/zfs-tenant/issues/3" target="_blank" rel="noopener"&gt;issue #3&lt;/a&gt; tracks it.&lt;/p&gt;&#10;&lt;h2 id="restoring"&gt;Restoring &lt;a class="anchor" href="#restoring" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The gate also allows &lt;code&gt;zfs send&lt;/code&gt; of his own snapshots, so getting data back is one pipe, run at his house:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh zfs-tenant-joe@bas-nas zfs send -w tank/friends/joe/offsite/photos@autosnap_2026-09-25_00:00:01_daily &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; zfs receive -u tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zfs load-key tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zfs mount tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My photos are a child dataset that inherits its key from an encryption root, and I only push the child.&#10;If my NAS dies and I pull the child back, can I still unlock it without the root?&#10;A throwaway VM says yes: a raw send carries the dataset&amp;rsquo;s own wrapped key, so the child arrives as its own encryption root and opens with the same passphrase.&#10;The one catch: after a &lt;code&gt;zfs change-key&lt;/code&gt;, the next push carries the new wrapping, so the passphrase that matters is the one at the time of the last push, and it has to live somewhere other than the NAS.&#10;Mine does: &lt;a href="https://github.com/basnijholt/zfs-unlock" target="_blank" rel="noopener"&gt;zfs-unlock&lt;/a&gt; keeps the passphrases on a separate device and &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/zfs-unlock.nix" target="_blank" rel="noopener"&gt;unlocks the NAS&lt;/a&gt; after every boot.&lt;/p&gt;&#10;&lt;h2 id="the-first-real-push"&gt;The first real push &lt;a class="anchor" href="#the-first-real-push" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Once &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix" target="_blank" rel="noopener"&gt;my side was deployed&lt;/a&gt;, Joe tried it from his TrueNAS box.&#10;His first move was to see what else his key could do.&#10;The gate logged every attempt on my NAS:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied interactive session&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;ls&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;/usr/bin/bash&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;/bin/bash&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe allowed &amp;#39;zfs receive -s -u tank/friends/joe/zt-test&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The last line is a small encrypted test dataset, sent with &lt;code&gt;zfs send -w&lt;/code&gt; piped into &lt;code&gt;ssh&lt;/code&gt;, without syncoid.&#10;It arrived with &lt;code&gt;keystatus&lt;/code&gt; set to &lt;code&gt;unavailable&lt;/code&gt;.&#10;Then I tried to read it as root on my own machine.&#10;&lt;code&gt;zfs mount&lt;/code&gt; answered &lt;code&gt;cannot mount 'tank/friends/joe/zt-test': dataset is exported to a local zone&lt;/code&gt;, and &lt;code&gt;zfs load-key&lt;/code&gt; with a guessed passphrase gave &lt;code&gt;Key load error: Incorrect key provided&lt;/code&gt;.&#10;The only readable text in the raw stream was ZFS property names and the snapshot name, which is the metadata ZFS leaves unencrypted, as described above.&lt;/p&gt;&#10;&lt;h2 id="tested-like-everything-else"&gt;Tested like everything else &lt;a class="anchor" href="#tested-like-everything-else" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Every security claim in this post is also checked by a VM test: two NixOS machines back each other up, the way my friend and I do, with real sanoid and syncoid 2.3.0, through real sshd and the gate, into real OpenZFS 2.4.4.&#10;It also checks what happens over time, such as snapshot retention and both machines rebooting, and it runs on every push in GitHub Actions.&lt;/p&gt;&#10;&lt;p&gt;The route to the &lt;a href="https://github.com/jimsalterjrs/sanoid/pull/1114" target="_blank" rel="noopener"&gt;remote code execution bug in syncoid&lt;/a&gt; was slightly absurd.&#10;I built the initial version of zfs-tenant through coding agents, literally from the bathtub, and then asked several frontier models to review the security boundary independently.&#10;One of them followed receiver-controlled data beyond my new code and into syncoid.&#10;A model&amp;rsquo;s suspicion is not evidence, so I turned it into the two-VM reproduction above: it failed on unpatched syncoid 2.3.0 and passed after the seven-line fix.&#10;The most consequential result of reviewing my small new project was a command-injection bug in the mature tool next to it.&lt;/p&gt;&#10;&lt;p&gt;The whole thing is about 930 lines of Python, not counting comments and docstrings, with no dependencies.&#10;The kernel does the actual enforcing: delegation, the quota, and the zone.&#10;The gate only removes the shell, and it is small enough to read in one sitting.&lt;/p&gt;&#10;&lt;h2 id="ai-scope-creep-or-useful"&gt;AI scope creep or useful? &lt;a class="anchor" href="#ai-scope-creep-or-useful" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The first version had &lt;em&gt;grace holds&lt;/em&gt;: every day, the host placed a hold on the newest snapshot of each of my friend&amp;rsquo;s datasets and released it 14 days later.&#10;ZFS will not destroy a held snapshot until the hold is released, and only the host can release it.&#10;The agent built it without me asking for it, so I removed it right away as scope creep.&lt;/p&gt;&#10;&lt;p&gt;Then every model I asked to review the project suggested them again, each in its own context window: Opus 5.5, GPT-6 Astra, and even the one that reviewed a draft of this post.&#10;Joe and I had set out to protect against a machine dying (a fire or a flood), not against one of our own machines being hacked.&#10;But I am tempted to add the holds back.&#10;They cover the case where malware or an attacker gets onto my machine and uses my key to delete my backups on his NAS, or to replace them with garbage.&#10;Adding the feature without asking was still wrong; the feature itself might not be.&lt;/p&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/zfs-tenant" target="_blank" rel="noopener"&gt;zfs-tenant on GitHub&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://zfs-tenant.nijho.lt" target="_blank" rel="noopener"&gt;zfs-tenant documentation&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/truenas-remote-backups/"&gt;Friend-to-friend TrueNAS backups without the cloud&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;Migrating from TrueNAS to NixOS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;The last btrfs machine: migrating my PC to ZFS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-allow.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-allow(8)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-zone.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-zone(8)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/jimsalterjrs/sanoid" target="_blank" rel="noopener"&gt;Sanoid and syncoid&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>My declarative multi-machine homelab, explained</title><link>https://www.nijho.lt/post/homelab-explained/</link><pubDate>Mon, 28 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/homelab-explained/</guid><description>&lt;p&gt;I open &lt;code&gt;https://mealie.lab.nijho.lt&lt;/code&gt; on my phone to look up a recipe.&#10;It works at home, on a train, or on hotel Wi-Fi in another country, over an encrypted HTTPS connection.&#10;If a stranger on the internet tries the same address, they get nothing.&#10;Nearly everything I run works this way: about 150 containers on four machines, used every day by seven people besides me.&#10;How that works is the thing friends actually ask me about, and it is what my &lt;a href="https://www.nijho.lt/post/homelab/"&gt;2024 homelab post&lt;/a&gt; never explained.&lt;/p&gt;&#10;&lt;p&gt;That post was a story about hardware: a NUC, an HP EliteDesk, a &lt;a href="https://www.truenas.com/" target="_blank" rel="noopener"&gt;TrueNAS&lt;/a&gt; box, and a lot of trial and error.&#10;Almost everything in it has since been replaced.&#10;&lt;a href="https://www.proxmox.com/" target="_blank" rel="noopener"&gt;Proxmox&lt;/a&gt; and TrueNAS are gone, and every machine runs &lt;a href="https://nixos.org/" target="_blank" rel="noopener"&gt;NixOS&lt;/a&gt;, &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;including the NAS&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The part I think is the coolest is the balance I found: everything is declarative, but with as little machinery as possible.&#10;&lt;strong&gt;Declarative&lt;/strong&gt; means I describe the end result in text files, and a tool makes reality match it.&#10;One extreme is what I had before, clicking through web UIs and running one-off install scripts.&#10;The other is &lt;a href="https://kubernetes.io/" target="_blank" rel="noopener"&gt;Kubernetes&lt;/a&gt;, which many self-hosted projects don&amp;rsquo;t support and which is a lot to babysit at home, or running every app as a NixOS module, which often lags behind upstream.&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;&#10;I landed in between: NixOS declares the machines, each project&amp;rsquo;s own &lt;a href="https://docs.docker.com/compose/" target="_blank" rel="noopener"&gt;Compose&lt;/a&gt; file declares its app, and &lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt;, a thin tool I wrote, decides which machine runs what, which is all the multi-host orchestration I need.&#10;Every layer is a text file in &lt;a href="https://git-scm.com/" target="_blank" rel="noopener"&gt;git&lt;/a&gt;, each uses the simplest tool that keeps it that way, and I still get new app releases as soon as upstream ships them.&#10;The price is that nothing fails over automatically, but because every machine sees the same files and data, moving services to another machine is a one-line change and one command.&lt;/p&gt;&#10;&lt;p&gt;It took me several years and many iterations to get here.&#10;This is the first version I would recommend to friends, because its declarative configuration makes changes auditable, reproducible, and reversible.&#10;That is what makes me comfortable managing it with AI: I can review the diff, rebuild the same configuration, and revert a change if needed.&lt;/p&gt;&#10;&lt;p&gt;This post tries to explain it all.&#10;I wrote it for people who have never set up a reverse proxy or a VPN, so every piece gets a short explanation before I show how I configured it.&#10;I deliberately made it comprehensive, with enough detail to build something similar yourself.&#10;That also makes it long, so read the parts you find interesting and skip the rest.&lt;/p&gt;&#10;&lt;p&gt;If it is too long, send it to your AI agent, discuss it, and figure out together which parts make sense for your own network.&lt;/p&gt;&#10;&lt;p&gt;If you have a single machine that already runs NixOS, you probably don&amp;rsquo;t need &lt;a href="https://www.docker.com/" target="_blank" rel="noopener"&gt;Docker&lt;/a&gt; or compose-farm at all: adding your services as NixOS modules is simpler, and that is what I recommend to friends in that situation.&#10;If you would rather use Docker, which is usually the path a project officially supports, I recommend compose-farm: it works just as well on a single host and lets you fan out later.&#10;The multi-machine part is where my setup really pays off, but the rest of this post, from the reverse proxy to the VPNs, is just as useful on one machine.&lt;/p&gt;&#10;&lt;p&gt;Throughout the post I use a few services as running examples: &lt;a href="https://mealie.io/" target="_blank" rel="noopener"&gt;Mealie&lt;/a&gt; for recipes, &lt;a href="https://ntfy.sh/" target="_blank" rel="noopener"&gt;ntfy&lt;/a&gt; for push notifications, and my git server at &lt;code&gt;git.nijho.lt&lt;/code&gt;.&lt;/p&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#the-big-picture"&gt;The big picture&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-machines-and-nixos"&gt;The machines, and NixOS&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-nixos-brings"&gt;What NixOS brings&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#deploying-nixos-with-comin"&gt;Deploying NixOS with comin&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-runs-on-it"&gt;What runs on it&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#containers-and-compose-farm"&gt;Containers and compose-farm&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-a-container-is"&gt;What a container is&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#upstreams-compose-file-my-folders"&gt;Upstream&amp;rsquo;s Compose file, my folders&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-i-wrote-compose-farm"&gt;Why I wrote compose-farm&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-front-door-a-reverse-proxy"&gt;The front door: a reverse proxy&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-a-reverse-proxy-brings"&gt;What a reverse proxy brings&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#services-announce-themselves"&gt;Services announce themselves&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#services-on-other-machines"&gt;Services on other machines&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#https-and-certificates"&gt;HTTPS and certificates&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-a-certificate-is"&gt;What a certificate is&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#proving-i-own-the-domain-without-opening-my-network"&gt;Proving I own the domain without opening my network&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#dns-the-phone-book"&gt;DNS: the phone book&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#what-dns-does"&gt;What DNS does&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#three-answers-for-the-same-name"&gt;Three answers for the same name&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#four-ways-in"&gt;Four ways in&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#1-the-home-network"&gt;1. The home network&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-wireguard-on-the-router"&gt;2. WireGuard on the router&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-tailscale-via-my-own-headscale"&gt;3. Tailscale, via my own Headscale&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-both"&gt;Why both?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-tailscale-dns-problem"&gt;The Tailscale DNS problem&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-the-internet"&gt;4. The internet&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#keeping-it-safe-one-allowlist"&gt;Keeping it safe: one allowlist&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#dns-is-not-a-lock"&gt;DNS is not a lock&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#who-is-knocking"&gt;Who is knocking&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#private-by-default"&gt;Private by default&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#trusting-my-own-network"&gt;Trusting my own network&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#sharing-with-friends-and-family"&gt;Sharing with friends and family&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-not-cloudflare-tunnel-or-tailscales-own-server"&gt;Why not Cloudflare Tunnel or Tailscale&amp;rsquo;s own server?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#declarative-everything"&gt;Declarative everything&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#why-it-matters"&gt;Why it matters&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#putting-it-together-adding-a-service"&gt;Putting it together: adding a service&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#lessons-learned"&gt;Lessons learned&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="the-big-picture"&gt;The big picture &lt;a class="anchor" href="#the-big-picture" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;To reach my services away from home, I use a &lt;strong&gt;VPN&lt;/strong&gt; (virtual private network): an encrypted connection that lets my devices communicate across the internet as part of a private network.&#10;Three projects fit together here:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.wireguard.com/" target="_blank" rel="noopener"&gt;WireGuard&lt;/a&gt;&lt;/strong&gt; provides the encrypted tunnels. I use it directly between my devices and my home router.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://tailscale.com/" target="_blank" rel="noopener"&gt;Tailscale&lt;/a&gt;&lt;/strong&gt; builds a private network using WireGuard tunnels. Its apps handle finding and connecting to the other devices.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://headscale.net/" target="_blank" rel="noopener"&gt;Headscale&lt;/a&gt;&lt;/strong&gt; replaces the coordination server that Tailscale normally hosts. I run that server myself, while my devices run the normal Tailscale apps. Headscale distributes device information and access rules; the apps carry the traffic.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;That gives me two VPN options: WireGuard to my router for access to my home network, or Tailscale coordinated by Headscale for sharing selected services and connecting from networks that block direct WireGuard.&#10;Both use WireGuard encryption.&lt;/p&gt;&#10;&lt;p&gt;This is the whole setup in one diagram.&#10;Every box gets its own section below.&lt;/p&gt;&#10;&lt;div class="mermaid"&gt;flowchart LR&#10; home["At home&lt;br/&gt;(home network)"]&#10; wg["Away&lt;br/&gt;(WireGuard)"]&#10; ts["Away&lt;br/&gt;(Tailscale)"]&#10; friends["Friends and family&lt;br/&gt;(Tailscale)"]&#10; net["Anyone&lt;br/&gt;(internet)"]&#10;&#10; subgraph router["Home router"]&#10; wgs["WireGuard server"]&#10; fwd["Port 443 forward"]&#10; end&#10;&#10; home --&gt; traefik&#10; wg --&gt; wgs --&gt; traefik&#10; ts -- "encrypted tunnel&lt;br/&gt;straight to the NAS" --&gt; traefik&#10; net --&gt; fwd -- "wan entrypoint:&lt;br/&gt;public routes only" --&gt; traefik&#10;&#10; traefik{{"Traefik&lt;br/&gt;reverse proxy&lt;br/&gt;+ IP allowlist"}}&#10; traefik --&gt; nas["Containers on nas"]&#10; traefik --&gt; nuc["Containers on nuc"]&#10; traefik --&gt; hp["Containers on hp"]&#10; traefik --&gt; pc["Containers on pc"]&#10;&#10; friends -. "ACL: only the ports&lt;br/&gt;shared with them" .-&gt; nas&#10; friends -.-&gt; pc&lt;/div&gt;&#10;&lt;p&gt;In short:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Four machines&lt;/strong&gt; run all my services as containers.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;compose-farm&lt;/strong&gt; decides which service runs on which machine.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One reverse proxy, &lt;a href="https://traefik.io/traefik/" target="_blank" rel="noopener"&gt;Traefik&lt;/a&gt;,&lt;/strong&gt; is the front door for every service on every machine.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://letsencrypt.org/" target="_blank" rel="noopener"&gt;Let&amp;rsquo;s Encrypt&lt;/a&gt; certificates&lt;/strong&gt; give every service real HTTPS, including the private ones.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;DNS&lt;/strong&gt; turns names like &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; into addresses, and gives a &lt;em&gt;different&lt;/em&gt; answer depending on where I am.&lt;/li&gt;&#10;&lt;li&gt;There are &lt;strong&gt;four ways in&lt;/strong&gt;: my home network, WireGuard on my router, Tailscale coordinated by my own Headscale server, and the open internet.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One allowlist&lt;/strong&gt; in Traefik limits every service to the first three by default. A service reaches the internet only if its config asks for it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Headscale ACLs&lt;/strong&gt; decide which of my friends and family can reach which service.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;NixOS, Compose files, and &lt;a href="https://www.terraform.io/" target="_blank" rel="noopener"&gt;Terraform&lt;/a&gt;&lt;/strong&gt; describe nearly all of it in git, so I rarely click a button, and AI agents can work on it the same way I do.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;If that list looks overwhelming, I get it.&#10;A reverse proxy, Let&amp;rsquo;s Encrypt, DNS, an allowlist, ACLs, NixOS, compose-farm, Terraform: that is a lot of moving parts for something that serves recipes.&#10;What lets me sleep at night is the last point (9).&#10;Apart from a handful of router settings, every piece of configuration is declarative and lives in git, so the whole setup is reproducible.&#10;If I break something, or more likely, an AI agent breaks something, &lt;code&gt;git log&lt;/code&gt; tells me what changed and &lt;code&gt;git revert&lt;/code&gt; undoes it.&#10;And the NAS takes a &lt;a href="https://openzfs.org/" target="_blank" rel="noopener"&gt;ZFS&lt;/a&gt; snapshot of all app data every 10 minutes, so even if something goes badly wrong, I lose at most ten minutes of data.&#10;You don&amp;rsquo;t need to understand every piece at once, either; each one is a file you can read when you get to it.&#10;I come back to this in &lt;a href="#declarative-everything"&gt;Declarative everything&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;To make the list concrete, this is what happens when I open Mealie in three situations:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;At home,&lt;/strong&gt; my phone asks my home DNS server for &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; and gets &lt;code&gt;192.168.1.6&lt;/code&gt;, the NAS. Traefik sees a request from &lt;code&gt;192.168.1.x&lt;/code&gt;, which is on the allowlist, and passes it to the Mealie container.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;On hotel Wi-Fi that blocks WireGuard,&lt;/strong&gt; my laptop uses Tailscale instead. It asks Headscale&amp;rsquo;s DNS for the same name and gets &lt;code&gt;100.64.0.28&lt;/code&gt;, the address of the NAS &lt;em&gt;inside&lt;/em&gt; my Tailscale network. The request travels through an encrypted tunnel straight to the NAS. Traefik sees a &lt;code&gt;100.64.0.x&lt;/code&gt; address, also on the allowlist, and passes it on.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A stranger on the internet&lt;/strong&gt; gets &lt;code&gt;192.168.1.6&lt;/code&gt; from public DNS, which is a private address that leads nowhere outside my home. If they find my home IP and connect to it directly, the router hands them to Traefik&amp;rsquo;s separate door for the internet, where Mealie doesn&amp;rsquo;t exist, and they get &lt;code&gt;404 Not Found&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Same name, HTTPS on all three paths, and only the stranger is refused.&lt;/p&gt;&#10;&lt;h2 id="the-machines-and-nixos"&gt;The machines, and NixOS &lt;a class="anchor" href="#the-machines-and-nixos" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Four machines run my services:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Machine&lt;/th&gt;&#10;&lt;th&gt;What it is&lt;/th&gt;&#10;&lt;th&gt;What it does&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/nas" target="_blank" rel="noopener"&gt;&lt;code&gt;nas&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;The physical NAS with all the disks&lt;/td&gt;&#10;&lt;td&gt;Traefik and most services run in an &lt;a href="https://linuxcontainers.org/incus/" target="_blank" rel="noopener"&gt;Incus&lt;/a&gt; system container, close to the data&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/nuc" target="_blank" rel="noopener"&gt;&lt;code&gt;nuc&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;A small, always-on Intel NUC&lt;/td&gt;&#10;&lt;td&gt;Home DNS server and a few lightweight services&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/hp" target="_blank" rel="noopener"&gt;&lt;code&gt;hp&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;An HP EliteDesk&lt;/td&gt;&#10;&lt;td&gt;Second home DNS server and a mix of services&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/pc" target="_blank" rel="noopener"&gt;&lt;code&gt;pc&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;My desktop with two RTX 3090s&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.nijho.lt/post/llama-nixos/"&gt;Local AI&lt;/a&gt;, including my dictation server&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Each name links to that machine&amp;rsquo;s NixOS configuration.&#10;The hardware of the NUC, the HP, and the NAS is in &lt;a href="https://www.nijho.lt/post/homelab/"&gt;my original homelab post&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;That system container is called &lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/docker-lxc" target="_blank" rel="noopener"&gt;&lt;code&gt;docker-lxc&lt;/code&gt;&lt;/a&gt;.&#10;It holds my Docker stack files and runs my Docker containers.&#10;In compose-farm, I call it &lt;code&gt;nas&lt;/code&gt;, so deployment commands using that name target the container on the physical NAS.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;Why the NAS runs its containers inside another container (click to unfold)&lt;/summary&gt;&#10; Incus uses &lt;a href="https://linuxcontainers.org/lxc/" target="_blank" rel="noopener"&gt;LXC&lt;/a&gt; to run system containers.&#10;This keeps Docker and its services separate from the NAS host that manages my disks.&#10;That container is a full NixOS system of its own, configured like the other machines.&#10;&lt;/details&gt;&#10;&#10;&lt;h3 id="what-nixos-brings"&gt;What NixOS brings &lt;a class="anchor" href="#what-nixos-brings" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;All four physical machines run &lt;a href="https://nixos.org/" target="_blank" rel="noopener"&gt;NixOS&lt;/a&gt;, and so does the &lt;code&gt;docker-lxc&lt;/code&gt; container.&#10;In NixOS, the &lt;em&gt;entire&lt;/em&gt; operating system is described in text files: which packages are installed, which services run, which disks get mounted, which firewall ports are open.&#10;You don&amp;rsquo;t install things by typing commands and hoping you remember them later.&#10;You write down what the machine should look like, and NixOS makes it so.&#10;If a machine dies, I install NixOS on a new one, point it at the same files, and get the same machine back.&lt;/p&gt;&#10;&lt;p&gt;My NixOS configuration lives in &lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;my public dotfiles&lt;/a&gt;, in &lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos" target="_blank" rel="noopener"&gt;&lt;code&gt;configs/nixos&lt;/code&gt;&lt;/a&gt;.&#10;A shared layer applies to every machine, and each machine adds its own specifics.&#10;Every machine joins my Tailscale network with one line in &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/common/services.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;common/services.nix&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tailscale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The NUC, the HP, and the PC mount the same shared folders from the NAS over NFS, a protocol for sharing folders over the network.&#10;That shared folder, &lt;code&gt;/opt/stacks&lt;/code&gt;, is what makes the next section work: every machine sees the same service definitions at the same path.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;The NFS mount in NixOS (click to unfold)&lt;/summary&gt;&#10; &lt;p&gt;From &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/optional/nfs-docker.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;optional/nfs-docker.nix&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;fileSystems&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/opt/stacks&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;truenas.local:/mnt/ssd/docker/stacks&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fsType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;nfs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;nfsvers=4&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;nofail&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;bg&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;soft&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;docker-lxc&lt;/code&gt; doesn&amp;rsquo;t need NFS; the NAS hands it the same folders directly from its disks (&lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/hosts/nas/virtualization.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;hosts/nas/virtualization.nix&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;h3 id="deploying-nixos-with-comin"&gt;Deploying NixOS with comin &lt;a class="anchor" href="#deploying-nixos-with-comin" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Changing a NixOS machine normally means logging in and running &lt;code&gt;nixos-rebuild switch&lt;/code&gt;.&#10;I don&amp;rsquo;t do that anymore.&#10;Every machine runs &lt;a href="https://github.com/nlewo/comin" target="_blank" rel="noopener"&gt;comin&lt;/a&gt;, a small GitOps agent.&#10;It watches my dotfiles on GitHub, and when a new commit lands on &lt;code&gt;main&lt;/code&gt;, each machine pulls it, builds its own configuration, and switches to it.&#10;The heavy builds come from &lt;a href="https://www.nijho.lt/post/nixos-cache/"&gt;my local build cache&lt;/a&gt;, so the machines themselves rarely compile anything.&#10;comin only deploys commits signed with my SSH key, so being able to push to the repo is not enough to take over my machines.&#10;comin originally only understood &lt;a href="https://gnupg.org/" target="_blank" rel="noopener"&gt;GPG&lt;/a&gt; signatures, so I &lt;a href="https://github.com/nlewo/comin/pull/171" target="_blank" rel="noopener"&gt;added support for SSH-signed commits&lt;/a&gt; upstream.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;The comin config (click to unfold)&lt;/summary&gt;&#10; &lt;p&gt;The core of &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/common/comin.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;common/comin.nix&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;comin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;remotes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;origin&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;https://github.com/basnijholt/dotfiles.git&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;branches&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;main&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;repositorySubdir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;configs/nixos&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sshAllowedSignersPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="n"&gt;cominAllowedSigners&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The last line points comin at the list of keys whose signatures it accepts.&lt;/p&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;p&gt;Changing a machine means pushing a signed commit.&#10;That comes back at the end of this post.&lt;/p&gt;&#10;&lt;h2 id="what-runs-on-it"&gt;What runs on it &lt;a class="anchor" href="#what-runs-on-it" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;As I write this, the four machines run 138 containers. A small selection:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Area&lt;/th&gt;&#10;&lt;th&gt;Services&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://traefik.io/traefik/" target="_blank" rel="noopener"&gt;Traefik&lt;/a&gt; (reverse proxy), &lt;a href="https://headscale.net/" target="_blank" rel="noopener"&gt;Headscale&lt;/a&gt; and &lt;a href="https://github.com/tale/headplane" target="_blank" rel="noopener"&gt;Headplane&lt;/a&gt; (my tailnet), &lt;a href="https://forgejo.org/" target="_blank" rel="noopener"&gt;Forgejo&lt;/a&gt; (git), the &lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt; web UI, &lt;a href="https://gethomepage.dev/" target="_blank" rel="noopener"&gt;Homepage&lt;/a&gt; (dashboard), &lt;a href="https://github.com/coder/code-server" target="_blank" rel="noopener"&gt;code-server&lt;/a&gt; (&lt;a href="https://code.visualstudio.com/" target="_blank" rel="noopener"&gt;VS Code&lt;/a&gt; in the browser)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Monitoring&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/louislam/uptime-kuma" target="_blank" rel="noopener"&gt;Uptime Kuma&lt;/a&gt;, &lt;a href="https://nicolargo.github.io/glances/" target="_blank" rel="noopener"&gt;Glances&lt;/a&gt;, &lt;a href="https://www.netdata.cloud/" target="_blank" rel="noopener"&gt;Netdata&lt;/a&gt;, &lt;a href="https://prometheus.io/" target="_blank" rel="noopener"&gt;Prometheus&lt;/a&gt; and &lt;a href="https://grafana.com/" target="_blank" rel="noopener"&gt;Grafana&lt;/a&gt;, &lt;a href="https://dozzle.dev/" target="_blank" rel="noopener"&gt;Dozzle&lt;/a&gt; (container logs), &lt;a href="https://crazymax.dev/diun/" target="_blank" rel="noopener"&gt;Diun&lt;/a&gt; and &lt;a href="https://getwud.github.io/wud/" target="_blank" rel="noopener"&gt;What&amp;rsquo;s Up Docker&lt;/a&gt; (image updates), &lt;a href="https://github.com/librespeed/speedtest" target="_blank" rel="noopener"&gt;LibreSpeed&lt;/a&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Tools&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://ntfy.sh/" target="_blank" rel="noopener"&gt;ntfy&lt;/a&gt; (push notifications), &lt;a href="https://syncthing.net/" target="_blank" rel="noopener"&gt;Syncthing&lt;/a&gt; (file sync), &lt;a href="https://atuin.sh/" target="_blank" rel="noopener"&gt;Atuin&lt;/a&gt; (shell history sync), &lt;a href="https://wakapi.dev/" target="_blank" rel="noopener"&gt;Wakapi&lt;/a&gt; (coding stats)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;agent-cli&lt;/a&gt; and &lt;a href="https://www.nijho.lt/post/diction-agent-cli-qwen/"&gt;Diction&lt;/a&gt; (dictation), &lt;a href="https://speaches.ai/" target="_blank" rel="noopener"&gt;Speaches&lt;/a&gt; (speech-to-text), &lt;a href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener"&gt;Kokoro&lt;/a&gt; (text-to-speech), &lt;a href="https://ollama.com/" target="_blank" rel="noopener"&gt;Ollama&lt;/a&gt;, &lt;a href="https://www.litellm.ai/" target="_blank" rel="noopener"&gt;LiteLLM&lt;/a&gt; (one API in front of all models), &lt;a href="https://openwebui.com/" target="_blank" rel="noopener"&gt;Open WebUI&lt;/a&gt;, &lt;a href="https://www.librechat.ai/" target="_blank" rel="noopener"&gt;LibreChat&lt;/a&gt;, &lt;a href="https://lobehub.com/" target="_blank" rel="noopener"&gt;LobeChat&lt;/a&gt;, &lt;a href="https://khoj.dev/" target="_blank" rel="noopener"&gt;Khoj&lt;/a&gt;, &lt;a href="https://docs.searxng.org/" target="_blank" rel="noopener"&gt;SearXNG&lt;/a&gt; (private metasearch), &lt;a href="https://unsloth.ai/" target="_blank" rel="noopener"&gt;Unsloth&lt;/a&gt; (fine-tuning)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Home and documents&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://mealie.io/" target="_blank" rel="noopener"&gt;Mealie&lt;/a&gt; (recipes), &lt;a href="https://grocy.info/" target="_blank" rel="noopener"&gt;Grocy&lt;/a&gt; (household inventory), &lt;a href="https://docs.paperless-ngx.com/" target="_blank" rel="noopener"&gt;Paperless-ngx&lt;/a&gt; with &lt;a href="https://github.com/clusterzx/paperless-ai" target="_blank" rel="noopener"&gt;paperless-ai&lt;/a&gt; and &lt;a href="https://github.com/icereed/paperless-gpt" target="_blank" rel="noopener"&gt;paperless-gpt&lt;/a&gt;, &lt;a href="https://immich.app/" target="_blank" rel="noopener"&gt;Immich&lt;/a&gt; (photos), &lt;a href="https://nextcloud.com/" target="_blank" rel="noopener"&gt;Nextcloud&lt;/a&gt;, &lt;a href="https://karakeep.app/" target="_blank" rel="noopener"&gt;Hoarder&lt;/a&gt; (bookmarks), &lt;a href="https://www.home-assistant.io/" target="_blank" rel="noopener"&gt;Home Assistant&lt;/a&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Chat&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://cinny.in/" target="_blank" rel="noopener"&gt;Cinny&lt;/a&gt; (&lt;a href="https://matrix.org/" target="_blank" rel="noopener"&gt;Matrix&lt;/a&gt; client for &lt;a href="https://www.nijho.lt/post/mindroom/"&gt;MindRoom&lt;/a&gt;), &lt;a href="https://thelounge.chat/" target="_blank" rel="noopener"&gt;The Lounge&lt;/a&gt; (IRC)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;The GPU-heavy AI services, like dictation, text-to-speech, and fine-tuning, run on &lt;code&gt;pc&lt;/code&gt;; the &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;local AI post&lt;/a&gt; covers that side.&#10;Home Assistant runs as a virtual machine in Incus on the HP; everything else in the table is a container.&lt;/p&gt;&#10;&lt;h2 id="containers-and-compose-farm"&gt;Containers and compose-farm &lt;a class="anchor" href="#containers-and-compose-farm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="what-a-container-is"&gt;What a container is &lt;a class="anchor" href="#what-a-container-is" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Almost everything I run is a &lt;a href="https://www.docker.com/" target="_blank" rel="noopener"&gt;Docker&lt;/a&gt; container.&#10;A container is an application packaged with everything it needs, isolated from the rest of the system.&#10;Mealie&amp;rsquo;s developers publish a Mealie image, I run it, it doesn&amp;rsquo;t care what else is installed on the machine, and removing it leaves nothing behind.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://docs.docker.com/compose/" target="_blank" rel="noopener"&gt;Docker Compose&lt;/a&gt; describes containers in a YAML file: which image, which folders it can see, which ports it uses, which settings.&#10;This is a trimmed version of my Mealie definition:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;mealie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ghcr.io/mealie-recipes/mealie&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;mealie&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="m"&gt;9925&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;9000&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;/mnt/data/mealie:/app/data/&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ALLOW_SIGNUP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;https://mealie.${DOMAIN}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;unless-stopped&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A &lt;strong&gt;port&lt;/strong&gt; is a number that tells apart the network services on one machine, so one address can serve many apps.&#10;Under &lt;code&gt;ports&lt;/code&gt;, &lt;code&gt;9925:9000&lt;/code&gt; means that port 9925 on the machine leads to port 9000 inside the container, where Mealie listens.&lt;/p&gt;&#10;&lt;p&gt;Every service (&amp;ldquo;stack&amp;rdquo;) gets its own folder with a &lt;code&gt;compose.yaml&lt;/code&gt; in one git repo, mounted at &lt;code&gt;/opt/stacks&lt;/code&gt; on every machine.&lt;/p&gt;&#10;&lt;h3 id="upstreams-compose-file-my-folders"&gt;Upstream&amp;rsquo;s Compose file, my folders &lt;a class="anchor" href="#upstreams-compose-file-my-folders" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Back when I ran Proxmox, I was a big fan of the &lt;a href="https://community-scripts.github.io/ProxmoxVE/" target="_blank" rel="noopener"&gt;Proxmox VE Helper-Scripts&lt;/a&gt;, started by tteck (&lt;a href="https://github.com/community-scripts/ProxmoxVE/discussions/237" target="_blank" rel="noopener"&gt;RIP&lt;/a&gt;) and now maintained by the community.&#10;Each one is a one-liner that creates an LXC container with an app installed inside, and they are how I &lt;a href="https://www.nijho.lt/post/homelab/"&gt;got into self-hosting&lt;/a&gt; and set up almost everything at first.&lt;/p&gt;&#10;&lt;p&gt;The catch is that they are a community effort, and the way they install an app is usually not a way the app&amp;rsquo;s own developers support.&#10;&lt;a href="https://immich.app/" target="_blank" rel="noopener"&gt;Immich&lt;/a&gt; shows the problem well: its docs say it requires Docker with the Docker Compose plugin, while today&amp;rsquo;s helper script builds Immich from source, installs &lt;a href="https://www.postgresql.org/" target="_blank" rel="noopener"&gt;PostgreSQL&lt;/a&gt; and &lt;a href="https://redis.io/" target="_blank" rel="noopener"&gt;Redis&lt;/a&gt;, and compiles six image-processing libraries directly in the container.&#10;That is effectively a mirror of the official setup, maintained by someone else; the script even pins the Immich version and only bumps it after testing each release.&#10;Every upgrade depends on a second set of maintainers keeping up with upstream, and for my containers that made upgrades painful and stressful.&lt;/p&gt;&#10;&lt;p&gt;Nowadays almost every project publishes a Docker Compose file, so that&amp;rsquo;s what I use, nearly unmodified.&#10;After the first setup, the Compose file almost never changes; the image does.&#10;Most of my services use the &lt;code&gt;latest&lt;/code&gt; tag, and upgrading is &lt;code&gt;cf update &amp;lt;stack&amp;gt;&lt;/code&gt; (or &lt;code&gt;--all&lt;/code&gt;), which pulls the newest image and recreates the container.&lt;/p&gt;&#10;&lt;p&gt;That is YOLOing it a bit.&#10;In the beginning I pinned every image to an exact digest, because I was afraid a new version would break something.&#10;Bumping all those pins became its own chore, so I switched almost everything to &lt;code&gt;latest&lt;/code&gt;.&#10;I only still pin the pieces where a surprise would really hurt: Traefik, Headscale, Forgejo, and databases, which stay on a major version like &lt;code&gt;postgres:16&lt;/code&gt; because moving to a new major version of PostgreSQL needs a manual migration.&#10;Since the switch I haven&amp;rsquo;t had real problems, and the rare breakage took a five-word prompt to an AI agent to fix.&lt;/p&gt;&#10;&lt;p&gt;The one thing I do change is where the data goes.&#10;Many Compose files keep data in named Docker volumes, which Docker manages somewhere under &lt;code&gt;/var/lib/docker/volumes&lt;/code&gt;.&#10;I stopped using them: too many times I deleted a volume by accident (&lt;code&gt;docker compose down -v&lt;/code&gt; and &lt;code&gt;docker volume prune&lt;/code&gt; make that easy), and I never had a good view of what was inside.&#10;Instead, every stack gets plain folders, which Docker calls bind mounts, in two fixed places:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;/opt/stacks/&amp;lt;stack&amp;gt;/&lt;/code&gt; holds the &lt;code&gt;compose.yaml&lt;/code&gt; and its &lt;code&gt;.env&lt;/code&gt;, in git.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;/mnt/data/&amp;lt;stack&amp;gt;/&lt;/code&gt; holds the app&amp;rsquo;s data.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Each &lt;code&gt;/mnt/data/&amp;lt;stack&amp;gt;&lt;/code&gt; is its own ZFS dataset on the NAS, with frequent snapshots and &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;backups&lt;/a&gt;.&#10;The Mealie example above shows the swap: upstream&amp;rsquo;s file uses a volume called &lt;code&gt;mealie-data&lt;/code&gt;, and mine says &lt;code&gt;/mnt/data/mealie:/app/data/&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="why-i-wrote-compose-farm"&gt;Why I wrote compose-farm &lt;a class="anchor" href="#why-i-wrote-compose-farm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/homelab-explained/compose-farm.svg" width="420" alt="Animated compose-farm logo" loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;p&gt;Docker Compose manages containers on &lt;em&gt;one&lt;/em&gt; machine, and I have four.&#10;The usual answers are Kubernetes or &lt;a href="https://docs.docker.com/engine/swarm/" target="_blank" rel="noopener"&gt;Docker Swarm&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;Kubernetes feels like overkill for a homelab, and many projects don&amp;rsquo;t support it: they publish a Compose file, not a &lt;a href="https://helm.sh/" target="_blank" rel="noopener"&gt;Helm&lt;/a&gt; chart (Kubernetes&amp;rsquo; package format), so I would end up writing and maintaining my own charts.&#10;That&amp;rsquo;s the helper-script problem again.&lt;/p&gt;&#10;&lt;p&gt;I also &lt;a href="https://github.com/basnijholt/dotfiles/pull/21" target="_blank" rel="noopener"&gt;experimented with Docker Swarm&lt;/a&gt;, writing a NixOS configuration for a three-manager cluster.&#10;Docker Swarm would meet my needs almost perfectly, but it is effectively in maintenance mode: it still ships with Docker, but Docker no longer invests in it.&lt;/p&gt;&#10;&lt;p&gt;So I wrote &lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt;.&#10;It is deliberately thin: one YAML file says which stack runs on which machine, and the &lt;code&gt;cf&lt;/code&gt; command SSHes to that machine and runs plain &lt;code&gt;docker compose&lt;/code&gt; there.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;compose_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/opt/stacks&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;hosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;nas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.6&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;nuc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;stacks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;traefik&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nas&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;headscale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nas&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;forgejo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nas&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;mealie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nas&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ntfy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;nuc&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;agent-cli&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;pc&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;glances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;all &lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# runs on every machine&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;traefik_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/opt/stacks/traefik/dynamic.d/compose-farm.yml&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Day to day, that looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf up mealie &lt;span class="c1"&gt;# start Mealie on whichever machine the config says&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf logs -f ntfy &lt;span class="c1"&gt;# follow logs, wherever ntfy runs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf ps &lt;span class="c1"&gt;# what is running where&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf apply &lt;span class="c1"&gt;# make reality match the config: start, move, and stop stacks&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Moving a service to another machine is a one-line change.&#10;I edit &lt;code&gt;mealie: nas&lt;/code&gt; to &lt;code&gt;mealie: hp&lt;/code&gt; and run &lt;code&gt;cf up mealie&lt;/code&gt;, and compose-farm stops it on the old machine and starts it on the new one.&#10;Because every machine sees the same &lt;code&gt;/opt/stacks&lt;/code&gt; and &lt;code&gt;/mnt/data&lt;/code&gt;, the service finds its configuration and data waiting for it.&lt;/p&gt;&#10;&lt;p&gt;That is also my answer to &amp;ldquo;what if a machine dies?&amp;rdquo;&#10;There is no automatic failover: nothing notices and reschedules the services for me, the way Kubernetes would.&#10;But because no stack is tied to a machine, recovering by hand takes a minute.&#10;The NAS is the exception: it holds the data and runs Traefik, so if it dies, nothing else can take over.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;Moving a dead machine&amp;rsquo;s services by hand (click to unfold)&lt;/summary&gt;&#10; &lt;p&gt;If the NUC died, I would point its name at the HP:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;hosts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;nas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.6&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;nuc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# was 192.168.1.2; the NUC is dead, so the HP takes over&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;hp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;{&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192.168.1.5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;}&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then &lt;code&gt;cf up --host nuc&lt;/code&gt; starts every NUC stack on the HP and updates Traefik&amp;rsquo;s routes, and I can clean up the names later.&#10;(Not &lt;code&gt;cf apply&lt;/code&gt;: while two names point at the same machine, it sees every stack there running twice and stops the &amp;ldquo;extra&amp;rdquo; copy.)&#10;This works for the NUC, the HP, and the PC, as long as the published ports don&amp;rsquo;t clash and a service doesn&amp;rsquo;t need the PC&amp;rsquo;s GPUs.&lt;/p&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;p&gt;There is no cluster, no database, and no agent running on each machine.&#10;If compose-farm disappeared tomorrow, every stack would still be a normal Compose folder I could start by hand.&lt;/p&gt;&#10;&lt;p&gt;The last line of that config, &lt;code&gt;traefik_file&lt;/code&gt;, is where compose-farm connects to the front door.&lt;/p&gt;&#10;&lt;h2 id="the-front-door-a-reverse-proxy"&gt;The front door: a reverse proxy &lt;a class="anchor" href="#the-front-door-a-reverse-proxy" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="what-a-reverse-proxy-brings"&gt;What a reverse proxy brings &lt;a class="anchor" href="#what-a-reverse-proxy-brings" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Every service listens on its own port on its own machine: Mealie on port 9925 of the NAS, ntfy on port 8089 of the NUC, and so on.&#10;I could bookmark &lt;code&gt;http://192.168.1.2:8089&lt;/code&gt;, but that gets annoying fast, it is unencrypted, and every service would need its own security.&lt;/p&gt;&#10;&lt;p&gt;A &lt;strong&gt;reverse proxy&lt;/strong&gt; is a receptionist in front of all of them.&#10;Every request arrives there first.&#10;It looks at which name you asked for, &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; or &lt;code&gt;ntfy.lab.nijho.lt&lt;/code&gt;, and passes the request to the right service on the right machine.&#10;Because everything goes through it, the reverse proxy is also the one place to:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;handle HTTPS and certificates, so the services don&amp;rsquo;t have to;&lt;/li&gt;&#10;&lt;li&gt;decide who is allowed in;&lt;/li&gt;&#10;&lt;li&gt;give every service a clean name instead of an IP and a port.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I use &lt;a href="https://traefik.io/traefik/" target="_blank" rel="noopener"&gt;Traefik&lt;/a&gt;.&#10;It runs on the NAS and is the only thing listening on the standard web ports: 80 for plain HTTP and 443 for HTTPS.&lt;/p&gt;&#10;&lt;h3 id="services-announce-themselves"&gt;Services announce themselves &lt;a class="anchor" href="#services-announce-themselves" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;What I like most about Traefik is that services describe their own routing with &lt;strong&gt;labels&lt;/strong&gt; in their Compose file.&#10;These are Mealie&amp;rsquo;s:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.enable=true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# https://mealie.lab.nijho.lt, only for trusted networks&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.mealie.rule=Host(`mealie.${DOMAIN}`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# http://mealie.local, plain HTTP at home&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.mealie-local.rule=Host(`mealie.local`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.mealie-local.entrypoints=web&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.services.mealie.loadbalancer.server.port=9000&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In words: if someone asks for &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; or &lt;code&gt;mealie.local&lt;/code&gt;, send them to port 9000 of this container.&#10;Every service gets two names by convention: an HTTPS name under &lt;code&gt;lab.nijho.lt&lt;/code&gt; that works everywhere, and a short &lt;code&gt;.local&lt;/code&gt; name for quick access at home.&#10;The HTTPS route doesn&amp;rsquo;t mention HTTPS, a certificate, or who is allowed in; Traefik&amp;rsquo;s defaults cover all three.&lt;/p&gt;&#10;&lt;p&gt;Traefik reads those labels by watching Docker on its own machine, and ignores any container that doesn&amp;rsquo;t opt in with &lt;code&gt;traefik.enable=true&lt;/code&gt;.&#10;Its own config sets the defaults:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;providers.docker=true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;providers.docker.exposedbydefault=false&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;providers.file.directory=/dynamic.d&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.web.address=:80&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.web.http.middlewares=local-ips-only@file&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.websecure.address=:443&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.websecure.asDefault=true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.websecure.http.middlewares=local-ips-only@file&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.websecure.http.tls.certresolver=le&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.wan.address=:4443&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;entrypoints.wan.http.tls.certresolver=le&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An &lt;strong&gt;entrypoint&lt;/strong&gt; is a port Traefik listens on.&#10;&lt;code&gt;web&lt;/code&gt; and &lt;code&gt;websecure&lt;/code&gt; are for home, WireGuard, and Tailscale, and a route that doesn&amp;rsquo;t name an entrypoint lands on &lt;code&gt;websecure&lt;/code&gt;.&#10;&lt;code&gt;local-ips-only&lt;/code&gt; is the allowlist, more on that in &lt;a href="#keeping-it-safe-one-allowlist"&gt;Keeping it safe&lt;/a&gt;.&#10;&lt;code&gt;wan&lt;/code&gt; is for the internet, more on that in &lt;a href="#4-the-internet"&gt;The internet&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="services-on-other-machines"&gt;Services on other machines &lt;a class="anchor" href="#services-on-other-machines" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;There is a catch: Traefik only sees Docker containers on &lt;em&gt;its own&lt;/em&gt; machine.&#10;It has no idea that ntfy runs on the NUC.&lt;/p&gt;&#10;&lt;p&gt;compose-farm fills that gap.&#10;It reads the Traefik labels of every stack, looks up which machine the stack runs on and which port it publishes, and writes the result to a file that Traefik watches.&#10;After &lt;code&gt;cf up ntfy&lt;/code&gt;, that file contains:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# /opt/stacks/traefik/dynamic.d/compose-farm.yml&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Auto-generated by compose-farm (only the ntfy entries shown)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;routers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ntfy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Host(`ntfy.lab.nijho.lt`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ntfy&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ntfy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;loadbalancer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;servers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;http://192.168.1.2:8089&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Note the ports: on its own machine, Traefik talks to a container directly on its internal port (9000 for Mealie), but a route to another machine has to use the port that machine publishes (8089 for ntfy).&#10;I never edit that file.&#10;I write labels the same way no matter where a service runs, and when a service moves, its route moves with it.&lt;/p&gt;&#10;&lt;p&gt;Things that don&amp;rsquo;t run in Docker, like &lt;a href="https://www.home-assistant.io/" target="_blank" rel="noopener"&gt;Home Assistant&lt;/a&gt; in its own virtual machine, get hand-written routes in a second file in the same folder.&lt;/p&gt;&#10;&lt;h2 id="https-and-certificates"&gt;HTTPS and certificates &lt;a class="anchor" href="#https-and-certificates" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="what-a-certificate-is"&gt;What a certificate is &lt;a class="anchor" href="#what-a-certificate-is" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;When you visit an &lt;code&gt;https://&lt;/code&gt; address, your browser checks the server&amp;rsquo;s &lt;strong&gt;certificate&lt;/strong&gt;: a small file, signed by an authority your browser trusts, that proves the server really is &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt;.&#10;The certificate is also what makes the encryption between you and the server possible.&#10;Without a valid one you get a scary warning, and many apps refuse to connect at all.&lt;/p&gt;&#10;&lt;figure id="figure-a-private-service-with-a-real-certificate"&gt;&lt;img src="https://www.nijho.lt/post/homelab-explained/padlock_hu_dfe972830de4de20.webp" srcset="https://www.nijho.lt/post/homelab-explained/padlock_hu_2b6f10ce0f5359bf.webp 400w, https://www.nijho.lt/post/homelab-explained/padlock_hu_dfe972830de4de20.webp 760w, https://www.nijho.lt/post/homelab-explained/padlock_hu_42709a581b573413.webp 1030w" sizes="auto, (max-width: 760px) 100vw, 720px" width="760" height="317" alt="Screenshot of Firefox showing https://mealie.lab.nijho.lt/login with the connection panel open. It says &amp;#39;You are securely connected to this site&amp;#39; and &amp;#39;Verified by Let&amp;#39;s Encrypt&amp;#39;. The Mealie login page is partly visible behind the panel." loading="lazy" data-zoomable&gt;&lt;figcaption&gt;A private service with a real certificate&lt;/figcaption&gt;&lt;/figure&gt;&#10;&lt;p&gt;&lt;a href="https://letsencrypt.org/" target="_blank" rel="noopener"&gt;Let&amp;rsquo;s Encrypt&lt;/a&gt; hands out certificates for free, automatically, to anyone who can prove they control a domain.&lt;/p&gt;&#10;&lt;h3 id="proving-i-own-the-domain-without-opening-my-network"&gt;Proving I own the domain without opening my network &lt;a class="anchor" href="#proving-i-own-the-domain-without-opening-my-network" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The usual proof is for Let&amp;rsquo;s Encrypt to &lt;em&gt;connect to your server&lt;/em&gt; and fetch a file from it (the &amp;ldquo;HTTP-01 challenge&amp;rdquo;).&#10;That doesn&amp;rsquo;t work for private services, because the whole point is that the internet can&amp;rsquo;t connect to them.&lt;/p&gt;&#10;&lt;p&gt;So I use the &lt;strong&gt;DNS-01 challenge&lt;/strong&gt; instead.&#10;Let&amp;rsquo;s Encrypt asks me to publish a specific text record in my domain&amp;rsquo;s DNS, the internet&amp;rsquo;s phone book that turns names into addresses (more on that &lt;a href="#dns-the-phone-book"&gt;below&lt;/a&gt;).&#10;My DNS is hosted at &lt;a href="https://www.cloudflare.com/" target="_blank" rel="noopener"&gt;Cloudflare&lt;/a&gt;, so Traefik creates that record through the Cloudflare API, waits for Let&amp;rsquo;s Encrypt to see it, and removes it again.&#10;Let&amp;rsquo;s Encrypt never has to reach my network.&lt;/p&gt;&#10;&lt;p&gt;DNS-01 also lets me request a &lt;strong&gt;wildcard certificate&lt;/strong&gt;: one certificate for &lt;code&gt;*.lab.nijho.lt&lt;/code&gt; that covers every name under it.&#10;A new service has HTTPS the moment its route exists, without requesting anything.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- --&lt;span class="l"&gt;certificatesresolvers.le.acme.dnsChallenge.provider=cloudflare&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.traefik-lab.tls.certresolver=le&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.traefik-lab.tls.domains[0].main=lab.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.traefik-lab.tls.domains[0].sans=*.lab.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Traefik renews it before it expires, and I don&amp;rsquo;t think about certificates anymore.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;.local&lt;/code&gt; names can&amp;rsquo;t get a public certificate, because nobody owns &lt;code&gt;.local&lt;/code&gt;.&#10;Those stay plain HTTP, and Traefik only answers them for devices on my home network.&#10;It is also why I don&amp;rsquo;t redirect HTTP to HTTPS everywhere: that would break every &lt;code&gt;.local&lt;/code&gt; name.&lt;/p&gt;&#10;&lt;h2 id="dns-the-phone-book"&gt;DNS: the phone book &lt;a class="anchor" href="#dns-the-phone-book" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="what-dns-does"&gt;What DNS does &lt;a class="anchor" href="#what-dns-does" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Computers talk to each other with IP addresses like &lt;code&gt;192.168.1.6&lt;/code&gt;.&#10;Humans prefer names like &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt;.&#10;&lt;strong&gt;DNS&lt;/strong&gt;, the Domain Name System, is the phone book that turns one into the other.&#10;Before your browser connects anywhere, it asks a DNS server for the address that belongs to a name.&lt;/p&gt;&#10;&lt;p&gt;Two details matter for the rest of this post.&lt;/p&gt;&#10;&lt;p&gt;First, some addresses are &lt;strong&gt;private&lt;/strong&gt;.&#10;Ranges like &lt;code&gt;192.168.x.x&lt;/code&gt; and &lt;code&gt;10.x.x.x&lt;/code&gt;, and Tailscale&amp;rsquo;s &lt;code&gt;100.64.x.x&lt;/code&gt;, are not reachable on the public internet; they only mean something inside one particular network.&#10;My NAS is &lt;code&gt;192.168.1.6&lt;/code&gt; inside my home, and so is some random device in thousands of other homes.&lt;/p&gt;&#10;&lt;p&gt;Second, the answer depends on &lt;em&gt;which DNS server you ask&lt;/em&gt;.&#10;My setup relies on that: the same name gets a different answer depending on where I am.&lt;/p&gt;&#10;&lt;h3 id="three-answers-for-the-same-name"&gt;Three answers for the same name &lt;a class="anchor" href="#three-answers-for-the-same-name" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Who is asking&lt;/th&gt;&#10;&lt;th&gt;Which DNS server&lt;/th&gt;&#10;&lt;th&gt;Answer for &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt;&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Anyone on the internet&lt;/td&gt;&#10;&lt;td&gt;Public DNS at Cloudflare&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;192.168.1.6&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;A device at home or on WireGuard&lt;/td&gt;&#10;&lt;td&gt;My own DNS servers on &lt;code&gt;nuc&lt;/code&gt; and &lt;code&gt;hp&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;192.168.1.6&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;A device on my Tailscale network&lt;/td&gt;&#10;&lt;td&gt;Headscale&amp;rsquo;s built-in DNS&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;100.64.0.28&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;&lt;strong&gt;Public DNS&lt;/strong&gt; has one wildcard record: &lt;code&gt;*.lab.nijho.lt&lt;/code&gt; points to &lt;code&gt;192.168.1.6&lt;/code&gt;.&#10;Yes, that is a private address in public DNS.&#10;It sounds odd, but it works nicely: at home, or on a tunnel that routes my home network, the public answer is simply &lt;em&gt;correct&lt;/em&gt;.&#10;For everyone else it leads nowhere, and it reveals nothing beyond the fact that I have a machine at &lt;code&gt;192.168.1.6&lt;/code&gt;, like half the internet.&#10;That is convenience, not security; more on that &lt;a href="#dns-is-not-a-lock"&gt;below&lt;/a&gt;.&#10;(Some routers with &amp;ldquo;DNS rebinding protection&amp;rdquo; refuse to pass on answers like this. Mine doesn&amp;rsquo;t.)&lt;/p&gt;&#10;&lt;p&gt;I don&amp;rsquo;t click those records together in Cloudflare&amp;rsquo;s dashboard.&#10;All my public DNS is &lt;a href="https://www.terraform.io/" target="_blank" rel="noopener"&gt;Terraform&lt;/a&gt;: a text file that lists every record, and a tool that makes Cloudflare match it.&#10;These are the lab records:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;cloudflare_dns_record&amp;#34; &amp;#34;lab_wildcard&amp;#34;&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; zone_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;cloudflare_zone_id&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;*.lab.nijho.lt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;192.168.1.6&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;A&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10800&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; proxied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;&lt;span class="c1"&gt; # Private IP: Cloudflare can&amp;#39;t proxy to it&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;terraform plan&lt;/code&gt; shows exactly which records will be added, changed, or removed before anything happens, and &lt;code&gt;terraform apply&lt;/code&gt; makes the change.&#10;The Terraform files live in a private repo, next to my other secrets, because they need a Cloudflare API token.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;My home DNS servers&lt;/strong&gt; run &lt;a href="https://coredns.io/" target="_blank" rel="noopener"&gt;CoreDNS&lt;/a&gt; on the NUC and the HP, declared in &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/optional/coredns.nix" target="_blank" rel="noopener"&gt;&lt;code&gt;optional/coredns.nix&lt;/code&gt;&lt;/a&gt; in the same NixOS repo as everything else.&#10;They answer &lt;code&gt;.local&lt;/code&gt; and &lt;code&gt;lab.nijho.lt&lt;/code&gt; names themselves and forward everything else to public DNS:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;labNijholtZone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pkgs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writeText&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;lab.nijho.lt.zone&amp;#34;&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; $ORIGIN lab.nijho.lt.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; @ 3600 IN A 192.168.1.6&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt; * 3600 IN A 192.168.1.6&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without them, &lt;code&gt;.local&lt;/code&gt; names wouldn&amp;rsquo;t work at all, since public DNS knows nothing about them.&lt;/p&gt;&#10;&lt;p&gt;Strictly speaking, &lt;code&gt;.local&lt;/code&gt; is reserved for multicast DNS, the protocol printers and AirPlay speakers use to announce themselves, and &lt;a href="https://support.apple.com/en-us/101903" target="_blank" rel="noopener"&gt;Apple warns&lt;/a&gt; that its devices might not resolve &lt;code&gt;.local&lt;/code&gt; names served by a regular DNS server.&#10;I know, and I have rarely run into it.&#10;The official name for home networks is &lt;code&gt;home.arpa&lt;/code&gt;, but some browsers treat a name like &lt;code&gt;example.home.arpa&lt;/code&gt; as a search term: Chrome on my phone searched Google instead of opening the site.&#10;I had to type the full address, &lt;code&gt;https://example.home.arpa&lt;/code&gt;, every time.&#10;The &lt;code&gt;.local&lt;/code&gt; names are a shortcut anyway; the &lt;code&gt;lab.nijho.lt&lt;/code&gt; names are the ones that work everywhere.&#10;They also keep lab names working when my internet connection is down.&#10;There are two so that one can reboot without taking name resolution with it.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Headscale&amp;rsquo;s DNS&lt;/strong&gt; is the odd one out, and it needs Tailscale explained first.&lt;/p&gt;&#10;&lt;h2 id="four-ways-in"&gt;Four ways in &lt;a class="anchor" href="#four-ways-in" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;There are four ways a request can reach Traefik:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Way in&lt;/th&gt;&#10;&lt;th&gt;When I use it&lt;/th&gt;&#10;&lt;th&gt;Address Traefik sees&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Home network&lt;/td&gt;&#10;&lt;td&gt;At home, on Wi-Fi or a cable&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;192.168.1.x&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;WireGuard on the router&lt;/td&gt;&#10;&lt;td&gt;My phone, laptop, and travel router when I&amp;rsquo;m away&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;10.6.0.x&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Tailscale, via Headscale&lt;/td&gt;&#10;&lt;td&gt;Friends and family, cloud VMs, and my own devices when WireGuard can&amp;rsquo;t get through&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;100.64.0.x&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;The internet&lt;/td&gt;&#10;&lt;td&gt;Anyone, for the few public services&lt;/td&gt;&#10;&lt;td&gt;Any public address&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;h3 id="1-the-home-network"&gt;1. The home network &lt;a class="anchor" href="#1-the-home-network" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;At home my devices are on &lt;code&gt;192.168.1.x&lt;/code&gt;, the home DNS servers answer, and everything just works.&lt;/p&gt;&#10;&lt;h3 id="2-wireguard-on-the-router"&gt;2. WireGuard on the router &lt;a class="anchor" href="#2-wireguard-on-the-router" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;A &lt;strong&gt;VPN&lt;/strong&gt;, a virtual private network, creates an encrypted tunnel from your device to another network, so your device behaves as if it were plugged in there.&#10;&lt;a href="https://www.wireguard.com/" target="_blank" rel="noopener"&gt;WireGuard&lt;/a&gt; is a modern VPN protocol: small, fast, and based on key pairs instead of usernames and passwords.&lt;/p&gt;&#10;&lt;p&gt;My router, an ASUS ZenWiFi XT8, has a WireGuard server built in.&#10;When I&amp;rsquo;m away, my phone connects to it and gets an address in &lt;code&gt;10.6.0.x&lt;/code&gt;, and from then on it is effectively at home.&#10;It can reach &lt;code&gt;192.168.1.6&lt;/code&gt;, and it uses my home DNS servers.&#10;The WireGuard iOS app has &amp;ldquo;On-Demand&amp;rdquo; rules, so the tunnel turns on by itself whenever I leave my home Wi-Fi.&#10;I wrote more about that in &lt;a href="https://www.nijho.lt/post/agentic-mobile-workflow/"&gt;my post about coding from my phone&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;To find my router, the phone needs my home&amp;rsquo;s public IP address.&#10;My internet provider can change that address whenever it likes, so the router also runs &lt;strong&gt;dynamic DNS (DDNS)&lt;/strong&gt;: it keeps a public hostname pointed at whatever my current IP is.&#10;The WireGuard client connects to that hostname.&lt;/p&gt;&#10;&lt;h3 id="3-tailscale-via-my-own-headscale"&gt;3. Tailscale, via my own Headscale &lt;a class="anchor" href="#3-tailscale-via-my-own-headscale" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;&lt;a href="https://tailscale.com/" target="_blank" rel="noopener"&gt;Tailscale&lt;/a&gt; is a VPN built on WireGuard, but organized differently.&#10;Instead of every device tunneling to one central point, Tailscale builds a &lt;strong&gt;mesh&lt;/strong&gt;: every device can make a direct, encrypted WireGuard connection to every other device.&#10;It gets through firewalls and home routers without opening any ports, and when a direct connection is impossible, it falls back to encrypted relay servers.&#10;Every device gets a stable address in &lt;code&gt;100.64.x.x&lt;/code&gt;, no matter which network it is on.&#10;All those devices together are called a &lt;strong&gt;tailnet&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;A mesh like that needs something that knows which devices exist, what their keys are, and who may talk to whom.&#10;That is the &lt;strong&gt;coordination server&lt;/strong&gt;, which Tailscale normally runs for you.&#10;&lt;a href="https://headscale.net/" target="_blank" rel="noopener"&gt;Headscale&lt;/a&gt; is an open-source coordination server that you run yourself.&#10;The official Tailscale apps for phones, laptops, and Linux all work with it; you point them at your own server instead of Tailscale&amp;rsquo;s.&lt;/p&gt;&#10;&lt;p&gt;Headscale runs as one of my stacks on the NAS, and &lt;a href="https://github.com/tale/headplane" target="_blank" rel="noopener"&gt;Headplane&lt;/a&gt; next to it gives it a web UI.&#10;The coordination server only &lt;em&gt;introduces&lt;/em&gt; devices to each other.&#10;My actual traffic flows directly between devices and never passes through it.&#10;Its configuration, the list of who may reach what, and its DNS records are all files in my stacks repo.&lt;/p&gt;&#10;&lt;h3 id="why-both"&gt;Why both? &lt;a class="anchor" href="#why-both" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Two VPNs look redundant, but they are good at different things.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;WireGuard on the router puts a device on my home network.&lt;/strong&gt;&#10;Adding a client is one entry in the router&amp;rsquo;s settings.&#10;That device can then reach everything at home, from the NAS to the printer to the router itself, at the same addresses and with the same DNS answers as on my home Wi-Fi.&#10;Nothing needs to be installed on the machines at home, and nothing depends on my homelab: if the NAS is down, or I broke something, WireGuard still gets me home so I can fix it.&#10;So my own devices use WireGuard: my iPhone, my laptop, and my travel router, which brings every device connected to it along.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Tailscale puts a device on a separate, virtual network.&lt;/strong&gt;&#10;A device on the tailnet can only reach other devices that run Tailscale themselves.&#10;My home network isn&amp;rsquo;t part of it, which is why the DNS trick below is needed.&#10;In return, it works almost everywhere.&#10;WireGuard to my router only works if the network I&amp;rsquo;m on lets UDP traffic out to my router&amp;rsquo;s port, and some hotel, airport, and office networks don&amp;rsquo;t.&#10;Tailscale doesn&amp;rsquo;t need an open port, and its relays speak HTTPS, which nearly every network allows.&#10;It also connects machines that are all away from home, like my laptop and a cloud VM, directly, without a detour through my house.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Tailscale also has fine-grained access control.&lt;/strong&gt;&#10;On my router, every WireGuard client gets my whole home network.&#10;On the tailnet, Headscale&amp;rsquo;s ACL decides per person and per device what they can reach.&#10;So everything that isn&amp;rsquo;t one of my own devices goes on the tailnet: friends, family, and cloud VMs.&lt;/p&gt;&#10;&lt;h3 id="the-tailscale-dns-problem"&gt;The Tailscale DNS problem &lt;a class="anchor" href="#the-tailscale-dns-problem" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The public answer, &lt;code&gt;192.168.1.6&lt;/code&gt;, works on WireGuard because the tunnel routes my home network.&#10;It doesn&amp;rsquo;t work on Tailscale, because I don&amp;rsquo;t route my home network through the tailnet.&#10;Worse, &lt;code&gt;192.168.1.x&lt;/code&gt; is one of the most common home network ranges in the world, so on a friend&amp;rsquo;s Wi-Fi &lt;code&gt;192.168.1.6&lt;/code&gt; might be their printer.&lt;/p&gt;&#10;&lt;p&gt;The fix is Headscale&amp;rsquo;s built-in DNS, &lt;strong&gt;MagicDNS&lt;/strong&gt;.&#10;Every device on the tailnet asks Headscale&amp;rsquo;s DNS first, and I tell it to answer every lab name with the NAS&amp;rsquo;s &lt;em&gt;tailnet&lt;/em&gt; address, &lt;code&gt;100.64.0.28&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;magic_dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;base_domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ts.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;extra_records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# BEGIN MANAGED HEADSCALE LAB DNS&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;mealie.lab.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;A&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100.64.0.28&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ntfy.lab.nijho.lt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;A&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100.64.0.28&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# ... one entry per name, about a hundred of them&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# END MANAGED HEADSCALE LAB DNS&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;ntfy points at the NAS too, even though it runs on the NUC.&#10;These records don&amp;rsquo;t point at &lt;em&gt;services&lt;/em&gt;; they point at &lt;em&gt;the front door&lt;/em&gt;, and Traefik forwards the request to the right machine as usual.&lt;/p&gt;&#10;&lt;p&gt;I don&amp;rsquo;t maintain that list by hand.&#10;A &lt;a href="https://gist.github.com/basnijholt/144eea772149f3d503bd1cd27fd72b45" target="_blank" rel="noopener"&gt;small script&lt;/a&gt; collects every &lt;code&gt;Host(...)&lt;/code&gt; rule from my Traefik labels and route files and rewrites the block between the &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;END&lt;/code&gt; markers:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 scripts/sync-headscale-lab-dns.py --dry-run &lt;span class="c1"&gt;# preview&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 scripts/sync-headscale-lab-dns.py &lt;span class="c1"&gt;# write&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cf restart headscale &lt;span class="c1"&gt;# Headscale doesn&amp;#39;t hot-reload&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Forgetting this step is sneaky: the new service works perfectly at home and silently fails over Tailscale.&#10;That&amp;rsquo;s why it is written down in &lt;code&gt;AGENTS.md&lt;/code&gt;, the instruction file my AI agents read, with the exact commands.&lt;/p&gt;&#10;&lt;p&gt;The result is that one name works everywhere I am.&#10;&lt;code&gt;https://mealie.lab.nijho.lt&lt;/code&gt; reaches the same Mealie, with the same certificate, whether my phone is at home, on WireGuard, or on Tailscale; only the path underneath differs.&lt;/p&gt;&#10;&lt;h3 id="4-the-internet"&gt;4. The internet &lt;a class="anchor" href="#4-the-internet" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The last way in is the open internet.&#10;My router forwards TCP port 443, HTTPS, to Traefik on the NAS, but to its own entrypoint, &lt;code&gt;wan&lt;/code&gt; on port 4443, not to the &lt;code&gt;websecure&lt;/code&gt; one that everything else uses.&#10;Only routes that list &lt;code&gt;wan&lt;/code&gt; answer there.&lt;/p&gt;&#10;&lt;p&gt;A few services are public on purpose.&#10;The clearest example is my git server, &lt;a href="https://forgejo.org/" target="_blank" rel="noopener"&gt;Forgejo&lt;/a&gt;, at &lt;code&gt;git.nijho.lt&lt;/code&gt;.&#10;A git server is only useful if the people and machines I work with can reach it, and not all of them are on my tailnet.&lt;/p&gt;&#10;&lt;p&gt;Its public name is a Terraform record that points at the router&amp;rsquo;s DDNS hostname, so it follows my home IP around automatically:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;cloudflare_dns_record&amp;#34; &amp;#34;git&amp;#34;&lt;/span&gt; {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; zone_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;cloudflare_zone_id&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;git.nijho.lt&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;lt;my router&amp;#39;s DDNS hostname&amp;gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;CNAME&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10800&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; proxied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;&lt;span class="c1"&gt; # SSH uses port 222, CF only proxies 80/443&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;In Traefik, Forgejo simply has two HTTPS routes: the usual &lt;code&gt;git.lab.nijho.lt&lt;/code&gt;, private like everything else, and &lt;code&gt;git.nijho.lt&lt;/code&gt;, which also listens on &lt;code&gt;wan&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.forgejo.rule=Host(`git.${DOMAIN}`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.forgejo-public.rule=Host(`git.nijho.lt`)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.forgejo-public.entrypoints=websecure,wan&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Git over SSH doesn&amp;rsquo;t go through Traefik; the router forwards a second port, 222, straight to Forgejo on the NAS.&#10;That is also why this DNS record is &amp;ldquo;DNS-only&amp;rdquo;: Cloudflare&amp;rsquo;s proxy only handles web traffic.&#10;Without the allowlist, Forgejo has to guard its own door: sign-ups are off, you have to log in to see anything, and SSH only accepts keys.&lt;/p&gt;&#10;&lt;p&gt;The Headscale coordination server is public too, at &lt;code&gt;headscale.nijho.lt&lt;/code&gt;, and it &lt;em&gt;has&lt;/em&gt; to be: a laptop on hotel Wi-Fi needs to reach it to log in and find the other devices before any tunnel exists.&#10;That record goes through Cloudflare&amp;rsquo;s proxy, which hides my home IP behind Cloudflare&amp;rsquo;s.&#10;Only the part the Tailscale apps talk to is public; the Headplane admin UI and Headscale&amp;rsquo;s &lt;code&gt;lab&lt;/code&gt; name stay behind the allowlist.&#10;Headscale doesn&amp;rsquo;t run its own relay server either; the tailnet uses Tailscale&amp;rsquo;s public relays when a direct connection isn&amp;rsquo;t possible.&lt;/p&gt;&#10;&lt;p&gt;Forwarding port 443 sounds scary if you assume it means everything is on the internet now.&#10;It doesn&amp;rsquo;t: the internet only reaches &lt;code&gt;wan&lt;/code&gt;, and a route has to ask for that.&lt;/p&gt;&#10;&lt;h2 id="keeping-it-safe-one-allowlist"&gt;Keeping it safe: one allowlist &lt;a class="anchor" href="#keeping-it-safe-one-allowlist" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="dns-is-not-a-lock"&gt;DNS is not a lock &lt;a class="anchor" href="#dns-is-not-a-lock" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Remember &lt;a href="#three-answers-for-the-same-name"&gt;the public DNS record from earlier&lt;/a&gt;: &lt;code&gt;*.lab.nijho.lt&lt;/code&gt; points to &lt;code&gt;192.168.1.6&lt;/code&gt;, a private IP address on my home network.&#10;I made this mistake myself: I thought pointing DNS at a private address was enough to keep my services off the internet.&#10;It wasn&amp;rsquo;t.&#10;DNS only tells a client where to go, and nothing forces a client to ask.&#10;Anyone can skip DNS and send a request for &lt;code&gt;mealie.lab.nijho.lt&lt;/code&gt; straight to my home IP:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl --resolve mealie.lab.nijho.lt:443:&amp;lt;my home IP&amp;gt; https://mealie.lab.nijho.lt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A home IP is not a secret: any public service at home points to it, and scanners sweep every address on the internet anyway.&#10;That request reaches the router, gets forwarded to Traefik like any other HTTPS request, and asks for Mealie by name.&#10;The names aren&amp;rsquo;t secret either, because they&amp;rsquo;re easy to guess.&#10;The wildcard certificate keeps the individual names out of the public &lt;a href="https://certificate.transparency.dev/" target="_blank" rel="noopener"&gt;certificate transparency logs&lt;/a&gt;, which is nice, but that is hiding, not locking.&lt;/p&gt;&#10;&lt;p&gt;So the DNS setup is there for convenience: one name that works everywhere.&#10;The security boundary is Traefik, which knows how every request actually arrived.&lt;/p&gt;&#10;&lt;h3 id="who-is-knocking"&gt;Who is knocking &lt;a class="anchor" href="#who-is-knocking" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Every network request carries a &lt;strong&gt;source address&lt;/strong&gt;: where it came from.&#10;When the router forwards a request from the internet, the source stays the real public address of whoever sent it.&#10;Unlike a name, a source address can&amp;rsquo;t be faked for a real connection: the reply goes back to that address, so a forged one never completes the connection.&#10;So Traefik can tell which of the four ways in a request took, just by looking at where it came from.&lt;/p&gt;&#10;&lt;p&gt;I use that in a single Traefik &lt;strong&gt;middleware&lt;/strong&gt;, a check that runs before a request reaches the service, called &lt;code&gt;local-ips-only&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;middlewares&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;local-ips-only&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ipAllowList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;sourceRange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;192.168.1.0/24&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# home network&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;100.64.0.0/24&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# Tailscale devices&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;10.6.0.0/24&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# WireGuard clients&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;172.20.0.1/32&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# the NAS itself (see below)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;127.0.0.1/32&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# localhost&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;/24&lt;/code&gt; means the first three numbers are fixed and the last can be anything, so &lt;code&gt;192.168.1.0/24&lt;/code&gt; covers &lt;code&gt;192.168.1.0&lt;/code&gt; through &lt;code&gt;192.168.1.255&lt;/code&gt;.&#10;&lt;code&gt;/32&lt;/code&gt; means exactly one address.&lt;/p&gt;&#10;&lt;p&gt;The middleware sits on the whole &lt;code&gt;websecure&lt;/code&gt; entrypoint, so every HTTPS route there gets the check without a word in its labels.&#10;A request from any other address gets &lt;code&gt;403 Forbidden&lt;/code&gt; before it gets anywhere near the service.&#10;For the &lt;code&gt;.local&lt;/code&gt; names, the same middleware sits on the plain-HTTP entrypoint.&#10;Internet traffic doesn&amp;rsquo;t arrive on &lt;code&gt;websecure&lt;/code&gt; at all, so this is the second lock: if I ever point the port forward at the wrong port, strangers get &lt;code&gt;403&lt;/code&gt; instead of my services.&lt;/p&gt;&#10;&lt;p&gt;The odd one, &lt;code&gt;172.20.0.1&lt;/code&gt;, is the gateway of my Docker network.&#10;Requests that start on the NAS itself, like a container calling another service by its lab name, show up with that address.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;Keeping real Tailscale addresses (click to unfold)&lt;/summary&gt;&#10; &lt;p&gt;Tailscale requests keep their real &lt;code&gt;100.64.0.x&lt;/code&gt; address thanks to one line in the NixOS config of &lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos/hosts/docker-lxc" target="_blank" rel="noopener"&gt;&lt;code&gt;docker-lxc&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tailscale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extraSetFlags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;--snat-subnet-routes=false&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without it, Tailscale replaces the source address of traffic it passes on to another network, like Docker&amp;rsquo;s internal one, and every Tailscale request would look like it came from &lt;code&gt;172.20.0.1&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;/details&gt;&#10;&#10;&lt;p&gt;To check all of this, I test from every way in.&lt;/p&gt;&#10;&lt;details&gt;&#10; &lt;summary&gt;How I test it (click to unfold)&lt;/summary&gt;&#10; &lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# From home, WireGuard, or Tailscale: expect 200&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -o /dev/null -w &lt;span class="s1"&gt;&amp;#39;%{http_code}\n&amp;#39;&lt;/span&gt; https://mealie.lab.nijho.lt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Through the router&amp;#39;s port forward, skipping DNS: expect 404&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -o /dev/null -w &lt;span class="s1"&gt;&amp;#39;%{http_code}\n&amp;#39;&lt;/span&gt; --resolve mealie.lab.nijho.lt:443:&amp;lt;my home IP&amp;gt; https://mealie.lab.nijho.lt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# A public service, from anywhere: expect 200&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;curl -s -o /dev/null -w &lt;span class="s1"&gt;&amp;#39;%{http_code}\n&amp;#39;&lt;/span&gt; https://git.nijho.lt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The second one works from inside too: the router sends the request back in through the same port forward, so it lands on &lt;code&gt;wan&lt;/code&gt; like a stranger&amp;rsquo;s would.&lt;/p&gt;&#10;&lt;/details&gt;&#10;&#10;&lt;h3 id="private-by-default"&gt;Private by default &lt;a class="anchor" href="#private-by-default" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Out of roughly a hundred HTTPS routes, only a handful are public, and each of those is a deliberate decision.&lt;/p&gt;&#10;&lt;p&gt;A route is public only if it lists the &lt;code&gt;wan&lt;/code&gt; entrypoint and has no allowlist of its own.&#10;A private service needs nothing in its labels: it lands on &lt;code&gt;websecure&lt;/code&gt;, where the allowlist guards the whole entrypoint.&#10;Forget a label and a service ends up private, not public.&#10;The one change that publishes something is typing &lt;code&gt;wan&lt;/code&gt;, which stands out in a diff and is easy to list:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;grep -rnw wan /opt/stacks/*/compose.yaml /opt/stacks/traefik/dynamic.d/&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One wrinkle: devices at home that use a public name, like &lt;code&gt;git.nijho.lt&lt;/code&gt;, also come in through the port forward, because DNS points that name at my home IP.&#10;For public routes that changes nothing.&#10;A couple of private services that I reach by a public name list &lt;code&gt;wan&lt;/code&gt; too, and keep the allowlist on the route itself.&lt;/p&gt;&#10;&lt;h3 id="trusting-my-own-network"&gt;Trusting my own network &lt;a class="anchor" href="#trusting-my-own-network" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;For most services, the allowlist is the only lock in front.&#10;Anything on my home network or on WireGuard, and any of my own devices on Tailscale, can reach them without an extra login.&lt;/p&gt;&#10;&lt;p&gt;I could add one.&#10;&lt;a href="https://www.authelia.com/" target="_blank" rel="noopener"&gt;Authelia&lt;/a&gt; runs next to Traefik, and putting its login page in front of a service takes one label.&#10;For Traefik&amp;rsquo;s own dashboard, that looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;traefik.http.routers.traefik-lab.middlewares=authelia@docker&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I do that for some services, but not all, because a login page in front of an app sometimes breaks things, like mobile apps that talk to the app directly.&lt;/p&gt;&#10;&lt;p&gt;Most apps have their own login, and I trust that enough on my own network.&#10;I also trust the people on it.&#10;If your home network has guests or devices you don&amp;rsquo;t trust, the trade-off looks different.&lt;/p&gt;&#10;&lt;h2 id="sharing-with-friends-and-family"&gt;Sharing with friends and family &lt;a class="anchor" href="#sharing-with-friends-and-family" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The allowlist trusts the whole &lt;code&gt;100.64.0.x&lt;/code&gt; range, and some devices on my tailnet belong to friends and family.&#10;That doesn&amp;rsquo;t give them everything, because there is a second layer in front: Headscale&amp;rsquo;s &lt;strong&gt;access control list (ACL)&lt;/strong&gt;.&#10;It decides, at the network level, which device may reach which port on which machine, and everything not listed is blocked.&lt;/p&gt;&#10;&lt;p&gt;A few examples of what that looks like in practice:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;A friend uses my Mealie.&lt;/strong&gt; Their router runs Tailscale and shares their home network with the tailnet, so every device in their home can reach Mealie without installing anything.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A friend uses my &lt;a href="https://www.nijho.lt/post/diction-agent-cli-qwen/"&gt;self-hosted dictation&lt;/a&gt;&lt;/strong&gt; on the GPU machine, and nothing else.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;An AI agent I run for a family member&lt;/strong&gt;, in a &lt;a href="https://www.nijho.lt/post/mindroom/"&gt;MindRoom&lt;/a&gt; container, can reach one Home Assistant instance and nothing else.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A small NixOS box at a family member&amp;rsquo;s home&lt;/strong&gt; joins the tailnet and makes a few shared services available on their network under simple &lt;code&gt;.local&lt;/code&gt; names, for devices that can&amp;rsquo;t run Tailscale themselves. It is defined in the same NixOS repo as my own machines.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;A simplified version of those rules:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;hosts&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;nas&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;100.64.0.28/32&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;pc&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;100.64.0.3/32&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;friend-a-home&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;10.10.0.0/16&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;acls&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;action&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;accept&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;friend-a@&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;friend-a-home&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;nas:9925&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;action&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;accept&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;friend-b@&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;pc:61337&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pc:8080&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;action&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;accept&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;me@&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nt"&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;*:*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Friends reach their service directly on its port, not through Traefik.&#10;They can&amp;rsquo;t even reach port 443 on the NAS, so Traefik never sees them.&#10;My own devices can reach everything.&lt;/p&gt;&#10;&lt;p&gt;That also means friends don&amp;rsquo;t use my lab names.&#10;I give them the machine&amp;rsquo;s tailnet address and the port, like &lt;code&gt;http://100.64.0.28:9925&lt;/code&gt;, and that&amp;rsquo;s it.&#10;A device keeps its tailnet address, so it can go in a bookmark, and one friend points a name in their own homelab&amp;rsquo;s DNS at it.&#10;It is plain HTTP, but between their network and mine it travels inside the encrypted tunnel.&lt;/p&gt;&#10;&lt;p&gt;Because the ACL is a file in my stacks repo, sharing one more service with someone is a one-line diff and a &lt;code&gt;cf restart headscale&lt;/code&gt;.&#10;The commit history doubles as a log of who got access to what, and when.&lt;/p&gt;&#10;&lt;p&gt;This is also how friend-to-friend setups like &lt;a href="https://www.nijho.lt/post/truenas-remote-backups/"&gt;my remote TrueNAS backups&lt;/a&gt; work: a tailnet is a private network you can extend to exactly the people you trust.&lt;/p&gt;&#10;&lt;h2 id="why-not-cloudflare-tunnel-or-tailscales-own-server"&gt;Why not Cloudflare Tunnel or Tailscale&amp;rsquo;s own server? &lt;a class="anchor" href="#why-not-cloudflare-tunnel-or-tailscales-own-server" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This is usually the next question.&#10;&lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" target="_blank" rel="noopener"&gt;Cloudflare Tunnel&lt;/a&gt; publishes services without forwarding any ports: a small daemon at home keeps an outbound connection open to Cloudflare, and Cloudflare serves your sites to the world.&#10;Tailscale runs the coordination server for you, for free, so you don&amp;rsquo;t need Headscale at all.&#10;Both are great products, and both are easier than what I do.&lt;/p&gt;&#10;&lt;p&gt;The reason I don&amp;rsquo;t use them is simple: I don&amp;rsquo;t want to depend on a third-party company for any of this.&#10;The plumbing in this post is open source and runs on my own machines, so if one of these companies disappears or changes its terms tomorrow, everything keeps working.&#10;With Cloudflare Tunnel, every request to my services would pass through Cloudflare, which decrypts it on the way.&#10;With Tailscale&amp;rsquo;s hosted server, a company would decide which devices are part of my network.&lt;/p&gt;&#10;&lt;p&gt;There are a few places where I still lean on someone else, and each one is replaceable:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Cloudflare hosts my public DNS.&lt;/strong&gt; The records are a Terraform file, so moving to another provider means rewriting that file for a different provider. The same goes for the certificate challenge, which Traefik supports for dozens of DNS providers, and for the few names that go through Cloudflare&amp;rsquo;s proxy.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Tailscale&amp;rsquo;s public relays&lt;/strong&gt; carry traffic when two devices can&amp;rsquo;t connect directly. Headscale has a relay server built in, which I can turn on in its config.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Let&amp;rsquo;s Encrypt&lt;/strong&gt; issues my certificates, but it speaks a standard protocol, ACME, that other certificate authorities speak too.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Terraform&lt;/strong&gt; moved to a source-available license in 2023. &lt;a href="https://opentofu.org/" target="_blank" rel="noopener"&gt;OpenTofu&lt;/a&gt; is the open-source fork, and it reads the same files.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="declarative-everything"&gt;Declarative everything &lt;a class="anchor" href="#declarative-everything" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;One idea runs through all of this: every piece of configuration is a &lt;strong&gt;text file in git&lt;/strong&gt;, not a setting I clicked somewhere.&#10;The opposite is running commands and clicking buttons until things look right, and then hoping you remember what you did.&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Piece&lt;/th&gt;&#10;&lt;th&gt;Where it&amp;rsquo;s defined&lt;/th&gt;&#10;&lt;th&gt;How it&amp;rsquo;s applied&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Operating system, Docker, Tailscale, NFS mounts, home DNS, firewalls&lt;/td&gt;&#10;&lt;td&gt;NixOS config in my public dotfiles&lt;/td&gt;&#10;&lt;td&gt;comin pulls signed commits and switches&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Which service runs where&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;compose-farm.yaml&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cf apply&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Each service and its routing&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;compose.yaml&lt;/code&gt; with Traefik labels&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cf up&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Routes to services on other machines&lt;/td&gt;&#10;&lt;td&gt;Generated by compose-farm&lt;/td&gt;&#10;&lt;td&gt;Automatically on &lt;code&gt;cf up&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Non-Docker routes and the allowlist&lt;/td&gt;&#10;&lt;td&gt;A route file next to Traefik&lt;/td&gt;&#10;&lt;td&gt;Traefik watches the folder&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Public DNS records&lt;/td&gt;&#10;&lt;td&gt;Terraform in a private repo&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;terraform apply&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Tailnet DNS records&lt;/td&gt;&#10;&lt;td&gt;Generated from Traefik rules into Headscale&amp;rsquo;s config&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cf restart headscale&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Who may reach what on the tailnet&lt;/td&gt;&#10;&lt;td&gt;Headscale ACL file&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cf restart headscale&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Router: WireGuard, port forward, DDNS&lt;/td&gt;&#10;&lt;td&gt;The router&amp;rsquo;s web UI&lt;/td&gt;&#10;&lt;td&gt;❌ Clicked&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Which devices are on the tailnet&lt;/td&gt;&#10;&lt;td&gt;Headscale&amp;rsquo;s database&lt;/td&gt;&#10;&lt;td&gt;Enrolled once per device; state, not configuration&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;The only configuration I still click is the router, and those settings change maybe once a year.&#10;Everything that changes weekly is a file.&#10;The next step in project &amp;ldquo;overengineered homelab&amp;rdquo; is a router running NixOS, but that&amp;rsquo;s for another time.&#10;What isn&amp;rsquo;t configuration, like which devices are enrolled in my tailnet and the data inside each app, lives on ZFS and is backed up like any other data.&lt;/p&gt;&#10;&lt;p&gt;It wasn&amp;rsquo;t always like this.&#10;My previous setup was built from good products that are all driven by clicking through a web UI:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Job&lt;/th&gt;&#10;&lt;th&gt;Before: clicked in a web UI&lt;/th&gt;&#10;&lt;th&gt;Now: a text file in git&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Virtualization&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.proxmox.com/" target="_blank" rel="noopener"&gt;Proxmox&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;NixOS and Incus&lt;/a&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.truenas.com/" target="_blank" rel="noopener"&gt;TrueNAS&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;NixOS with ZFS&lt;/a&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;DNS&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://technitium.com/dns/" target="_blank" rel="noopener"&gt;Technitium&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;CoreDNS in my NixOS config&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Reverse proxy&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/NginxProxyManager/nginx-proxy-manager" target="_blank" rel="noopener"&gt;Nginx Proxy Manager&lt;/a&gt;&lt;/td&gt;&#10;&lt;td&gt;Traefik labels in each &lt;code&gt;compose.yaml&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Containers&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/louislam/dockge" target="_blank" rel="noopener"&gt;Dockge&lt;/a&gt;, one machine at a time&lt;/td&gt;&#10;&lt;td&gt;&lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt;, all machines from one file&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Yes, most of them have APIs, but that is not how most people use them, me included.&#10;Most of the configuration lived in each product&amp;rsquo;s database, and the reasons behind each setting lived in my head.&#10;Now both live in git.&lt;/p&gt;&#10;&lt;h3 id="why-it-matters"&gt;Why it matters &lt;a class="anchor" href="#why-it-matters" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The classic benefits are real: git history is my changelog, rebuilding a dead machine is boring, and nothing lives only in my head.&lt;/p&gt;&#10;&lt;p&gt;But the benefit that matters most to me now is a newer one: &lt;strong&gt;this setup is easy to work on with AI.&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;Coding agents are very good at reading and editing text files, running commands, and checking the result.&#10;They are much worse at clicking through a web dashboard.&#10;Because almost everything here is files and CLI commands, an agent can do exactly what I do: add a service, move it to another machine, find out why a route returns &lt;code&gt;403&lt;/code&gt;, add a DNS record, write the NixOS change, and show me the diff.&#10;Terraform is a good example of why this is safe: the agent edits the record, runs &lt;code&gt;terraform plan&lt;/code&gt;, and I see exactly what will change at Cloudflare before anything does.&lt;/p&gt;&#10;&lt;p&gt;I &lt;a href="https://www.nijho.lt/post/removing-guardrails/"&gt;run my agents in YOLO mode&lt;/a&gt;, and declarative config is a big part of why that doesn&amp;rsquo;t scare me.&#10;Every change is a reviewable diff, and every mistake is a &lt;code&gt;git revert&lt;/code&gt; away.&#10;It&amp;rsquo;s the same reason I &lt;a href="https://www.nijho.lt/post/file-based-rag-memory/"&gt;prefer plain files over databases&lt;/a&gt; for AI memory.&lt;/p&gt;&#10;&lt;p&gt;The stacks repo has an &lt;a href="https://agents.md/" target="_blank" rel="noopener"&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/a&gt; that captures the conventions and the sharp edges (a symlink to &lt;code&gt;CLAUDE.md&lt;/code&gt;, so &lt;a href="https://code.claude.com/docs/en/overview" target="_blank" rel="noopener"&gt;Claude Code&lt;/a&gt; and other agents read the same file):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;&amp;gt; &lt;/span&gt;&lt;span class="ge"&gt;**⚠️ IMPORTANT: NEVER run `docker compose` directly!**&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;&amp;gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="ge"&gt;&amp;gt; Always use `cf` (compose-farm) for ALL compose operations.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;&amp;gt; &lt;/span&gt;&lt;span class="ge"&gt;Running `docker compose` directly bypasses host routing,&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;&amp;gt; &lt;/span&gt;&lt;span class="ge"&gt;state tracking, and Traefik config generation.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The Tailscale DNS step from earlier lives there too, with the exact commands.&#10;Whenever something bites me once, it goes into that file, so it doesn&amp;rsquo;t bite an agent twice.&lt;/p&gt;&#10;&lt;p&gt;The other half is generating everything I would otherwise keep in sync by hand.&#10;The cross-machine Traefik routes come from the Compose labels, the Headscale DNS records come from the Traefik rules, and even my &lt;a href="https://github.com/louislam/uptime-kuma" target="_blank" rel="noopener"&gt;Uptime Kuma&lt;/a&gt; monitors are synced from the Traefik labels.&#10;Hand-maintained lists drift; generated ones don&amp;rsquo;t.&lt;/p&gt;&#10;&lt;h2 id="putting-it-together-adding-a-service"&gt;Putting it together: adding a service &lt;a class="anchor" href="#putting-it-together-adding-a-service" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This is everything it takes to add a new service, say ntfy on the NUC:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Create &lt;code&gt;/opt/stacks/ntfy/compose.yaml&lt;/code&gt; with the container and its Traefik labels: an HTTPS name and a &lt;code&gt;.local&lt;/code&gt; name, like Mealie.&lt;/li&gt;&#10;&lt;li&gt;Add &lt;code&gt;ntfy: nuc&lt;/code&gt; to &lt;code&gt;compose-farm.yaml&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Run &lt;code&gt;cf up ntfy&lt;/code&gt;. compose-farm starts it on the NUC and writes its route into Traefik&amp;rsquo;s file, and Traefik picks it up immediately.&lt;/li&gt;&#10;&lt;li&gt;Run the Headscale DNS script and &lt;code&gt;cf restart headscale&lt;/code&gt;, so the name also works over Tailscale.&lt;/li&gt;&#10;&lt;li&gt;Commit.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;After that:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;https://ntfy.lab.nijho.lt&lt;/code&gt; works at home, over WireGuard, and over Tailscale, with a valid certificate I never requested.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;http://ntfy.local&lt;/code&gt; works at home.&lt;/li&gt;&#10;&lt;li&gt;Sent to my home IP from the internet, it answers &lt;code&gt;404 Not Found&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;To make it public like &lt;code&gt;git.nijho.lt&lt;/code&gt;, I would add a Terraform record and a second route that also listens on &lt;code&gt;wan&lt;/code&gt;.&#10;To share it with a friend, one line in the ACL.&lt;/p&gt;&#10;&lt;p&gt;In practice, I describe the service to an agent and review what it did.&lt;/p&gt;&#10;&lt;h2 id="lessons-learned"&gt;Lessons learned &lt;a class="anchor" href="#lessons-learned" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Use the DNS-01 challenge.&lt;/strong&gt; You get real certificates for private services and one wildcard for everything, and the internet never needs to reach you.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;One front door, private by default.&lt;/strong&gt; Knowing that a route has to ask to be on the internet is what made forwarding port 443 feel fine.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;A private address in public DNS is a great trick&lt;/strong&gt; for home and WireGuard. A mesh VPN like Tailscale needs its own answer.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;DNS is not a security boundary.&lt;/strong&gt; Anyone can send any name to your IP. The check has to happen at the front door.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Point DNS at the front door, not at the service.&lt;/strong&gt; Traefik knows where things run, so moving a service never touches DNS.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Keep one way in that doesn&amp;rsquo;t depend on the homelab.&lt;/strong&gt; For me that&amp;rsquo;s WireGuard on the router. The NAS holds the data and runs Traefik, so when it is down, everything is, and I still need a way in to fix it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Test from every way in.&lt;/strong&gt; Some problems only show up on one path, like a new name that works at home but not over Tailscale.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Two layers for sharing.&lt;/strong&gt; Headscale ACLs decide who reaches what; Traefik&amp;rsquo;s &lt;code&gt;wan&lt;/code&gt; entrypoint decides what the internet sees.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Make private the default.&lt;/strong&gt; Public should take an extra word in the config, so a forgotten label fails closed.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Generate what you can.&lt;/strong&gt; A generated list can&amp;rsquo;t drift.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Make everything a file.&lt;/strong&gt; It&amp;rsquo;s better for future me, and it&amp;rsquo;s the difference between an AI agent that can help and one that can only give advice.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/compose-farm" target="_blank" rel="noopener"&gt;compose-farm&lt;/a&gt;: my tool for running Docker Compose stacks across multiple machines&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/nixos" target="_blank" rel="noopener"&gt;My NixOS configuration&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/nlewo/comin" target="_blank" rel="noopener"&gt;comin&lt;/a&gt;: GitOps for NixOS machines&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://doc.traefik.io/traefik/" target="_blank" rel="noopener"&gt;Traefik&lt;/a&gt;, including the &lt;a href="https://doc.traefik.io/traefik/middlewares/http/ipallowlist/" target="_blank" rel="noopener"&gt;IP allowlist middleware&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://letsencrypt.org/docs/challenge-types/" target="_blank" rel="noopener"&gt;Let&amp;rsquo;s Encrypt challenge types&lt;/a&gt;: HTTP-01 vs DNS-01&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs" target="_blank" rel="noopener"&gt;Terraform Cloudflare provider&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.wireguard.com/" target="_blank" rel="noopener"&gt;WireGuard&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://tailscale.com/blog/how-tailscale-works" target="_blank" rel="noopener"&gt;How Tailscale works&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://headscale.net/" target="_blank" rel="noopener"&gt;Headscale&lt;/a&gt; and &lt;a href="https://github.com/tale/headplane" target="_blank" rel="noopener"&gt;Headplane&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://coredns.io/" target="_blank" rel="noopener"&gt;CoreDNS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forgejo.org/" target="_blank" rel="noopener"&gt;Forgejo&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="footnotes" role="doc-endnotes"&gt;&#10;&lt;hr&gt;&#10;&lt;ol&gt;&#10;&lt;li id="fn:1"&gt;&#10;&lt;p&gt;Yes, &lt;a href="https://repology.org/repositories/graphs" target="_blank" rel="noopener"&gt;nixpkgs is the largest and most up-to-date package repository&lt;/a&gt; there is. Even so, I follow &lt;code&gt;nixos-unstable&lt;/code&gt;, and a new version only reaches me once it is merged, built, and tested, and the channel moves forward, which usually takes a couple of days. Updates that trigger large rebuilds go through a staging branch first and take longer, and not every package gets updated as quickly as the popular ones. With &lt;a href="https://www.docker.com/" target="_blank" rel="noopener"&gt;Docker&lt;/a&gt;, I can run a release the day upstream publishes it.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/div&gt;&#10;</description></item><item><title>Removing the guardrails and letting my coding agents loose</title><link>https://www.nijho.lt/post/removing-guardrails/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/removing-guardrails/</guid><description>&lt;p&gt;I have run coding agents in YOLO mode since I started using them in May 2025.&#10;I think it is the only way to parallelize work.&#10;Sometimes I have ten agents running at the same time, and clicking &amp;ldquo;approve&amp;rdquo; on every command they run would make that impossible.&lt;/p&gt;&#10;&lt;p&gt;What makes me comfortable with it is that models tend to do what you ask, and that you can keep them in an environment where they cannot do much harm.&#10;I don&amp;rsquo;t keep secrets within their reach, and above all I have &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;very good backups&lt;/a&gt;.&#10;The last piece is a small set of hooks that block the few things I never want an agent to do on its own.&lt;/p&gt;&#10;&lt;h2 id="why-hooks-and-not-agentsmd"&gt;Why hooks and not AGENTS.md &lt;a class="anchor" href="#why-hooks-and-not-agentsmd" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My system prompt has always told agents not to force-push or merge PRs.&#10;Some of them did it anyway.&lt;/p&gt;&#10;&lt;p&gt;The first time I really noticed was with &lt;a href="https://www.nijho.lt/post/gemini-3-pro-first-impressions/"&gt;Gemini 3 Pro in November 2025&lt;/a&gt;, which merged my PR and force-pushed to &lt;code&gt;main&lt;/code&gt;.&#10;It happened a couple more times in the weeks after, as I parallelized more and asked more of the models.&#10;Three weeks later I had hooks for Claude Code and Gemini CLI that block these commands before they run.&#10;Codex did not support hooks yet.&#10;To my surprise, it did not need them: OpenAI&amp;rsquo;s models are extremely good at following instructions, and Codex never ignored an explicit rule in my &lt;code&gt;AGENTS.md&lt;/code&gt;.&#10;Codex supports hooks by now, so it runs the same ones as the others.&lt;/p&gt;&#10;&lt;p&gt;Claude has been worse at following rules, and Gemini far worse still: absolutely dogshit.&#10;I only try Gemini now and then, and I have been disappointed every time.&#10;By now I don&amp;rsquo;t even try the latest Google models anymore.&lt;/p&gt;&#10;&lt;p&gt;I don&amp;rsquo;t think agents are ever purposely malicious.&#10;They try to do what you asked, but they can misinterpret it, and then they force-push or merge something.&#10;A rule in a Markdown file is a hope; a hook actually stops the command.&#10;Not that a hook would stop a malicious agent: it could put the blocked command in a Bash script and run that instead.&#10;What the hook does is tell the agent why I don&amp;rsquo;t want it, because every block comes with a short explanation:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git add -A is not allowed - add files explicitly to avoid adding unrelated untracked files&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The models are well aligned, so once they know my intention, they respect it.&lt;/p&gt;&#10;&lt;h2 id="what-i-block"&gt;What I block &lt;a class="anchor" href="#what-i-block" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;All hooks live in &lt;a href="https://github.com/basnijholt/dotfiles/tree/main/configs/claude/hooks" target="_blank" rel="noopener"&gt;my dotfiles&lt;/a&gt;, and I deliberately kept them simple.&#10;The detection logic is one Python module, &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/claude/hooks/git_guard.py" target="_blank" rel="noopener"&gt;&lt;code&gt;git_guard.py&lt;/code&gt;&lt;/a&gt;, and the Claude Code, Codex, and Gemini CLI hooks all import it.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;&lt;code&gt;git commit --amend&lt;/code&gt; and &lt;code&gt;git push --force&lt;/code&gt;.&lt;/strong&gt;&#10;The unit I review is a PR.&#10;When I review a PR, I know I have reviewed it up to a certain commit, and later I only look at the diff of the new commits.&#10;Rewriting history breaks that.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Pushing to &lt;code&gt;main&lt;/code&gt;.&lt;/strong&gt;&#10;Everything happens in PRs.&#10;Inside a PR I don&amp;rsquo;t care how messy the history gets.&#10;I want the agent to push very often, so no work gets lost, and every commit has to be green, so each one is a snapshot I can go back to.&#10;The agent maintains its own development of the feature, and I have not written a commit message myself in a long time.&#10;When the PR is done, I squash merge it.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;&lt;code&gt;gh pr merge&lt;/code&gt;.&lt;/strong&gt;&#10;The merge comes after my review, so it is mine to do.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;&lt;code&gt;git add -A&lt;/code&gt;.&lt;/strong&gt;&#10;I often have unrelated untracked or unstaged files lying around.&#10;I work in many open source repositories, and some of those files should not be public, like deployment plans.&#10;This is the only block without an override.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;&lt;code&gt;sleep&lt;/code&gt;.&lt;/strong&gt;&#10;This one is for Claude, and Opus 5 specifically, which I hate with a passion.&#10;It would run tests in the background and then &lt;code&gt;sleep 300&lt;/code&gt;, badly overestimating how long the tests take, when a blocking tool call returns exactly when they finish.&lt;/p&gt;&#10;&lt;h2 id="the-override"&gt;The override &lt;a class="anchor" href="#the-override" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Over time the models got more capable.&#10;I could parallelize more, and I trusted them to work on their own for longer.&#10;I noticed I was spending a significant fraction of my time clicking buttons and doing operations I had forbidden the agent to do, only to end up doing them myself.&lt;/p&gt;&#10;&lt;p&gt;So in July I added an override.&#10;When a hook blocks a command, the agent gets this hint:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;If (and only if) the user has explicitly approved this exact action,&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;re-run the command prefixed with EXPLICITLY_USER_APPROVED_HOOK_OVERRIDE=1 to override this block.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nothing checks whether I actually approved it.&#10;It is a soft gate, but it works because the agent now has to write down, in the command itself, that I approved the action.&#10;When it runs into the block without my approval, it becomes obvious to the agent that it has to ask me first.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;git add -A&lt;/code&gt; stays hard-blocked, and a chained command cannot carry an override past it: &lt;code&gt;EXPLICITLY_USER_APPROVED_HOOK_OVERRIDE=1 git commit --amend &amp;amp;&amp;amp; git add -A&lt;/code&gt; is still rejected.&lt;/p&gt;&#10;&lt;h2 id="when-the-override-was-used-without-approval"&gt;When the override was used without approval &lt;a class="anchor" href="#when-the-override-was-used-without-approval" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;It has happened, mostly with GPT-5.6 Sol, which I used a lot at the time.&lt;/p&gt;&#10;&lt;p&gt;It used to be that you had to keep your context window short, because auto-compaction was lossy and produced poor summaries.&#10;Since around GPT-5.3, compaction works well enough that I keep a session going for as long as I am working on the same feature.&#10;So I would ask the agent to merge a PR early on, start a follow-up PR in the same session, and after a compaction the agent concluded that it should merge the new PR too.&#10;It did, without my approval.&lt;/p&gt;&#10;&lt;p&gt;The other case was my &lt;a href="https://github.com/mindroom-ai/mindroom/blob/main/.claude/skills/pr-review/SKILL.md" target="_blank" rel="noopener"&gt;&lt;code&gt;pr-review&lt;/code&gt; skill&lt;/a&gt;, by far my favorite skill that I wrote myself.&#10;I would tell the agent to run it and squash merge the PR only if the review approved it.&#10;A few times, the review found problems, the agent fixed them, and then it merged.&#10;What I meant was: merge if it is approved right away, and otherwise wait for me.&#10;Now I spell that out and end with &amp;ldquo;otherwise, wait for my instructions.&amp;rdquo;&lt;/p&gt;&#10;&lt;h2 id="working-from-my-phone"&gt;Working from my phone &lt;a class="anchor" href="#working-from-my-phone" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The override matters most when I am away from my computer.&#10;On a month-long trip visiting family in Europe, I worked only from my phone, using &lt;a href="https://www.nijho.lt/post/agentic-mobile-workflow/"&gt;my mobile coding workflow&lt;/a&gt;.&#10;Typing commands in a terminal on a phone is inconvenient, so I would rather say what I want and let the agent do it, including the merge.&#10;I have also built end-to-end encrypted voice calls to my agents over Matrix with &lt;a href="https://www.nijho.lt/post/mindroom/"&gt;MindRoom&lt;/a&gt;, and I am moving more toward that kind of voice-driven workflow.&lt;/p&gt;&#10;&lt;h2 id="not-much-has-changed"&gt;Not much has changed &lt;a class="anchor" href="#not-much-has-changed" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I recently reread &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;my first post on agentic coding&lt;/a&gt; from August 2025, and very little in it is outdated.&#10;The models are more capable, and I give them much larger scopes, but the way I work is mostly the same.&#10;The hooks are the main thing I added, and the override is how I loosened them again.&lt;/p&gt;&#10;</description></item><item><title>Combining Keychain and 1Password CLI for ssh-agent management</title><link>https://www.nijho.lt/post/ssh-1password-funtoo-keychain/</link><pubDate>Sun, 30 Mar 2025 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/ssh-1password-funtoo-keychain/</guid><description>&lt;h2 id="combining-keychain-and-1password-cli-for-ssh-agent-management"&gt;Combining Keychain and 1Password CLI for SSH Agent Management &lt;a class="anchor" href="#combining-keychain-and-1password-cli-for-ssh-agent-management" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For about a decade, I&amp;rsquo;ve relied on two key tools in my development workflow: &lt;a href="https://www.funtoo.org/Funtoo:Keychain" target="_blank" rel="noopener"&gt;Funtoo Keychain&lt;/a&gt; for managing my &lt;code&gt;ssh-agent&lt;/code&gt; and 1Password for securely storing secrets.&#10;While I used them extensively, I never actually integrated them directly for SSH key passphrase handling.&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;I wrote about Keychain before in &lt;a href="../terminal-ninja/#-keychain-ssh-key-management-made-simple"&gt;this post&lt;/a&gt;.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;p&gt;Recently, with the advent of &lt;a href="https://developer.1password.com/docs/ssh/get-started/" target="_blank" rel="noopener"&gt;1Password&amp;rsquo;s built-in SSH agent&lt;/a&gt;, I gave it a try, attracted by the promise of managing keys directly within my vault.&#10;However, I quickly found myself frequently prompted for my master password or biometrics, which interrupted my flow more often than I preferred compared to Keychain&amp;rsquo;s persistence.&lt;/p&gt;&#10;&lt;p&gt;This led me back to the reliable &amp;ldquo;unlock once per session&amp;rdquo; behavior of Keychain.&#10;But the experience using the 1Password Command-Line Interface (&lt;code&gt;op&lt;/code&gt;) during that time sparked an idea: could I combine the persistence of Keychain with the secure passphrase retrieval of the &lt;code&gt;op&lt;/code&gt; CLI?&#10;The answer is yes, and this post outlines the setup that achieves that balance.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;The Goal:&lt;/strong&gt; Use 1Password as the secure vault for the SSH key passphrase but rely on &lt;code&gt;keychain&lt;/code&gt; to manage the &lt;code&gt;ssh-agent&lt;/code&gt; process and ensure the key, once unlocked, remains available for the entire login session without further prompts from 1Password.&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;Requires 1Password CLI (&lt;code&gt;op&lt;/code&gt;) and Funtoo Keychain installed.&#10;This post assumes you &lt;a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent#generating-a-new-ssh-key" target="_blank" rel="noopener"&gt;have a working SSH key&lt;/a&gt; and a &lt;a href="https://developer.1password.com/docs/cli/get-started/" target="_blank" rel="noopener"&gt;1Password with the CLI installed and authenticated&lt;/a&gt;.&#10;Run &lt;code&gt;brew install --cask 1password-cli&lt;/code&gt; and &lt;code&gt;brew install keychain&lt;/code&gt; to install them.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;p&gt;&lt;strong&gt;Why This Approach?&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;This setup directly addresses the trade-off I encountered.&#10;Funtoo &lt;code&gt;keychain&lt;/code&gt; excels at starting &lt;code&gt;ssh-agent&lt;/code&gt; reliably on login and keeping keys available throughout a session with just one initial unlock.&#10;While the native 1Password agent is convenient for vault integration, its session management, in my experience, led to more frequent authentication prompts than desired for long-running sessions.&lt;/p&gt;&#10;&lt;p&gt;By using &lt;code&gt;keychain&lt;/code&gt; for agent management and calling the &lt;code&gt;op&lt;/code&gt; CLI via the standard &lt;code&gt;SSH_ASKPASS&lt;/code&gt; mechanism &lt;em&gt;only&lt;/em&gt; for the initial key load, we get the best of both worlds: 1Password&amp;rsquo;s secure storage for the passphrase and Keychain&amp;rsquo;s persistent agent access with minimal prompting after the first unlock.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;The Components:&lt;/strong&gt;&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Funtoo &lt;code&gt;keychain&lt;/code&gt;:&lt;/strong&gt; Manages &lt;code&gt;ssh-agent&lt;/code&gt; lifecycle and loads specified keys.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;1Password CLI (&lt;code&gt;op&lt;/code&gt;):&lt;/strong&gt; Securely retrieves secrets (the passphrase) from your 1Password vault. Requires &lt;code&gt;op&lt;/code&gt; to be installed and logged in.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;askpass-1password.sh&lt;/code&gt;:&lt;/strong&gt; A small helper script that &lt;code&gt;ssh-add&lt;/code&gt; (via &lt;code&gt;keychain&lt;/code&gt;) calls to get the passphrase. This script invokes &lt;code&gt;op&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;setup_ssh_agent.sh&lt;/code&gt;:&lt;/strong&gt; A script sourced by your shell startup file (&lt;code&gt;.zshrc&lt;/code&gt;, &lt;code&gt;.bashrc&lt;/code&gt;, etc.) to orchestrate the setup.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;&lt;strong&gt;Implementation:&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Step 1: Create the &lt;code&gt;askpass&lt;/code&gt; Helper Script&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;This script simply calls &lt;code&gt;op read&lt;/code&gt; to fetch the passphrase using its &lt;a href="https://developer.1password.com/docs/cli/secret-reference-syntax/" target="_blank" rel="noopener"&gt;Secret Reference URI&lt;/a&gt;.&lt;/p&gt;&#10;&lt;figure&gt;&lt;img src="https://developer.1password.com/img/cli/copy-secret-reference-dark.png" alt="Copy Secret Reference screenshot" loading="lazy" data-zoomable&gt;&lt;/figure&gt;&lt;ul&gt;&#10;&lt;li&gt;Find your SSH key item in 1Password.&lt;/li&gt;&#10;&lt;li&gt;Locate the passphrase field, click the options/arrow, and copy the &amp;ldquo;Copy Secret Reference&amp;rdquo;.&lt;/li&gt;&#10;&lt;li&gt;Create the script at &lt;code&gt;~/.ssh/askpass-1password.sh&lt;/code&gt;:&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/sh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ~/.ssh/askpass-1password.sh&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Provides the SSH key passphrase from 1Password CLI via SSH_ASKPASS&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Paste your Secret Reference URI here&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;OP_SECRET_REFERENCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;op://YourVault/YourSSHKeyItem/password&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Use &amp;#39;op read&amp;#39; to retrieve and print the passphrase to stdout&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Requires 1Password CLI (&amp;#39;op&amp;#39;) to be installed and authenticated.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$OP_SECRET_REFERENCE&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;ul&gt;&#10;&lt;li&gt;Make it executable: &lt;code&gt;chmod u+x ~/.ssh/askpass-1password.sh&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;&lt;strong&gt;Step 2: Create the Setup Orchestration Script&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;This script checks if &lt;code&gt;op&lt;/code&gt; is available and conditionally configures &lt;code&gt;keychain&lt;/code&gt; to use the helper script.&lt;/p&gt;&#10;&lt;p&gt;&lt;em&gt;(Adjust path &lt;code&gt;~/dotfiles/scripts/setup_ssh_agent.sh&lt;/code&gt; as needed)&lt;/em&gt;&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/zsh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# ~/dotfiles/scripts/setup_ssh_agent.sh - Sets up ssh-agent via keychain.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# If &amp;#39;op&amp;#39; CLI and the askpass helper script are available, uses 1Password for passphrase.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Intended to be sourced by .zshrc or similar.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;_askpass_helper&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.ssh/askpass-1password.sh&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="nv"&gt;_askpass_vars_set&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Check if &amp;#39;op&amp;#39; CLI is installed and the helper script is usable&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt; -v op &amp;gt;/dev/null 2&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_askpass_helper&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; -x &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_askpass_helper&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# Configure environment for 1Password helper script&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;SSH_ASKPASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_askpass_helper&lt;/span&gt;&lt;span class="s2"&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="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;SSH_ASKPASS_REQUIRE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;prefer&amp;#34;&lt;/span&gt; &lt;span class="c1"&gt;# Use ASKPASS even in terminals&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;_askpass_vars_set&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Execute keychain to start/find agent, load key, and set env vars&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# (Adjust key name &amp;#39;id_ed25519&amp;#39; as needed)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="k"&gt;$(&lt;/span&gt;keychain --eval --quiet --agents ssh --inherit any-once id_ed25519&lt;span class="k"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Clean up ASKPASS variables if they were set&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;$_askpass_vars_set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;unset&lt;/span&gt; SSH_ASKPASS&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;unset&lt;/span&gt; SSH_ASKPASS_REQUIRE&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Step 3: Source the Setup Script in &lt;code&gt;.zshrc&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;Add this to your &lt;code&gt;~/.zshrc&lt;/code&gt; (or equivalent):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zsh" data-lang="zsh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# --- SSH Agent Configuration (via keychain &amp;amp; 1Password) ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Source the dedicated setup script if it exists.&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;_ssh_setup_script&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="nv"&gt;HOME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dotfiles/scripts/setup_ssh_agent.sh&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; -f &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_ssh_setup_script&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$_ssh_setup_script&lt;/span&gt;&lt;span class="s2"&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="k"&gt;fi&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# --- End SSH Agent Configuration ---&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;How It Works:&lt;/strong&gt;&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;When you start a new shell session, &lt;code&gt;.zshrc&lt;/code&gt; sources &lt;code&gt;setup_ssh_agent.sh&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;The script checks if &lt;code&gt;op&lt;/code&gt; is installed and the &lt;code&gt;askpass-1password.sh&lt;/code&gt; helper is ready.&lt;/li&gt;&#10;&lt;li&gt;If yes, it sets the &lt;code&gt;SSH_ASKPASS&lt;/code&gt; and &lt;code&gt;SSH_ASKPASS_REQUIRE&lt;/code&gt; environment variables.&lt;/li&gt;&#10;&lt;li&gt;It runs &lt;code&gt;keychain --eval id_ed25519&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;keychain&lt;/code&gt; starts or connects to &lt;code&gt;ssh-agent&lt;/code&gt; and uses &lt;code&gt;ssh-add&lt;/code&gt; to load &lt;code&gt;id_ed25519&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;ssh-add&lt;/code&gt; sees &lt;code&gt;SSH_ASKPASS&lt;/code&gt; is set and executes &lt;code&gt;askpass-1password.sh&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;The helper script runs &lt;code&gt;op read ...&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;The 1Password CLI handles authentication (prompting for your master password or using biometrics/system auth &lt;em&gt;if necessary&lt;/em&gt; based on its current session state – typically only if the &lt;code&gt;op&lt;/code&gt; session has expired).&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;op read&lt;/code&gt; outputs the passphrase.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;ssh-add&lt;/code&gt; receives the passphrase and unlocks the key, adding it to the agent.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;keychain --eval&lt;/code&gt; outputs the necessary &lt;code&gt;export SSH_AUTH_SOCK=...&lt;/code&gt; commands.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;eval&lt;/code&gt; executes these commands, configuring your current shell to use the agent.&lt;/li&gt;&#10;&lt;li&gt;The &lt;code&gt;ASKPASS&lt;/code&gt; variables are unset.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;&lt;strong&gt;Conclusion:&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;After years of using Funtoo Keychain and 1Password separately, and a brief foray into the native 1Password agent, this combined approach hits the sweet spot for me.&#10;It leverages 1Password&amp;rsquo;s secure vault for storing the SSH key passphrase but restores the &amp;ldquo;unlock once per session&amp;rdquo; convenience I appreciate from &lt;code&gt;keychain&lt;/code&gt;.&#10;It requires a single passphrase entry via 1Password when the key is first added (typically on login/boot), after which the key remains available via the standard &lt;code&gt;ssh-agent&lt;/code&gt; mechanism without further 1Password interaction for that session.&lt;/p&gt;&#10;</description></item></channel></rss>