{"posts":[{"id":"6aa3620ab0ccab00016fcc52","uuid":"a6f6e75f-48e0-472e-8e8e-607637707100","title":"The Node I Thought Was Spare","slug":"the-node-i-thought-was-spare","html":"<h2 id=\"what-started-this\">What Started This</h2>\n<p>I wanted an offsite backup machine. That was the whole idea. Put a small box at a family member's house, hang a 12TB external drive off it, push backups to it over WireGuard, and finally stop pretending that \"backed up to the NAS in the same room\" counts as a backup strategy. Fire, theft, or a bad afternoon with a ransomware payload and the entire thing evaporates at once.</p>\n<p>I did not want to buy hardware for it. I had two BeeLink mini PCs sitting in the rack, both of them part of the <a href=\"https://emir.fyi/building-a-high-availability-kubernetes-cluster-across-mixed-hardware-part-1-the-build/\">high availability Kubernetes cluster I built across mixed hardware</a>, and one of them, I was fairly sure, was mostly idle. I remembered giving it a couple of jobs. Kubernetes something. Maybe a load balancer? I would go look, move whatever it was doing somewhere else, and reclaim the box.</p>\n<p>Reader, it was not mostly idle.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Work out what the box is actually doing</s> (done, and it was more than I thought)</li>\n<li><s>Work out whether you can even remove a node from a three node cluster</s> (done, the answer is \"not directly\")</li>\n<li><s>Build a replacement VM on the other hypervisor</s> (done)</li>\n<li><s>Join it, verify, then drain the old one</s> (done)</li>\n<li><s>Move the host level services across</s> (done)</li>\n<li><s>Fix the three things that broke</s> (done, and two of them were quietly my fault)</li>\n<li>Wipe the freed box and build the offsite machine (pending, next post)</li>\n</ol>\n<h2 id=\"the-machine-that-wasnt-free\">The Machine That Wasn't Free</h2>\n<p>My mental model of that node was \"Kubernetes control plane, a Postgres replica, maybe a load balancer node.\" Three jobs, two of which I could probably shuffle elsewhere in an afternoon. I checked it properly instead of trusting my memory, which is the first thing that went right.</p>\n<pre><code>kubectl get pods -A -o wide --field-selector spec.nodeName=n1\n</code></pre>\n<p>The real list:</p>\n<table>\n<thead>\n<tr>\n<th>What</th>\n<th>Detail</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Control plane</td>\n<td>etcd, apiserver, controller manager, scheduler</td>\n</tr>\n<tr>\n<td>kube-vip</td>\n<td>serves the API VIP</td>\n</tr>\n<tr>\n<td>Postgres</td>\n<td>one <a href=\"https://emir.fyi/making-postgres-ha-on-kubernetes-with-cloudnativepg-and-the-operator-spof-nobody-talks-about/\">CloudNativePG</a> instance</td>\n</tr>\n<tr>\n<td>CoreDNS</td>\n<td>one of exactly two replicas</td>\n</tr>\n<tr>\n<td>sealed-secrets</td>\n<td>the only replica</td>\n</tr>\n<tr>\n<td>ArgoCD</td>\n<td>server and notifications controller</td>\n</tr>\n<tr>\n<td>Apps</td>\n<td>two workloads plus a web frontend</td>\n</tr>\n<tr>\n<td>Host services</td>\n<td>one of my <a href=\"https://emir.fyi/making-cloudflare-tunnel-actually-highly-available/\">cloudflared tunnel connectors</a>, monitoring agent</td>\n</tr>\n</tbody>\n</table>\n<p>The load balancer guess was wrong. My <a href=\"https://emir.fyi/entire-homelab-dies-because-one-machine-reboots-building-an-ha-load-balancer-with-keepalived-and-caddy/\">HA load balancer</a> runs on three completely different machines. What I was thinking of was <code>kube-vip</code>, which serves the Kubernetes API VIP, a different VIP entirely from the one Caddy floats for services. Two VIPs, two purposes, one confused homelabber.</p>\n<p>Nine jobs, not three. And note what is in that list: the <strong>only</strong> replica of sealed-secrets, one of exactly <strong>two</strong> CoreDNS replicas, a third of my tunnel connectors. This was not a spare box with a cluster membership card. It was a working node carrying a slice of nearly every service I run, and I had it filed in my head as \"mostly idle\" because the last time I thought about it, in April, it was.</p>\n<p>Most of that list reschedules by itself, thankfully. Pods are cattle. The two that do not move on their own are the control plane role and the Postgres instance, and the control plane role is the one that turns this from \"drain a node\" into \"plan a migration.\"</p>\n<h2 id=\"the-quorum-math-that-decides-everything\">The Quorum Math That Decides Everything</h2>\n<p>Here is the thing I nearly got wrong, and it is the whole reason this post exists.</p>\n<p>etcd needs a majority to accept writes. Three members means a quorum of two, so you can lose one member and keep running. That is the entire point of running three.</p>\n<p>Two members means a quorum of two. You can lose <strong>zero</strong>.</p>\n<table>\n<thead>\n<tr>\n<th>Members</th>\n<th>Quorum</th>\n<th>Failures tolerated</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>3</td>\n<td>2</td>\n<td>1</td>\n</tr>\n<tr>\n<td>2</td>\n<td>2</td>\n<td><strong>0</strong></td>\n</tr>\n<tr>\n<td>1</td>\n<td>1</td>\n<td>0</td>\n</tr>\n</tbody>\n</table>\n<p>A two member etcd cluster is not \"slightly less redundant than three.\" It is worse than a single node in a specific and nasty way: you now have two machines that can each independently take the whole cluster down, instead of one. You have doubled your failure surface and kept zero tolerance. It is the worst square on the board.</p>\n<p>So \"just remove the node and run on two until I get around to it\" was never an option. The replacement had to exist first.</p>\n<p>The sequence that works is three, then four, then three:</p>\n<table>\n<thead>\n<tr>\n<th>Stage</th>\n<th>Members</th>\n<th>Quorum</th>\n<th>Tolerates</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Today</td>\n<td>3</td>\n<td>2</td>\n<td>1</td>\n</tr>\n<tr>\n<td>New node joined</td>\n<td>4</td>\n<td>3</td>\n<td>1</td>\n</tr>\n<tr>\n<td>Old node removed</td>\n<td>3</td>\n<td>2</td>\n<td>1</td>\n</tr>\n</tbody>\n</table>\n<p>Four members feels wasteful and is a bit odd (an even number buys you nothing over three), but it is a waypoint, not a destination. Crucially, tolerance never drops below where it started. At no point during the migration was the cluster less resilient than the day before.</p>\n<h2 id=\"which-node-do-you-actually-retire\">Which Node Do You Actually Retire?</h2>\n<p>I had two BeeLinks and assumed they were interchangeable. They were not, and the thing that decided it was storage.</p>\n<p>Both nodes use <code>local-path</code> provisioner volumes, which are node local disk with a hard node affinity baked into the PersistentVolume. A pod can reschedule anywhere. Its data cannot follow.</p>\n<pre><code>kubectl get pv -o json | jq -r '...'\n\npg-ha-1            -&gt; node=n2\npg-ha-2            -&gt; node=n1\npg-ha-3            -&gt; node=ha-cp\nvaultwarden-data   -&gt; node=n2\n</code></pre>\n<p>There it is. My <strong>password vault</strong> lives on a 1GiB local-path volume pinned to n2. Retiring n2 would mean hand migrating that data or losing it. Retiring n1 means losing a Postgres replica, which <a href=\"https://emir.fyi/making-postgres-ha-on-kubernetes-with-cloudnativepg-and-the-operator-spof-nobody-talks-about/\">the operator</a> rebuilds from the primary automatically and without me being clever.</p>\n<p>So n1 it was, decided by a single line of PersistentVolume node affinity rather than by my vague sense of which box was busier.</p>\n<p>That discovery had a bonus prize attached, which is that I now know my password vault is sitting on one node's local disk with no backup anywhere. Small, irreplaceable, and the least protected thing I own. It went straight onto the TODO list, ahead of the photos.</p>\n<h2 id=\"add-before-you-remove\">Add Before You Remove</h2>\n<p>The replacement is a VM on my second hypervisor, which was running one Home Assistant VM and 6GB of its 62GB of RAM. Embarrassing amounts of headroom.</p>\n<p>The VM itself is Terraform, cloned from the Ubuntu template:</p>\n<pre><code class=\"language-hcl\">resource \"proxmox_virtual_environment_vm\" \"n3\" {\n  name      = \"n3\"\n  node_name = \"hypervisor2\"\n  vm_id     = 141\n\n  clone { vm_id = 9000 }\n  cpu    { cores = 4 }\n  memory { dedicated = 16384 }\n  disk   { interface = \"scsi0\"; size = 150; datastore_id = \"local-lvm\" }\n\n  initialization {\n    ip_config { ipv4 { address = \"192.168.1.43/24\"; gateway = \"192.168.1.1\" } }\n  }\n}\n</code></pre>\n<p>That disk is 150GB for a reason. A Postgres volume is going to land on this node, and I would rather size it once than resize it under pressure later.</p>\n<h2 id=\"the-version-pin-i-almost-missed\">The Version Pin I Almost Missed</h2>\n<p>My cluster runs Kubernetes 1.35.3. My Ansible playbook installed the packages like this:</p>\n<pre><code class=\"language-yaml\">- name: Install kubeadm, kubelet, kubectl\n  apt:\n    name: [kubeadm, kubelet, kubectl]\n    state: present\n</code></pre>\n<p>No version. So it installs whatever the repository is offering today. I checked before running it:</p>\n<pre><code>Installed: 1.35.3-1.1\nCandidate: 1.35.8-1.1\n</code></pre>\n<p>The repo had drifted five patch releases ahead of my cluster while I was not looking. Unpinned, my brand new control plane node would have joined at 1.35.8 alongside three peers on 1.35.3. It probably would have worked. Patch skew inside a minor version usually does. But \"probably fine\" is not a thing I want to discover about my control plane at 11pm, and more to the point it would have been an <em>accidental</em> upgrade of one node, which is the kind of drift that is invisible until it is not.</p>\n<p>The fix is boring and permanent:</p>\n<pre><code class=\"language-yaml\">- name: Install kubeadm, kubelet, kubectl (pinned)\n  apt:\n    name:\n      - \"kubeadm={{ k8s_package_version }}\"\n      - \"kubelet={{ k8s_package_version }}\"\n      - \"kubectl={{ k8s_package_version }}\"\n    state: present\n    allow_downgrade: true\n</code></pre>\n<p>The version lives in the inventory next to the cluster definition, where you bump it deliberately as an upgrade rather than absorbing it by accident.</p>\n<h2 id=\"three-things-that-went-wrong\">Three Things That Went Wrong</h2>\n<p>None of these were in my plan. All three are now written into the runbook, which is the actual product of an exercise like this.</p>\n<h3 id=\"the-tag-that-skipped-a-step\">The tag that skipped a step</h3>\n<p>My playbook has a final play that removes the <code>NoSchedule</code> taint from control plane nodes, because in a three node homelab cluster every node has to run workloads. That play carries its own tag. I ran the join with <code>--tags join</code>. You can see where this is going.</p>\n<p>The node joined perfectly. Then I moved the Postgres replica, and it sat in <code>Pending</code> for five minutes:</p>\n<pre><code>0/4 nodes are available: 1 node(s) had untolerated taint(s),\n1 node(s) were unschedulable, 2 node(s) didn't match pod anti-affinity rules\n</code></pre>\n<p>That message is a complete story if you read it. One node cordoned (the one I was draining), two nodes already holding a Postgres instance so anti-affinity rules them out, and one node tainted. Nowhere to go. The new node was tainted because the untaint play never ran.</p>\n<p>Running the tags separately felt tidy and surgical. It was actually just skipping steps.</p>\n<h3 id=\"the-certificate-the-new-node-had-never-heard-of\">The certificate the new node had never heard of</h3>\n<p>Draining n1 moved its pods onto the other nodes, and two of them landed on n3. Those two are an app I build myself, so its image comes from a registry running in my own house rather than from Docker Hub. They would not start:</p>\n<pre><code>Failed to pull image \"registry.lan/myapp:latest\":\n  x509: certificate signed by unknown authority\n</code></pre>\n<p>Everything else in the cluster pulls from public registries that Ubuntu already trusts, so those pods came up without complaint. Only my own images come from my own registry, and that registry serves HTTPS with a certificate from my own internal certificate authority. Every other machine here has trusted that CA for as long as I can remember. The node I had built an hour earlier had never heard of it.</p>\n<p>One playbook fixed that, and it fixes it for every internal service at once rather than just the registry:</p>\n<pre><code>ansible-playbook playbook-step-ca-trust.yml --limit n3\n</code></pre>\n<p>Then it kept failing. Same error, character for character. containerd reads the system trust store when it starts, and it had been running for an hour, so the certificate was sitting on disk where it could not see it. A restart and the pull went straight through.</p>\n<p>An error message that does not change after you have fixed its cause is a genuinely nasty way to lose ten minutes.</p>\n<h3 id=\"the-load-balancer-still-pointing-at-a-ghost\">The load balancer still pointing at a ghost</h3>\n<p>This is the one with real blast radius, and the one I nearly did not find.</p>\n<p>My Caddy load balancer round robins Kubernetes traffic across the control plane nodes:</p>\n<pre><code>reverse_proxy 192.168.1.40:30443 192.168.1.41:30443 192.168.1.42:30443 {\n  lb_policy round_robin\n  health_interval 10s\n  health_timeout 5s\n  }\n</code></pre>\n<p>That first address is the node I had just removed from the cluster. A third of my upstream slots pointed at a machine that no longer served the port.</p>\n<p>Every service still returned 200. Every single one. Because Caddy health checks the backends every ten seconds, noticed the dead one, and quietly routed around it. The system healed over the mistake so smoothly that nothing in my monitoring, and nothing in my browser, gave me any indication that my three way load balancing had silently become two way.</p>\n<p>That is the failure mode I find genuinely unsettling. Not the outage that pages you. The degradation that works.</p>\n<p>I only caught it because I went looking through the docs for stale references to the old node's address and found it sitting in an Ansible template. If I had trusted \"everything returns 200,\" it would still be there, and I would have found out the next time one of the two surviving backends went down.</p>\n<p><strong>Config that references cluster membership is state, and it drifts.</strong> The health check is a safety net, not a substitute for the config being right.</p>\n<h2 id=\"what-it-looks-like-now\">What It Looks Like Now</h2>\n<table>\n<thead>\n<tr>\n<th></th>\n<th>Before</th>\n<th>After</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Control plane</td>\n<td>VM, BeeLink, BeeLink</td>\n<td>VM, BeeLink, <strong>VM on the second hypervisor</strong></td>\n</tr>\n<tr>\n<td>Physical hosts</td>\n<td>2</td>\n<td><strong>3</strong></td>\n</tr>\n<tr>\n<td>etcd members</td>\n<td>3</td>\n<td>3, and never fewer during the swap</td>\n</tr>\n<tr>\n<td>Postgres</td>\n<td>3/3</td>\n<td>3/3, replica rebuilt on the new node</td>\n</tr>\n<tr>\n<td>Tunnel connectors</td>\n<td>3</td>\n<td>3, and never fewer than 2 during the swap</td>\n</tr>\n</tbody>\n</table>\n<p>The failure domain spread actually improved. Before, two of my three control plane nodes were BeeLinks sitting on the same shelf on the same power strip. Now the three members live on three separate physical machines. I did not set out to improve resilience, I set out to free up a box, but moving a node from \"one of two identical mini PCs\" to \"a completely different hypervisor\" is a genuine upgrade that fell out of the migration for free.</p>\n<p>And the Postgres replica rebuild was the least dramatic part of the whole day. Delete the volume and the pod, and the operator notices an instance is missing, provisions a new volume on an available node, clones from the primary over streaming replication, and rejoins the quorum. My actual database is 922MB, so this took seconds. I watched a pod called <code>pg-ha-4</code> appear on a machine that had existed for about an hour and start serving as a synchronous replica, and felt slightly redundant myself.</p>\n<h2 id=\"what-i-actually-learned\">What I Actually Learned</h2>\n<p><strong>Nothing in a three node cluster is spare.</strong> Three is the minimum for a quorum. Every node is load bearing by definition, and \"I'll just take one back\" is not a thing you get to do without building its replacement first. If you want a spare machine, the number you need to start from is four.</p>\n<p><strong>Audit, do not remember.</strong> My recollection of that node's job was wrong in one place and incomplete in about six others. Thirty seconds of <code>kubectl get pods --field-selector</code> was worth more than everything I thought I knew about my own cluster.</p>\n<p><strong>The storage decides which node you can retire, not the CPU.</strong> I was thinking about which box was busier. The actual constraint was one line of node affinity on a PersistentVolume, pinning my password vault to a specific machine. Node local storage turns pods back into pets.</p>\n<p><strong>A fresh node is not a peer.</strong> It is missing every piece of state that accreted onto the others one incident at a time. Certificate trust, package pins, taints. Those are not documented anywhere because they were never a decision, they were just Tuesday. Building a new node is the only reliable way to find them, and writing them down afterwards is the only way to not rediscover them next time.</p>\n<p><strong>Watch out for the systems that heal over your mistakes.</strong> Health checks, retries, and automatic failover are exactly what you want, right up until they hide a configuration error so completely that you would never know except by reading the config. Redundancy that quietly becomes less redundant is the thing that gets you, because it feels identical to redundancy that works.</p>\n<p>The box is free now. The BIOS setting that makes it power itself back on after a blackout has to be sorted before it leaves the house, because at a family member's home there is nobody to press the button and a backup machine that silently stays off after a power blip is worse than no backup machine at all. That, and the offsite build itself, is the next post.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<p>A dimly lit home server rack photographed at a slight angle, three small mini PCs stacked on a shelf with soft blue and amber status LEDs glowing. One of the three machines is being lifted out by a pair of hands, and where it was there is a faint translucent blue wireframe outline of a replacement machine already materializing in the empty slot. Thin glowing cables connect all the machines, and the cables to the departing box are fading while the cables to the wireframe brighten. Shallow depth of field, moody dark background, cinematic teal and orange color grading, high detail, photorealistic, homelab aesthetic.</p>\n","comment_id":"6aa3620ab0ccab00016fcc52","feature_image":"https://emir.fyi/content/images/2026/09/fd499dc4-74aa-4d97-bb7d-92c839790b35.png","featured":false,"visibility":"public","created_at":"2026-09-10T22:06:02.000-04:00","updated_at":"2026-09-11T11:24:19.000-04:00","published_at":"2026-09-11T11:24:19.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/the-node-i-thought-was-spare/","excerpt":"What Started This\n\n\nI wanted an offsite backup machine. That was the whole idea. Put a small box at a family member's house, hang a 12TB external drive off it, push backups to it over WireGuard, and finally stop pretending that \"backed up to the NAS in the same room\" counts as a backup strategy. Fire, theft, or a bad afternoon with a ransomware payload and the entire thing evaporates at once.\n\n\nI did not want to buy hardware for it. I had two BeeLink mini PCs sitting in the rack, both of them pa","reading_time":10,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"6a9d7046ccab54000125377a","uuid":"5c79f38d-3746-4076-9eec-9a0620bfbdc8","title":"My Monitoring Hub Lived Inside the Thing It Was Watching","slug":"my-monitoring-hub-lived-inside-the-thing-it-was-watching","html":"<h2 id=\"the-problem\">The Problem</h2>\n<p>Here is a fun little paradox I let sit in my homelab for way too long. My monitoring hub, the piece of software whose entire job is to tell me when something in the house has fallen over, was running on a Mac Mini in the house. Same power. Same internet. Same failure domain as everything it was supposed to be watching.</p>\n<p>So the day the power flickers or the ISP has a bad afternoon, here is what happens: the homelab goes dark, and the thing that is supposed to text me \"hey, the homelab went dark\" goes dark with it. The watchtower was inside the castle. If the castle burns, the tower does not get to send up a flare, because the tower is also on fire.</p>\n<p>And sure enough this actually happened, while I was away for the world cup and I had no idea what's going on. Luckily I have great neighbors who came in and inspected the house. The issue was that my entire electrical circuit shut down, meaning my fancy ups power backup worked until it didn't. This isn't solution to that problem (which I've resolved in the meantime), but rather an additional enhancement towards remidiating a broken system.</p>\n<p>I run <a href=\"https://beszel.dev/?ref=emir.fyi\">Beszel</a> for this, and I love it. It is tiny, it is fast, it does one job. But it was in the wrong place. What I wanted was a hub somewhere else entirely, on a box that stays up when my house does not, so it can be the one thing that survives to tell me the rest did not.</p>\n<p>I already had a candidate. A little cloud VPS I stood up a while back to be a <a href=\"https://emir.fyi/wireguard-cloud-vpn-and-locking-myself-out-hardening-ssh/\">WireGuard exit node</a>, sitting in a datacenter on someone else's power and someone else's uplink. It was doing one small job and had plenty of room for a second. Outside my blast radius, on infrastructure I trust more than my own breaker panel. Perfect.</p>\n<p>This is the story of moving the hub there. It went well, right up until the moment I texted myself that fourteen services were down. All of them. At once. On purpose, sort of. Let me tell you about it over coffee.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Work out which agents push to the hub and which get pulled, because it changes everything</s> (done, and it was detective work)</li>\n<li><s>Convert the one stubborn pull agent to push</s> (done, cleanly, no duplicate)</li>\n<li><s>Stand up the hub on the VPS as a plain binary, not Docker</s> (done)</li>\n<li><s>Copy the database across and upgrade the version</s> (done)</li>\n<li><s>Cut every agent over to the new hub and retire the old one</s> (done)</li>\n<li><s>Accidentally page myself with a full fake apocalypse</s> (extremely done)</li>\n<li><s>Add a firewall without dropping a live video stream</s> (done, mid episode)</li>\n</ol>\n<h2 id=\"push-or-pull-and-why-you-must-know-before-you-touch-anything\">Push or Pull, and Why You Must Know Before You Touch Anything</h2>\n<p>Beszel agents can talk to the hub in two directions, and the whole migration hinges on which one each agent uses.</p>\n<p>In <strong>push</strong> mode the agent dials out to the hub over a WebSocket. The agent is the one making the connection. Put the hub anywhere with a public address and the agent will find it, no inbound holes required.</p>\n<p>In <strong>pull</strong> mode the hub reaches in to the agent over an SSH-style channel on port 45876. The hub is the one making the connection, so it needs a route to the agent.</p>\n<p>That distinction is the difference between \"trivial migration\" and \"please open a hole from a cloud box into your home network,\" which is a sentence that should make anyone uneasy. If every agent pushes, the cloud hub never needs to touch my LAN. If any agent gets pulled, I would have to expose it. So before I moved a single thing, I needed to know exactly what I was dealing with.</p>\n<p>The hub keeps all of this in a little SQLite database. I pulled the list of systems out of it:</p>\n<pre><code class=\"language-bash\">sqlite3 ~/beszel/data/data.db \\\n  \"SELECT name, host, status FROM systems ORDER BY name;\"\n</code></pre>\n<p>Most rows looked normal, a hostname and a LAN address. But a cluster of them listed a <code>host</code> of <code>192.168.1.1</code>, an address the hub could not possibly reach, and yet their status was cheerfully <code>up</code>. That stopped me. How is a system reporting healthy if the hub cannot even route to the address it has on file for it?</p>\n<p>The answer, once it clicked, is airtight. The <code>host</code> field means two completely different things depending on direction. In pull mode it is a destination the hub dials, so it has to be reachable. In push mode it is just the return address stamped on the connection when the agent phoned in, whatever the network happened to rewrite it to on the way. So an unreachable address plus an <code>up</code> status can only mean one thing: the hub is not reaching out to that box at all. The data is arriving on its own. That is a push agent, full stop, and no amount of squinting at the address changes it.</p>\n<p>I verified the handful of ambiguous ones by actually reading each agent's config, but that one piece of logic did most of the work. When I finished, the tally was fifteen push and one pull. The one holdout was my container host.</p>\n<h2 id=\"converting-the-last-pull-agent-without-making-a-mess\">Converting the Last Pull Agent Without Making a Mess</h2>\n<p>The container host was the only box the old hub was reaching in to. To move the hub to the cloud, that agent had to start pushing instead.</p>\n<p>Here is the part I was nervous about. Beszel identifies a pushing agent by a fingerprint tied to a registration token. If I just flipped the agent to push, would it bind to the existing system record and keep all its history, or would it show up as a brand new duplicate and leave a ghost behind?</p>\n<p>I went looking in the database and found that every system already has a row in a <code>fingerprints</code> table pairing it with a token. The pull agent's row had a token but an empty fingerprint, which is exactly the shape of \"a push agent has been provisioned here but has not connected yet.\" And the agent already held that same token in its config. So the moment it pushed, it would present the matching token, the hub would look it up, find the waiting row, and fill in the fingerprint. Same system, same history, no duplicate.</p>\n<p>Which is precisely what happened. I added the hub URL to the agent, restarted it, watched the log say <code>WebSocket connected</code>, and checked the hub. Still one record. Fingerprint now populated. History intact. That is the good kind of boring.</p>\n<h2 id=\"standing-up-the-hub-and-why-not-docker\">Standing Up the Hub, and Why Not Docker</h2>\n<p>The VPS is an OpenVZ container, and OpenVZ containers are fussy about the things Docker wants from a kernel. Rather than fight overlay filesystems and cgroup quirks, I ran the hub the way it ships, as a single Go binary under systemd. Fewer moving parts, nothing to argue with.</p>\n<pre><code class=\"language-ini\"># /etc/systemd/system/beszel-hub.service\n[Unit]\nDescription=Beszel Hub\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nUser=beszel\nGroup=beszel\nWorkingDirectory=/opt/beszel\nExecStart=/opt/beszel/beszel serve --http 127.0.0.1:8090\nRestart=on-failure\nNoNewPrivileges=true\nProtectSystem=strict\nReadWritePaths=/opt/beszel/beszel_data\n\n[Install]\nWantedBy=multi-user.target\n</code></pre>\n<p>Notice it binds to <code>127.0.0.1</code>. The hub itself is never exposed. In front of it I put Caddy, which handles TLS and gets a real Let's Encrypt certificate automatically. The entire reverse proxy config is four lines:</p>\n<pre><code>beszel.example.com, metrics.example.com {\n        reverse_proxy 127.0.0.1:8090\n}\n</code></pre>\n<p>That Let's Encrypt certificate turned out to matter more than I expected, and it quietly solved a problem I had been carrying. My old hub used a certificate from my private CA, which meant every agent had to trust my private root or it would refuse the connection and silently go offline. A public certificate is trusted by everything out of the box. Moving to the cloud with a real cert deleted an entire class of \"why is this agent down\" from my life.</p>\n<p>Two hostnames, on purpose. The UI lives at <code>metrics.example.com</code> and goes through Cloudflare's proxy, which is nice for a login page facing the open internet. The agents connect to <code>beszel.example.com</code>, which is a plain DNS record pointing straight at the box, no proxy. That split is not decoration. Beszel's agent WebSocket does not survive Cloudflare's proxy, it comes back with a 401 and the agent sulks. So the agents get a direct path and the humans get the proxied one, and everyone is happy.</p>\n<h2 id=\"copy-the-database-then-upgrade\">Copy the Database, Then Upgrade</h2>\n<p>I wanted the history, the alert rules, and my login to come across, so this was a database copy rather than a fresh start. Beszel uses SQLite, so a consistent snapshot is one command that does not even need the hub stopped:</p>\n<pre><code class=\"language-bash\">sqlite3 ~/beszel/data/data.db \".backup '/tmp/data.db'\"\n</code></pre>\n<p>I copied that to the VPS, dropped it into the new hub's data directory, and started it. Sixteen systems, all my history, my account. Then I upgraded the hub binary to the newest release and let it run its migrations against the copied database, which is the officially blessed upgrade path and went without a complaint. The new version had a feature I had been waiting on, container health alerts, so the upgrade was worth doing on its own.</p>\n<p>Old hub still running at home the whole time, untouched, ready to flip back to. That detail becomes important in about two paragraphs.</p>\n<h2 id=\"the-part-where-i-paged-myself-an-apocalypse\">The Part Where I Paged Myself an Apocalypse</h2>\n<p>Here is where I earned the coffee.</p>\n<p>The database I copied did not just bring my systems and history. It brought my alert rules and my notification config, which is to say my Telegram webhook. And the new hub, freshly started, looked at its sixteen systems, saw that almost none of them were talking to it yet because they were all still pushing to the old hub at home, and did exactly what I built it to do. It decided they were down. All of them. And it reached for the phone.</p>\n<p>My pocket started buzzing like an angry hornet. \"System down. System down. System down.\" Fourteen of them, in a tight little cluster, each one a small lie. Nothing was actually down. Everything was fine and happily reporting to the other hub. I had simply stood up a second watchtower, handed it a stale map, and it panicked on cue.</p>\n<p>The fix in the moment was to stop the new hub before it could send more. The lesson underneath it is the one worth keeping. When you run two monitoring hubs at once during a migration, and your agents are only reporting to one of them, the <em>other</em> hub sees a graveyard and starts screaming about it. And it is worse than that, because during a partial cutover the pain flips: as each agent moves to the new hub, the <em>old</em> hub loses sight of it and wants to alert too. Whichever hub an agent is not talking to will try to page you about it.</p>\n<p>So the real move, the one I should have made from the start, is to silence both hubs before you touch anything. I blanked the webhook on each one, saving the original first, so the rules could keep evaluating but had nowhere to send. Then I cut everything over in a batch, confirmed all sixteen were green on the new hub, and only then restored the webhook on the winner. Quiet migration, no hornets.</p>\n<p>If you take one thing from this post, that is it. A copied monitoring database is a loaded notification cannon. Point it at the wall before you plug it in.</p>\n<h2 id=\"cutting-over-and-the-fallback-that-would-not-let-go\">Cutting Over, and the Fallback That Would Not Let Go</h2>\n<p>With both hubs muzzled, moving the agents was mechanical. For the Linux boxes the hub URL lives in the systemd unit, so it was a small edit and a restart, scripted across the fleet. The Mac agents keep it in an env file. The container host meant recreating a container. The TrueNAS box and the Home Assistant add on each needed a hand. One by one they lit up green on the new hub.</p>\n<p>Then I noticed one box kept flickering. It would connect to the new hub, then a few minutes later drop and reappear on the <em>old</em> one. I would push it back, it would drift home again.</p>\n<p>The culprit was a fallback I had forgotten I built in. Every pushing agent also keeps that pull-style listener open on 45876 as a backup path, and the old hub still had all these systems in its list, so it was actively reaching in and grabbing them the instant a WebSocket so much as hiccuped. The old hub was not a passive has-been waiting to be retired. It was standing there with a fishing rod, reeling my agents back the moment my attention wandered.</p>\n<p>There is no clever fix for that. The old hub had to actually go. I stopped it and disabled it from ever starting again, and the flickering stopped instantly. The new hub was finally the only voice in the room.</p>\n<h2 id=\"a-firewall-while-the-vpn-was-carrying-my-favorite-tv-show\">A Firewall, While the VPN Was Carrying My Favorite TV Show</h2>\n<p>I tune to my favorite TV Show on Bosnian TV via VPN because they only allow local broadcast. So here we are, last thing we need to do.</p>\n<p>The box now hosted a monitoring hub as well as a VPN, and it had no host firewall at all, just a wide open default policy leaning entirely on the fact that only a few things were listening. I wanted a proper default deny.</p>\n<p>The complication: as I sat down to do this, that same box was actively streaming my favorite show to a phone over WireGuard. Get this wrong and I do not just lock myself out, I cut the episode off right before the cliffhanger. That is not a mistake you get forgiven for.</p>\n<p>So the rules were surgical. Touch only the inbound chain, never the forwarding rules that carry the VPN traffic, put \"keep every connection that already exists\" as the very first rule so the live stream sails through untouched, and explicitly allow the VPN port on top of that for belt and suspenders. Allow SSH, the VPN, and the two web ports. Deny the rest.</p>\n<pre><code class=\"language-bash\"># accepts first, while the policy is still open, so nothing drops mid-setup\niptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT\niptables -A INPUT -i lo -j ACCEPT\niptables -A INPUT -p icmp -j ACCEPT\niptables -A INPUT -p tcp --dport 49222 -j ACCEPT   # ssh, on a non-default port\niptables -A INPUT -p udp --dport 51820 -j ACCEPT   # wireguard\niptables -A INPUT -p tcp --dport 80 -j ACCEPT      # acme\niptables -A INPUT -p tcp --dport 443 -j ACCEPT     # caddy + beszel\n# and only now, the door\niptables -P INPUT DROP\n</code></pre>\n<p>The ordering is the whole trick. Every allow rule goes in while the policy is still open, so there is never a moment where a legitimate packet gets dropped because its rule had not been added yet. The drop policy goes on last, by which point the \"keep existing connections\" rule is already in place protecting both my SSH session and the stream.</p>\n<p>And because I have locked myself out of exactly enough remote boxes to have learned, I did all of this with a second SSH session held open and a script already counting down to undo the whole thing in five minutes unless I confirmed it worked. I confirmed. The stream never so much as stuttered. I watched the VPN's byte counter climb straight through the change, which is the only proof I actually trust.</p>\n<p>I reached for the modern firewall tool first, <code>nft</code>, and found it was not even installed on this little container, and its existing VPN rules were all in old-school <code>iptables</code> anyway. Mixing a fresh firewall framework into a box carrying a live stream is not a move you make on a whim, so I stayed on <code>iptables</code> to match what was already there. Right tool is sometimes just the one already in the room.</p>\n<h2 id=\"what-i-actually-took-away\">What I Actually Took Away</h2>\n<p>The hub works. It sits in a datacenter, watches my whole house from the outside, and if the house goes dark it is the one thing still standing to tell me. The paradox is resolved. The watchtower is finally outside the castle.</p>\n<p>But the running service is not really the souvenir. Three things are.</p>\n<p>The first is a piece of reasoning I will reuse forever: an unreachable address plus a healthy status can only mean the data is being pushed, not pulled. Systems tell you how they are wired if you read them carefully enough.</p>\n<p>The second is a scar. A copied monitoring database carries live notification config, and it will fire the instant it disagrees with reality. Silence your alerting before a migration, on every hub involved, and turn it back on only when the new world is actually true. I learned that at the cost of one buzzing pocket and a brief, sincere belief that my entire homelab had died.</p>\n<p>The third is the same reflex I keep relearning and keep being grateful for. Before you change anything that controls how you reach a box, or whether it can reach you, arm the undo first. The held session, the five minute timer, the saved-off original config. Every single time it has felt like overkill right up until the one time it was the only thing between me and a very long evening.</p>\n<p>The homelab still cannot tell me it is on fire from inside a fire. But now something outside can. That is the whole point, and it only took one fake apocalypse to get there.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<p>A cinematic tech-noir scene. A tall stone watchtower stands <em>outside</em> a castle wall, perched on a distant hill lit by warm amber light, while the castle itself sits in cool blue shadow across a dark valley. From the tower a single glowing beam of data, rendered as circuit-board traces and light, arcs across the valley toward the castle, watching over it. In the foreground a phone screen glows with a cascade of red \"system down\" alert bubbles, slightly out of focus, hinting at a false alarm. The tower is clearly safe and separate from whatever might happen to the castle. Deep blues and warm ambers, subtle 3D render mixed with clean vector line work, a faint terminal prompt glowing in one corner. A mood of calm vigilance earned through one chaotic night.</p>\n","comment_id":"6a9d7046ccab54000125377a","feature_image":"https://emir.fyi/content/images/2026/09/9c9c7523-e3e4-4fee-ab51-8513ba286bd2.png","featured":false,"visibility":"public","created_at":"2026-09-06T09:53:10.000-04:00","updated_at":"2026-09-07T10:18:36.000-04:00","published_at":"2026-09-07T10:18:36.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/my-monitoring-hub-lived-inside-the-thing-it-was-watching/","excerpt":"The Problem\n\n\nHere is a fun little paradox I let sit in my homelab for way too long. My monitoring hub, the piece of software whose entire job is to tell me when something in the house has fallen over, was running on a Mac Mini in the house. Same power. Same internet. Same failure domain as everything it was supposed to be watching.\n\n\nSo the day the power flickers or the ISP has a bad afternoon, here is what happens: the homelab goes dark, and the thing that is supposed to text me \"hey, the home","reading_time":12,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"6a987ef3ccab540001253767","uuid":"f6caf4fd-a6ae-4af3-8228-33cc332614d9","title":"My Router Kept Turning Itself Off, So I Bought a Real One","slug":"my-router-kept-turning-itself-off-so-i-bought-a-real-one","html":"<h2 id=\"the-problem\">The Problem</h2>\n<p>Every few weeks, my entire house would lose the internet. Not the ISP. Not the WiFi. Everything, all at once, including things that have nothing to do with the internet. DNS stopped resolving, so none of my internal services could find each other. DHCP stopped handing out addresses, so anything that rebooted came up with no network at all. My phone would sit there spinning. My wife would ask, in a tone I have come to recognize, whether the internet was broken again.</p>\n<p>The culprit was a small fanless N100 mini PC I bought off AliExpress for about the price of a nice dinner. It ran pfSense CE perfectly well, right up until it didn't.</p>\n<p>And the recovery ritual is what really got me. My rack is mounted on the wall, so bringing the network back meant climbing a ladder, carrying an HDMI cable, a wireless keyboard and a display up with me, plugging all of it into a firewall, and restarting the thing. At which point it would come up perfectly happily, as if nothing had ever been wrong, and I would climb back down and unplug it all again.</p>\n<p>Here is the part that made it maddening. <strong>Just pressing reset did not work.</strong> Reset with a monitor and a keyboard attached worked every time. It was as though the box only agreed to boot if somebody was watching.</p>\n<p>I ran <code>last reboot</code> one evening to see how bad the pattern was. Thirteen unclean shutdowns. That is not a router. That is a coin flip with an ethernet port.</p>\n<p>I never chased down the root cause, but writing this up I went looking, and it turns out to be an extremely well travelled road. The UEFI firmware on a lot of consumer mini PCs simply refuses to complete boot when it cannot detect a display. Netgate's own forum has been collecting these for years: <a href=\"https://forum.netgate.com/topic/121385/2-4-0-does-not-boot-without-monitor?ref=emir.fyi\">2.4.0 does not boot without Monitor</a>, <a href=\"https://forum.netgate.com/topic/141318/can-t-boot-without-monitor?ref=emir.fyi\">Can't boot without monitor</a>, <a href=\"https://forum.netgate.com/topic/122506/solved-shuttle-dh110-not-booting-headless-after-pfsense-2-4-1-upgrade?ref=emir.fyi\">a Shuttle DH110 doing the same thing</a>, and <a href=\"https://forum.netgate.com/topic/185626/local-hdmi-console-disabled-after-pfsense-booted-with-monitor-off?ref=emir.fyi\">an HDMI console going dark when pfSense boots with the monitor off</a>.</p>\n<p>There are real fixes, and they are cheap. An HDMI dummy plug is a couple of dollars and makes the firmware believe a monitor is present. Disabling the serial ports in BIOS clears it on some boards. So does enabling CSM, or flipping the UEFI OS type. Any one of those might well have ended my ladder trips for the cost of a coffee.</p>\n<p>I did not do any of them, and I want to be honest about why. It was not that I could not find the fix. It was that I no longer wanted to keep patching around a machine I had stopped trusting. Every workaround would have been another thing propping up a box that had already cost me thirteen outages and an unknown number of ladder climbs, and I would still never really know why it went down in the first place.</p>\n<p>What I wanted instead was equipment from the people who actually make pfSense, which reviews consistently said was good. I had resisted spending money on that for a long time, because a firewall is deeply boring and there is always something more fun to buy. But once I added up what the cheap one had actually cost me in time and irritation, the maths stopped being close. If the new one simply does not break, it will have paid for itself.</p>\n<p>So I bought a Netgate 4200.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<p>Here is the arc, so you can see where this is going:</p>\n<ul>\n<li>[x] Back up the existing config</li>\n<li>[x] Bench the new box behind the live firewall</li>\n<li>[x] Update it before touching anything</li>\n<li>[x] Restore the config</li>\n<li>[x] Two cable swap cutover</li>\n<li>[x] Verify everything</li>\n</ul>\n<p>Six steps. It took few hours, and almost none of that time was spent on the parts I expected.</p>\n<h2 id=\"what-i-was-replacing\">What I Was Replacing</h2>\n<p>Let me be fair to the N100 box for a moment, because it was not a bad machine.</p>\n<p>Intel N100, 16 GB of RAM, four 2.5 GbE ports, completely silent, drew almost no power. It ran pfSense CE 2.8.1 with zero packages installed, which turned out to matter enormously later. It routed my whole network for a long time without complaint. For the money it was genuinely good.</p>\n<p>The problem was never performance. The problem was that when it failed, I had no way to diagnose it and nobody to ask, so my only tool was a ladder.</p>\n<p>What I actually wanted was boring. A box whose entire job is to be a firewall, built by people whose entire job is building firewalls, that I never have to look at.</p>\n<h2 id=\"the-question-i-actually-started-with\">The Question I Actually Started With</h2>\n<p>Before I plugged anything in, I had one question I could not find a clean answer to anywhere:</p>\n<p><strong>Do I update the new appliance first, or restore my config first?</strong></p>\n<p>This sounds trivial. It is not, and getting it backwards can waste an afternoon.</p>\n<p>The rule, once you find it buried in Netgate's documentation, is about the <strong>configuration revision</strong>, not the version number. Every pfSense release stamps a revision into <code>config.xml</code>. My CE 2.8.1 box was on revision 24.0.</p>\n<p>And the rule is one directional:</p>\n<table>\n<thead>\n<tr>\n<th>Direction</th>\n<th>Supported?</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Older config onto a newer release</td>\n<td><strong>Yes</strong>, and it gets upgraded automatically</td>\n</tr>\n<tr>\n<td>Newer config onto an older release</td>\n<td><strong>No</strong>, rejected outright</td>\n</tr>\n</tbody>\n</table>\n<p>So if my new appliance had shipped with something older than my config's revision, restoring first would have simply failed. Update first, and the direction is always safe.</p>\n<p>There is a second ordering rule I have never seen written down anywhere, and it is arguably more important:</p>\n<p><strong>The restore has to be the very last thing you do before cutover.</strong></p>\n<p>The instant that config lands, the new box believes it is your gateway. It claims your gateway IP, starts a DHCP server, and starts answering DNS for all your internal hostnames. If it is connected to your live network at that moment, you now have two devices fighting over the same address and two DHCP servers racing to answer every request. That is a genuinely bad afternoon, and it is entirely avoidable by unplugging one cable.</p>\n<p>So the sequence is: update, then unplug WAN, then restore, then cut over.</p>\n<h2 id=\"the-free-safety-net-nobody-mentions\">The Free Safety Net Nobody Mentions</h2>\n<p>This is the best practical tip in this entire post, so I am putting it in its own section.</p>\n<p>My LAN runs on <code>10.10.10.0/24</code>. The Netgate ships with a factory default LAN of <code>192.168.1.1/24</code>. Those do not overlap.</p>\n<p>Which means I could plug the new firewall's WAN port into a switch port on my existing network, plug my laptop into the new firewall's LAN port, and run the whole thing as a lab <strong>behind my live firewall</strong> while my house carried on completely unaware. The new box got internet through double NAT, which is perfectly fine for pulling updates. My laptop sat on <code>192.168.1.x</code> on one side and <code>10.10.10.x</code> on the other, with no conflict.</p>\n<pre><code>[ISP] → [old firewall] → [switch] → [new firewall PORT1/WAN]\n                                          ↓\n                                    [new firewall PORT2/LAN] → [laptop]\n</code></pre>\n<p>Nobody's Netflix was harmed. I could take as long as I wanted.</p>\n<p>Here is the opinionated version: <strong>this is the single strongest argument for replacing a firewall with a second physical box rather than reinstalling the one you have.</strong> There is no equivalent safety net when you are reformatting your only router. You are committed the moment you boot the installer. With two boxes, you can fumble around for hours and roll back with two cables.</p>\n<p>Check your subnets before you rely on this. If your LAN happens to be on <code>192.168.1.0/24</code> you will need to temporarily move the new box to something else first, which is a five minute detour and still worth it.</p>\n<h2 id=\"the-port-labels-are-lying-to-you\">The Port Labels Are Lying To You</h2>\n<p>Here is the trap that would have cost me an hour if I had not read the docs first.</p>\n<p>On the Netgate 4200, the front panel port numbers run in the <strong>opposite</strong> direction to the underlying device names:</p>\n<table>\n<thead>\n<tr>\n<th>Front label</th>\n<th>Device name</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>PORT1 WAN</td>\n<td><code>igc3</code></td>\n</tr>\n<tr>\n<td>PORT2 LAN</td>\n<td><code>igc2</code></td>\n</tr>\n<tr>\n<td>PORT3</td>\n<td><code>igc1</code></td>\n</tr>\n<tr>\n<td>PORT4</td>\n<td><code>igc0</code></td>\n</tr>\n</tbody>\n</table>\n<p>My old box had WAN on <code>igc0</code> and LAN on <code>igc1</code>.</p>\n<p>Read those two things together and you will see the problem. Those device names <strong>exist</strong> on the new box. They are just physical ports 4 and 3. So restoring my config unmodified does not throw an error, does not prompt for anything, and does not warn you. It quietly wires your WAN to the port silkscreened PORT4 and then sits there passing no traffic while you check cables and question your life choices.</p>\n<p>Even the MAC addresses run backwards:</p>\n<pre><code>PORT1 / igc3   00:00:5e:00:53:a0   ← this is the MAC printed on the label\nPORT2 / igc2   00:00:5e:00:53:a1\nPORT3 / igc1   00:00:5e:00:53:a2\nPORT4 / igc0   00:00:5e:00:53:a3\n</code></pre>\n<p>The fix is trivial once you know. My entire 31 KB config file contained exactly two hardware specific strings, on lines 70 and 83. Everything else refers to <code>wan</code> and <code>lan</code> as logical names that do not care what silicon they land on.</p>\n<pre><code class=\"language-bash\"># Remap WAN igc0 → igc3 and LAN igc1 → igc2 before restoring\nsed -i '' 's|&lt;if&gt;igc0&lt;/if&gt;|&lt;if&gt;igc3&lt;/if&gt;|; s|&lt;if&gt;igc1&lt;/if&gt;|&lt;if&gt;igc2&lt;/if&gt;|' config-remapped.xml\n\n# Always confirm you did not corrupt the XML\nxmllint --noout config-remapped.xml\n</code></pre>\n<p>Two lines. That is the entire hardware migration. It is worth pausing on how good that is: a firewall config with 36 DHCP reservations, 34 DNS overrides, NAT rules and a dynamic DNS client moved between completely different hardware by changing two strings.</p>\n<p>The reason it was that easy is that I had <strong>zero packages installed</strong>. No pfBlockerNG, no HAProxy, nothing. Packages are where migrations get ugly, because they carry their own state and their own version compatibility. If you are planning a hardware swap someday, the cheapest thing you can do today is resist installing packages you do not truly need.</p>\n<h2 id=\"then-nothing-worked-at-all\">Then Nothing Worked At All</h2>\n<p>I had a plan. The plan was good. Let me tell you about the four hours in the middle.</p>\n<h3 id=\"the-brand-new-appliance-did-not-finish-booting\">The brand new appliance did not finish booting</h3>\n<p>I unboxed it, plugged in PORT1 to my switch and PORT2 to my laptop, powered it on, and waited. The status LED did its blue flashing thing, which the documentation says means \"OS boot in progress.\" Then it stopped flashing.</p>\n<p>My laptop got a self assigned <code>169.254.x.x</code> address. Nothing was serving DHCP. I could not reach the web interface. The old firewall saw no sign of the new box on the network at all, not a single frame.</p>\n<p>Both ethernet ports had link lights. This turned out to be the most misleading fact of the entire day.</p>\n<p><strong>Ethernet link lights come from the PHY, which powers up with the hardware and negotiates link whether or not the operating system has booted.</strong> A completely wedged box with a dead OS will still light up both ports and negotiate gigabit. I confirmed this later by accident: with the appliance fully shut down into standby, my laptop still reported <code>status: active, 1000baseT &lt;full-duplex&gt;</code> on that cable. Link lights tell you the hardware has power. They tell you nothing else. I will never trust them again.</p>\n<h3 id=\"the-test-that-actually-isolated-the-problem\">The test that actually isolated the problem</h3>\n<p>I spent too long chasing whether the cable was bad, whether the switch port was dead, whether I had the wrong port. All of it was guesswork.</p>\n<p>Here is the test that ended the guessing, and I recommend it any time you are stuck between \"the network path is broken\" and \"the service is broken\":</p>\n<pre><code class=\"language-bash\"># Same source address, same destination, different ports\nnc -z -v -s 192.168.1.100 192.168.1.1 53    # Connection succeeded\nnc -z -v -s 192.168.1.100 192.168.1.1 443   # Operation timed out\nnc -z -v -s 192.168.1.100 192.168.1.1 80    # Operation timed out\n</code></pre>\n<p>A full TCP handshake completed on port 53. A real DNS query came back in 0 msec. So the cable was fine, the switch was fine, the host was alive, and TCP worked. Ports 80 and 443 specifically had nothing listening.</p>\n<p>That one paired result replaced about ninety minutes of theorizing. <strong>When you cannot tell whether the path or the service is broken, find a port on the same host that does work.</strong> If anything answers, the path is proven and you can stop looking at cables.</p>\n<p>The diagnosis: the box had booted, Kea DHCP was running, unbound DNS was running, and the web server had simply never started. On a factory fresh appliance, out of the box.</p>\n<h3 id=\"the-fix-was-embarrassingly-boring\">The fix was embarrassingly boring</h3>\n<p>A power cycle. Short press the power button, wait for the LED to go to pulsing orange, press it again. Ten minutes later the web interface answered.</p>\n<p>I checked afterward whether slow certificate generation was to blame, since that can hold up the web server on a low power CPU. It was not. The GUI certificate had been issued at the factory weeks earlier. That first boot was just genuinely stuck.</p>\n<p>I do not have a satisfying explanation, and I am suspicious of write ups that manufacture one. Sometimes brand new hardware needs to be turned off and on again, and the honest lesson is that a factory fresh appliance failing its first boot is a thing that can happen to you too.</p>\n<h3 id=\"macos-will-lie-to-you-about-dhcp\">macOS will lie to you about DHCP</h3>\n<p>This bit me three separate times, so it is worth its own warning.</p>\n<p>When the DHCP server appears <strong>after</strong> macOS has already given up and self assigned a <code>169.254.x.x</code> address, macOS does not promptly try again. It sits there, contentedly wrong, for a long time. The box being fixed does not reach back and poke your laptop.</p>\n<p>Reseating the USB-C end of my ethernet adapter fixed it every time. <code>sudo ipconfig set en7 DHCP</code> does the same thing without the physical fiddling.</p>\n<p>The nastier version of this problem: while your interface is on <code>169.254.x.x</code>, it has <strong>no route</strong> to the subnet you are trying to reach. So every <code>ping</code>, <code>curl</code> and port check you run silently falls through to your default route and goes out over WiFi instead. I did exactly this and briefly concluded the new firewall was unreachable when in fact I had never sent it a single packet. If you take one thing from this post, make it this: <strong>verify which interface your test actually used before you believe its result.</strong></p>\n<pre><code class=\"language-bash\">route -n get 192.168.1.1     # says which interface will be used\narp -an | grep 192.168.1.1   # says which MAC answered, and on which interface\nping -b en7 192.168.1.1      # forces the test onto a specific interface\n</code></pre>\n<h3 id=\"i-restored-the-wrong-file-and-the-symptom-told-me-exactly-which-one\">I restored the wrong file, and the symptom told me exactly which one</h3>\n<p>This is my favourite failure of the day, because the evidence was so clean.</p>\n<p>After the restore, I could not get an address on PORT2 where LAN was supposed to be. I moved the cable to PORT1, nothing. Moved it to PORT3, and immediately got a proper lease with the right gateway and the right DNS server.</p>\n<p>I logged in and looked at the interface assignments:</p>\n<pre><code>WAN → igc0\nLAN → igc1\n</code></pre>\n<p>Those are my <strong>old box's</strong> values. Which meant I had uploaded the original backup instead of the remapped one. The two files were sitting next to each other in the same directory with identical timestamps, and I grabbed the wrong one in the file dialog.</p>\n<p>And it explains the symptom perfectly. <code>LAN = igc1 = PORT3</code>, which is exactly the port that gave me a lease. The mistake announced itself.</p>\n<p>The fix did not need another restore. Interface assignments are two dropdowns in the web interface:</p>\n<ol>\n<li>Change WAN from <code>igc0</code> to <code>igc3</code>, save. Nothing breaks, since WAN has no cable in it.</li>\n<li>Change LAN from <code>igc1</code> to <code>igc2</code>, save. <strong>Your connection dies immediately</strong>, which is success rather than failure. The change applies server side; the reply just cannot reach you because LAN is no longer on the port you are plugged into.</li>\n<li>Move your cable to PORT2 and reconnect.</li>\n</ol>\n<p>Do those as two separate saves rather than one. There is no reason to lose connectivity before you have to.</p>\n<h2 id=\"the-cutover\">The Cutover</h2>\n<p>By this point the new box was sitting on my bench with the full config, correct interface assignments, and no WAN cable. My house was still happily running on the old flaky box, which had the decency not to switch itself off during any of this.</p>\n<p>The one thing I got right without being told: <strong>I had to physically move the appliance into the rack, which meant powering it down and unplugging it.</strong> My first instinct was to shut down, move it, cable everything up, and power on. That would have meant sitting through a ten minute boot with my whole house offline, hoping it came up correctly this time.</p>\n<p>Instead:</p>\n<ol>\n<li>Clean shutdown, move it into the rack, reconnect power, <strong>leave both network ports disconnected</strong></li>\n<li>Let it fully boot and confirm the web interface answered, with my laptop on PORT2</li>\n<li>Only then power off the old firewall</li>\n<li>Move the WAN cable to PORT1 and the LAN cable to PORT2</li>\n<li>Power cycle the modem</li>\n</ol>\n<p>The slow, uncertain part happened while I was still safely online. Actual downtime was the cable swap plus waiting for a DHCP lease from my ISP. A couple of minutes.</p>\n<p><strong>Power cycle the modem.</strong> Your WAN MAC address just changed, and many ISPs bind the lease to it. Mine handed out an address after a modem restart with no further persuasion. If yours does not, pfSense lets you spoof the old MAC under Interfaces then WAN, which is why you should write the old one down <strong>before</strong> you unplug the box you can no longer read it from.</p>\n<h2 id=\"did-it-work\">Did It Work</h2>\n<p>My public IP changed during the cutover, from one address to a completely different one. This is exactly why I had recorded the old one before starting. Without that baseline I would have spent an hour wondering whether a changed public IP was a symptom of something I had broken.</p>\n<p>The Cloudflare dynamic DNS client picked it up on its own and updated my VPN hostname within a few minutes. There were some alarming errors in the log during the cutover window:</p>\n<pre><code>ERROR [phpDynDNS] (vpn.example.com) Could not determine the request IP address\n                  (using \"wan\", \"igc3\"): gateway not online\n</code></pre>\n<p>Those were transient. The WAN gateway simply had not come up yet. Once it did, the update went through. Worth knowing so you do not panic at your logs the way I briefly did.</p>\n<p>Everything else came across intact:</p>\n<table>\n<thead>\n<tr>\n<th>Check</th>\n<th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>WAN and LAN</td>\n<td>Both negotiated 2500Base-T, correct addresses</td>\n</tr>\n<tr>\n<td>DNS</td>\n<td>Every internal hostname resolving, recursion working</td>\n</tr>\n<tr>\n<td>DHCP</td>\n<td>All 36 reservations honoured</td>\n</tr>\n<tr>\n<td>NAT</td>\n<td>WireGuard forward rebound to the new public IP automatically</td>\n</tr>\n<tr>\n<td>Kubernetes</td>\n<td>All three nodes reachable through the API VIP</td>\n</tr>\n<tr>\n<td>Internal services</td>\n<td>Every one of them answering</td>\n</tr>\n<tr>\n<td>WireGuard from cellular</td>\n<td>Working</td>\n</tr>\n</tbody>\n</table>\n<p>That last one is the test worth doing deliberately. <strong>Connecting over WireGuard from your phone on mobile data exercises the NAT port forward and the dynamic DNS update at the same time,</strong> and it is the only check you cannot fake from inside your own network. If your DDNS had silently failed, that is where you would find out, ideally before you are in an airport rather than after.</p>\n<p>One false alarm worth mentioning, because it nearly sent me chasing nothing: my first sweep reported Home Assistant unreachable. Home Assistant listens on 8123, not 443. I had probed the wrong port. Through the load balancer it returned a perfectly healthy 200. Check what port a service actually listens on before you declare it broken.</p>\n<h2 id=\"what-id-tell-you\">What I'd Tell You</h2>\n<p><strong>Learn the config revision rule.</strong> Older config onto newer release works and gets upgraded. Newer onto older is rejected. Update the appliance first and you are always going the safe direction.</p>\n<p><strong>Restore last, cut over immediately after.</strong> The moment that config lands, the new box thinks it owns your network. Do not let it touch the live wire until the old one is off.</p>\n<p><strong>Bench the new box behind the old one.</strong> If your subnets do not overlap, this costs nothing and buys unlimited time. It is the best reason to migrate onto a second box rather than reinstall your only one.</p>\n<p><strong>Ethernet link lights mean nothing.</strong> They tell you the hardware has power. Not that the OS booted, not that anything is listening.</p>\n<p><strong>Find a port that works before you blame the cable.</strong> One successful connection on any port proves the entire path and saves you from an hour of cable swapping.</p>\n<p><strong>Check which interface your test used.</strong> A self assigned address means no route, which means your test silently went out a different interface and lied to you.</p>\n<p><strong>Write down the old WAN MAC before you unplug anything.</strong> You cannot read it off a box you have already disconnected, and you may need it if your ISP is fussy.</p>\n<p><strong>Keep the old box as a cold spare.</strong> Mine is sitting powered off with its config intact. Rollback is two cables and a button. That safety net is why the cutover was relaxed instead of terrifying.</p>\n<p><strong>Resist installing packages.</strong> My migration was two lines of <code>sed</code> because there was nothing else to carry.</p>\n<p>The part I keep coming back to is that almost nothing that went wrong was the thing I had prepared for. I had researched the port mapping trap carefully and it never bit me, precisely because I had researched it. What actually cost me the afternoon was a brand new appliance failing its first boot, an operating system that would not re-request DHCP, and me grabbing the wrong file out of a folder.</p>\n<p>Which is roughly how all of these go. The known risks get handled because they are known. The time disappears into the things nobody thought to write down.</p>\n<p>So I wrote them down.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<blockquote>\n<p>A rack mounted white security gateway appliance seated cleanly in a home server rack, four ethernet ports with two blue cables plugged into the leftmost ports, small blue status LEDs glowing. Beside it on the shelf sits a small unbranded generic mini PC, powered off, cables coiled, clearly retired. Shallow depth of field, moody low key homelab lighting, teal and amber color grade, photographic realism, no text, no logos, no visible branding.</p>\n</blockquote>\n","comment_id":"6a987ef3ccab540001253767","feature_image":"https://emir.fyi/content/images/2026/09/40c3bbcc-4d72-4444-b7b9-076039cdd33c.png","featured":false,"visibility":"public","created_at":"2026-09-02T15:54:27.000-04:00","updated_at":"2026-09-02T16:15:13.000-04:00","published_at":"2026-09-02T16:14:22.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/my-router-kept-turning-itself-off-so-i-bought-a-real-one/","excerpt":"The Problem\n\n\nEvery few weeks, my entire house would lose the internet. Not the ISP. Not the WiFi. Everything, all at once, including things that have nothing to do with the internet. DNS stopped resolving, so none of my internal services could find each other. DHCP stopped handing out addresses, so anything that rebooted came up with no network at all. My phone would sit there spinning. My wife would ask, in a tone I have come to recognize, whether the internet was broken again.\n\n\nThe culprit w","reading_time":14,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"6a8db746ccab540001253754","uuid":"37f38146-d8d7-4e10-b162-a6f21a337a3f","title":"The Patch Was Easy. The Password Reset Almost Locked Me Out","slug":"the-patch-was-easy-the-password-reset-almost-locked-me-out","html":"<h1 id=\"the-patch-was-easy-the-password-reset-almost-locked-me-out\">The Patch Was Easy. The Password Reset Almost Locked Me Out.</h1>\n<p>Five months ago I wrote <a href=\"https://emir.fyi/my-blog-told-me-it-was-vulnerable-it-was-right/\">My Blog Told Me It Was Vulnerable. It Was Right.</a> It was about CVE-2026-26980, a 9.4 CVSS SQL injection in Ghost's Content API, and about the mildly humiliating discovery that my only publicly exposed service was also the only one I'd never bothered to put in a compose file. I fixed the CVE, codified the stack, wrote down a standard, and ended the post with a line about how this would never happen again.</p>\n<p>Reader, it happened again.</p>\n<p>This time the email came from Ghost directly. Subject line \"Critical Ghost security update,\" the kind of thing that arrives at 10am on a Tuesday and rearranges your afternoon. A batch of new advisories had landed, and I should update as soon as possible and consider resetting my authentication credentials.</p>\n<p>Here's the thing though. This time I was ready. The compose file existed. The standard existed. I knew exactly which host, which volume, which env file. The patch itself took about four minutes.</p>\n<p>And then I nearly locked myself out of my own blog forever, because of a config value I'd never once thought about.</p>\n<h2 id=\"how-it-started\">How It Started</h2>\n<p>The March incident had a certain clarity to it. One CVE, one number, one very large CVSS score. Nine point four is a number that makes you act. You read \"unauthenticated attackers can read arbitrary data from your database,\" you look at your publicly accessible blog, and you go patch it.</p>\n<p>August was murkier. Seven advisories, published as a batch, and not one of them had a CVE ID assigned. Just GHSA identifiers and severity labels:</p>\n<table>\n<thead>\n<tr>\n<th>Advisory</th>\n<th>Severity</th>\n<th>Patched in</th>\n<th>Was I exposed?</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Editor oEmbed Preview Allows Untrusted Script Execution</td>\n<td>High (8.1)</td>\n<td>6.34.0</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td>Input Validation Issue in Admin iframe Could Result in Staff Account Takeover</td>\n<td>High (7.3)</td>\n<td>6.34.0</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td>Blind Password Hash Disclosure in Ghost Admin API</td>\n<td>Moderate (4.3)</td>\n<td>6.58.0</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td>Staff Sessions not fully Invalidated on Password Change</td>\n<td>Moderate</td>\n<td>6.34.0</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td>Staff Tokens Granted Elevated Post Privileges</td>\n<td>Moderate</td>\n<td>6.58.0</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td>Unauthenticated Comment Read in Private Mode</td>\n<td>Moderate</td>\n<td>6.58.0</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td>Denied Extension Bypass in Theme Serving via URL Encoding</td>\n<td>Moderate</td>\n<td>6.20.0</td>\n<td>No</td>\n</tr>\n</tbody>\n</table>\n<p>I was on 6.21.2. The highest patch floor in that list was 6.58.0, latest release was 6.59.0, so I was thirty eight minor versions behind and exposed to six of the seven. The theme serving bypass was patched in 6.20.0 and I'd scraped past it by two releases, entirely by accident. I'd like to claim that as good hygiene but it's just what happens when you patch once and then stop paying attention for five months.</p>\n<p>I want to flag something about the psychology here, because I think it's the actually interesting part. A single 9.4 with a CVE number attached is <em>easy</em> to take seriously. Seven moderates and a couple of highs with no CVE IDs is much easier to file under \"I'll get to it.\" The March vulnerability was scarier. The August batch was more likely to be ignored. Those are not the same axis, and I only noticed because I happened to be looking.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ul>\n<li>[x] Back up the database and content volume before touching anything</li>\n<li>[x] Update Ghost from 6.21.2 to 6.59.0</li>\n<li>[x] Rotate the MySQL credentials</li>\n<li>[x] Rotate every Ghost API key, password, and session</li>\n<li>[x] Fix the thing I found along the way that would have locked me out</li>\n<li>[x] Re-point the parts of my infrastructure that depended on the old keys</li>\n</ul>\n<p>That fifth item is the post.</p>\n<h2 id=\"the-easy-part\">The Easy Part</h2>\n<p>I want to be honest about how boring this was, because boring is the whole payoff from March.</p>\n<p>Back up first. The content volume is 186MB of themes and images, the database dumps to about 400KB gzipped:</p>\n<pre><code class=\"language-bash\"># mysqldump straight out of the running container\ndocker exec mysql-db sh -c 'MYSQL_PWD=$MYSQL_ROOT_PASSWORD mysqldump -uroot \\\n  --single-transaction --routines --triggers ghost' | gzip &gt; ghost-db.sql.gz\n\n# tar the content volume via a throwaway alpine container\ndocker run --rm -v ghost-content:/src:ro -v $PWD:/dst alpine \\\n  tar czf /dst/ghost-content.tar.gz -C /src .\n\n# then actually verify them, because an unverified backup is a wish\ngzip -t ghost-db.sql.gz &amp;&amp; tar tzf ghost-content.tar.gz &gt; /dev/null\n</code></pre>\n<p>That last line matters more than it looks. A backup you haven't integrity-checked is a backup you're <em>hoping</em> about. It costs two seconds to know.</p>\n<p>Then the upgrade, which was a one line diff:</p>\n<pre><code class=\"language-diff\"> services:\n   ghost:\n-    image: ghost:6.21.2\n+    image: ghost:6.59.0\n</code></pre>\n<p><code>scp</code> the compose file up, <code>docker compose up -d</code>, wait. Ghost ran its migrations for 107 seconds, chattering about renaming columns and adding indexes and one slightly ominous line about <code>gift_links</code> that turned out to be fine. Then:</p>\n<pre><code>[INFO] Database is in a ready state.\n[INFO] Ghost booted in 109.857s\n</code></pre>\n<p>Twenty three posts intact. Site up. Admin up. That was the whole upgrade. In March this same operation was an evening project because I had to reverse engineer my own container first. This time it was a text edit and a file copy. The standard held, which is a genuinely nice feeling and also the last nice feeling in this post for a while.</p>\n<p>I rotated the MySQL credentials the same way as last time, and I'll repeat the gotcha from the March post because it's still the thing that bites people: <code>MYSQL_ROOT_PASSWORD</code> and <code>MYSQL_PASSWORD</code> only do anything on <strong>first initialization</strong> of the data directory. Changing them in your <code>.env</code> does not change the password in the database. You <code>ALTER USER</code> inside MySQL first, then update the env file to match, then recreate. Other order, and your fresh container confidently presents a password the database has never heard of.</p>\n<h2 id=\"the-part-where-i-almost-ruined-my-afternoon\">The Part Where I Almost Ruined My Afternoon</h2>\n<p>Ghost's advice was to update <em>and</em> consider resetting authentication credentials. There's an official flow for this, and it's genuinely well built. Settings, Advanced, Danger zone, \"Reset all authentication.\" It rotates every API key, signs out every staff user, and forces a password reset. Exactly what you want after a batch of advisories that includes password hash disclosure and incomplete session invalidation.</p>\n<p>I had my cursor over the button. And then, for no particularly noble reason, I decided to check how Ghost was configured to send email.</p>\n<pre><code class=\"language-bash\">docker exec emir.fyi-blog sh -c 'cat /var/lib/ghost/config.production.json'\n</code></pre>\n<pre><code class=\"language-json\">{\n  \"mail\": {\n    \"transport\": \"Direct\"\n  }\n}\n</code></pre>\n<p><code>Direct</code> means Ghost attempts to deliver mail itself, connecting straight to the recipient's mail server from wherever it happens to be running. Where it happens to be running, in my case, is a Docker host on a residential connection behind a Cloudflare tunnel. Gmail's opinion of unauthenticated mail arriving directly from a consumer IP block is not a warm one. That mail was going nowhere.</p>\n<p>Now put those two facts next to each other.</p>\n<p>\"Reset all authentication\" signs you out and requires a password reset. The password reset arrives <strong>by email</strong>. I have exactly one staff account. If I had clicked that button ten seconds earlier, I would have been signed out of a blog I could not sign back into, holding a reset email that was never going to be delivered.</p>\n<p>Recoverable? Sure. I have database access, and a bcrypt hash written directly into the <code>users</code> table would have gotten me back in eventually. But \"eventually,\" on a Tuesday afternoon, via manual surgery on my own auth table, is not how I wanted to spend the day. And it would have been entirely self-inflicted.</p>\n<p>The worse realization came a minute later. Ghost 6 has Device Verification, which emails you a six digit code when you sign in from a new device. That feature had been silently useless on my install for its entire existence. I'd simply never signed in from a new enough device to find out. The blog had a broken dependency sitting quietly in the middle of its authentication flow, and the only thing keeping it invisible was that I'd never needed it.</p>\n<p>This is the failure mode I actually want to write down. It was not a bug. Nothing was broken, crashed, or alerting. Every dashboard was green, the site served traffic all day, and Beszel was perfectly happy. It was a capability I had never exercised, which meant it had never had a chance to fail where I could see it. My monitoring watched whether Ghost was <em>up</em>. Nothing on earth was watching whether Ghost could <em>send an email</em>.</p>\n<h2 id=\"fixing-mail-first\">Fixing Mail First</h2>\n<p>I already run Brevo SMTP for other services in the homelab, so the fix was mostly plumbing. Ghost takes config as env vars with double underscores for nesting:</p>\n<pre><code class=\"language-bash\">mail__transport=SMTP\nmail__options__host=smtp-relay.brevo.com\nmail__options__port=587\nmail__options__secure=false\nmail__options__auth__user=your-smtp-user\nmail__options__auth__pass=your-smtp-password\nmail__from=Your Blog &lt;ghost@example.com&gt;\n</code></pre>\n<p>Two notes. Set <code>secure: false</code> for port 587, which uses STARTTLS. <code>true</code> is for 465. And the <code>from</code> address has to be on a domain you've actually authenticated in your relay. My blog domain isn't verified in Brevo but another domain I own is, so mail goes out from there. Slightly inelegant, completely functional.</p>\n<p>Then I verified it, in three escalating steps, because I'd just been reminded what assumptions are worth.</p>\n<p><strong>Step one, does the config exist.</strong> Trivially checkable and almost meaningless, but it rules out typos:</p>\n<pre><code class=\"language-bash\">docker exec emir.fyi-blog env | grep '^mail__'\n</code></pre>\n<p><strong>Step two, does the relay accept us.</strong> Ghost bundles nodemailer, so you can borrow it to test the handshake without sending anything:</p>\n<pre><code class=\"language-bash\">docker exec emir.fyi-blog node -e \"\nconst nm = require('/var/lib/ghost/current/node_modules/nodemailer');\nnm.createTransport({\n  host: process.env.mail__options__host,\n  port: Number(process.env.mail__options__port),\n  secure: false,\n  auth: { user: process.env.mail__options__auth__user,\n          pass: process.env.mail__options__auth__pass }\n}).verify()\n .then(() =&gt; console.log('SMTP AUTH: OK'))\n .catch(e =&gt; console.log('SMTP AUTH: FAILED', e.message));\n\"\n</code></pre>\n<pre><code>SMTP AUTH: OK\n</code></pre>\n<p><strong>Step three, does a human receive it.</strong> This is the only step that counts, and I want to be emphatic about why. I have a note in my TODO from a previous adventure with a different provider that shows 100 sent, 100 delivered, and not a single email ever arriving in the inbox. Not in spam. Not in promotions. Just gone. \"The relay accepted it\" and \"a person read it\" are separated by SPF, DKIM, DMARC, reputation scoring, and the inscrutable moods of large mail providers.</p>\n<p>So I triggered a real Ghost password reset, through Ghost's own code path, and went and looked at an actual inbox:</p>\n<pre><code class=\"language-bash\">curl -X POST http://localhost:2368/ghost/api/admin/authentication/password_reset \\\n  -H 'Content-Type: application/json' \\\n  -H 'X-Forwarded-Proto: https' \\\n  -d '{\"password_reset\":[{\"email\":\"you@example.com\"}]}'\n</code></pre>\n<p>It arrived. Better still, signing back in triggered a Device Verification code, which also arrived, confirming that a feature I'd never successfully used in my life was now working.</p>\n<p><em>Then</em> I clicked the Danger zone button.</p>\n<h2 id=\"what-the-reset-actually-did\">What The Reset Actually Did</h2>\n<p>Worth documenting, since the docs describe it in general terms and I had the database open anyway:</p>\n<pre><code>API keys rotated in the last 10 minutes:  9\nActive sessions:                          5 → 1\nPosts:                                    23 (unchanged)\nStaff account:                            active\n</code></pre>\n<p>The sign out was immediate and total. I landed on a page reading \"Update your password. For security, you need to create a new password. An email has been sent to you with instructions.\"</p>\n<p>That sentence is the whole post in miniature. Twenty minutes earlier, that page would have been a wall.</p>\n<h2 id=\"the-bill-comes-due-for-baked-in-keys\">The Bill Comes Due For Baked-In Keys</h2>\n<p>One consequence I did see coming. I proxy a public JSON feed of my posts at <code>/api/posts</code> through Caddy, which injects a Ghost Content API key server side so callers don't have to:</p>\n<pre><code class=\"language-caddy\">@api_posts path /api/posts /api/posts/\nhandle @api_posts {\n  handle {\n    rewrite * /ghost/api/content/posts/?key={{ ghost_content_api_key }}&amp;limit=all&amp;{http.request.uri.query}\n    reverse_proxy 192.168.1.10:2368 {\n      header_up X-Forwarded-Proto https\n    }\n  }\n}\n</code></pre>\n<p>\"Rotate every API key\" includes that one. The moment the reset completed, the feed started returning 401. Entirely expected, briefly satisfying to watch, and then I had to go fix it.</p>\n<p>The fix itself was unglamorous. That key is templated into the Caddy config on my load balancer nodes, so it got the new value and Caddy got a reload. Ten minutes, no drama. If you inject an API key anywhere in your infrastructure, write down where, because \"rotate all keys\" is a bigger button than it looks and the reverse proxy will not tell you it's now serving a dead credential.</p>\n<p>The part worth keeping is how I checked it afterward. Verify <em>every</em> branch, not just the one you were thinking about:</p>\n<pre><code class=\"language-bash\">curl -o /dev/null -w \"%{http_code}\\n\" \"https://emir.fyi/api/posts\"               # 200\ncurl -o /dev/null -w \"%{http_code}\\n\" \"https://emir.fyi/api/posts?limit=1\"       # 200\ncurl -o /dev/null -w \"%{http_code}\\n\" \"https://emir.fyi/api/posts?filter=slug:x\" # 200\n</code></pre>\n<p>My config has separate handlers for \"caller passed a limit\" and \"caller didn't.\" Testing only the default path would have left the other one quietly broken with the old key, and I'd have found out from a stranger.</p>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<p>March's lesson was about deployment. I had a service with no compose file, and the CVE forced me to codify it. I ended that post pleased with myself for establishing a standard.</p>\n<p>The standard worked. That part's real. The patch was a one line diff and a file copy, exactly as designed.</p>\n<p>But codifying deployment only answers \"how do I change this thing.\" It says nothing about \"can I recover this thing.\" Those feel adjacent and they are not. Every piece of my March work was about the deploy path, and the thing that nearly bit me was on the recovery path, which I had never walked, never tested, and never thought about.</p>\n<p>The specific shape of it is worth internalizing. A broken deploy is loud. Containers crash loop, healthchecks go red, monitoring pages you. A broken <em>recovery</em> path is completely silent, because by definition you aren't using it. Mine had been broken for the entire life of the install and the only way I found out was by hesitating over a button for no reason.</p>\n<p>So the question I'm adding to my list, for every service I run: if I locked myself out of this right now, what's the path back in, and when did I last confirm that path works? For Ghost the answer was email, and the answer to the second half was \"never.\"</p>\n<p>The blog is on 6.59.0 with fresh everything. But the actual repair today wasn't the version bump. It was noticing that the fire escape had been welded shut since the day I moved in.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<blockquote>\n<p>A dimly lit server room at night, a single rack glowing with soft blue status LEDs, all indicators green and healthy. In the foreground, a heavy steel emergency exit door stands slightly ajar with warm light spilling through it, but the door frame is crudely welded shut with thick industrial weld beads along its edge. A small brass envelope icon hangs from the door handle like a key tag. Cinematic lighting, shallow depth of field, moody teal and amber color grading, photorealistic, 16:9 aspect ratio.</p>\n</blockquote>\n","comment_id":"6a8db746ccab540001253754","feature_image":"https://emir.fyi/content/images/2026/08/cc3b31d4-fea4-4e77-a696-e5fb946a43dc.png","featured":false,"visibility":"public","created_at":"2026-08-25T11:39:50.000-04:00","updated_at":"2026-08-25T12:08:27.000-04:00","published_at":"2026-08-25T12:08:27.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/the-patch-was-easy-the-password-reset-almost-locked-me-out/","excerpt":"The Patch Was Easy. The Password Reset Almost Locked Me Out.\n\n\nFive months ago I wrote My Blog Told Me It Was Vulnerable. It Was Right. It was about CVE-2026-26980, a 9.4 CVSS SQL injection in Ghost's Content API, and about the mildly humiliating discovery that my only publicly exposed service was also the only one I'd never bothered to put in a compose file. I fixed the CVE, codified the stack, wrote down a standard, and ended the post with a line about how this would never happen again.\n\n\nRead","reading_time":10,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"6a10832ea2778d0001eecb22","uuid":"6128b7b7-5959-46a0-b137-587dcd6c87ba","title":"Cooling My Garage Servers While I'm at the World Cup","slug":"cooling-my-garage-servers-while-im-at-the-world-cup","html":"<h2 id=\"the-itch\">The Itch</h2>\n<p>A few of my homelab boxes live in the garage. In Florida. The garage stays manageable most of the year, the rack vents to ambient, and the Everwell mini split bolted to the wall keeps things comfortable whenever I remember to turn it on. The \"remember\" part is the load-bearing word in that sentence. It's also my office where I work from so I \"remember\" when it gets hot lol</p>\n<p>The 2026 World Cup is happening here. Some of it close enough that I'm not going to be the guy with three host cities on his doorstep and stay home watching it on a laptop. So I'm going. For chunks of weeks at a time. To matches. With my servers sitting in a room that can hit triple digits in June if the AC is off long enough.</p>\n<p>None of those servers care about being warm, until they suddenly do, in the form of thermal throttling, then noisy fans, then the kind of silent permanent damage that you only discover three months later when a drive starts failing SMART checks. I'd rather not come back from a group stage match to a brick.</p>\n<p>The Everwell only listens to its IR remote, which is sitting on my desk. So I needed something to act as a substitute for me. A small, dumb hand that holds the remote and can be told what to press by Home Assistant. That ended up being a BroadLink RM4 mini, plus a slightly larger pile of yak shaving than I expected.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<p>Phased so future me can see what's done and what's pending.</p>\n<ul>\n<li>[x] Pair the BroadLink RM4 mini to WiFi via the vendor app</li>\n<li>[x] Teach it the AC's power on/off codes, verify it actually toggles the mini-split</li>\n<li>[x] Wire the RM4 into Home Assistant via the local Broadlink integration (no cloud)</li>\n<li>[x] Build an automation that cools the garage when it gets too hot</li>\n<li>[x] Verify the whole thing is reachable from outside the LAN</li>\n<li>[ ] Soak-test for a few weeks of actual heat, before the first match</li>\n</ul>\n<p>Five of six done. The sixth one is just time.</p>\n<h2 id=\"picking-the-hardware\">Picking the Hardware</h2>\n<p>The shortlist was small. I needed something cheap, IR only, with a real Home Assistant integration, and not so weird that it would require me to read someone's GitHub gist from 2021 to make it work.</p>\n<table>\n<thead>\n<tr>\n<th>Device</th>\n<th>Verdict</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>BroadLink RM4 mini ($25)</td>\n<td>What I picked. IR only, mature HA integration, well-documented</td>\n</tr>\n<tr>\n<td>BroadLink RM4 Pro ($45)</td>\n<td>RF + IR. The RF half is wasted budget for an IR-only mini-split</td>\n</tr>\n<tr>\n<td>SwitchBot Hub Mini</td>\n<td>Concept's the same but you're more married to their app</td>\n</tr>\n<tr>\n<td>Sonoff IR blaster</td>\n<td>Cheaper, flakier reputation, dimmer HA community support</td>\n</tr>\n<tr>\n<td>\"Just buy a smart AC\"</td>\n<td>Not happening. My skills don't go beyond this split ac in terms of being an AC tech, I installed it myself and it would have to work</td>\n</tr>\n</tbody>\n</table>\n<p>The RM4 mini is a small black puck about the size of a film canister. USB-powered, 2.4 GHz Wi-Fi only, omnidirectional IR LED, plus a free bonus temperature + humidity sensor that you get whether you want it or not. (You want it. More on that later.)</p>\n<h2 id=\"phase-1-pairing-the-thing\">Phase 1: Pairing the Thing</h2>\n<p>The pairing dance was the most \"consumer IoT\" part of the build. Three things to know up front:</p>\n<ol>\n<li>The BroadLink app makes you create a cloud account before it'll talk to the device. Annoying. You can drop the cloud later, you just can't skip it now.</li>\n<li>The RM4 mini is 2.4 GHz only. If you're on a mesh router with a single combined 5/2.4 SSID, the pairing wizard will absolutely fail and not tell you why. Temporarily turn off 5 GHz on your phone or join a dedicated 2.4 GHz SSID for the duration of the pairing.</li>\n<li>Region selection is permanent per account. Pick USA (or whatever's actually right for you) and don't fat-finger it.</li>\n</ol>\n<p>The actual pairing was about 90 seconds. Plug the RM4 in, the LED on the front rapid-blinks at roughly 2 Hz, the app discovers it on the LAN, you hand it your Wi-Fi password, done. The app immediately starts showing the device's onboard temp and humidity sensor, which felt magical for a $25 puck.</p>\n<h2 id=\"phase-2-teaching-it-the-ac\">Phase 2: Teaching It The AC</h2>\n<p>The Everwell remote is the \"Full State Display\" kind, the better of the two flavors. Snowflake icon, fan-speed bars, setpoint digits, all on the LCD. Every IR transmission encodes the entire state (mode, temp, fan, on/off) in one burst, which means discrete on/off codes exist. No toggle ambiguity, no \"did I just turn the AC on twice.\"</p>\n<p>So I went down the obvious path first. BroadLink app -&gt; Add Appliance-&gt; AC Remote Full Display -&gt; let the app pattern-match against its codeset library.</p>\n<p>This is where it got annoying. The \"User Defined Panel\" flow walks you through:</p>\n<ol>\n<li>Use the original remote to set the AC to a desired state</li>\n<li>Turn the AC off with the original remote</li>\n<li>Tell the app what state you set (mode, temp, fan speed)</li>\n<li>Tap \"Have a test\"</li>\n</ol>\n<p>Under the hood it's guessing your AC's protocol from a library of known codesets and trying them in sequence. The hope is one matches and the test press turns your AC back on.</p>\n<p>For Everwell, no dice. The test sends a signal that looks plausible to the app but doesn't speak my AC's specific dialect. Weirdly, it would recognize the off code but not the on. Off brand mini splits are a graveyard of \"close but wrong\" rebadges (Hisense, TCL, Midea, etc., all OEMing the same physical units with subtly different IR encodings).</p>\n<p>The fix: stop pattern matching, start recording.</p>\n<p>In the BroadLink app the option is buried at the bottom of the \"Add Appliance\" grid, called <strong>UserDefine</strong>. It makes an empty virtual remote with no semantics, just blank buttons. You add a button, hold the original remote a few inches from the RM4, press the physical button you want to clone, and the RM4 captures the raw IR burst byte for byte. Replay later equals the same bytes back out. Works on anything that emits IR.</p>\n<p>Two captures were all I needed:</p>\n<ul>\n<li><strong>Power ON 74F</strong>, captured with the AC off, remote display showing Cool 74°F Fan Auto. Press Power. RM4 records the \"turn on at this exact state\" burst.</li>\n<li><strong>Power OFF</strong>, captured with the AC on, same close-range setup. Press Power. RM4 records the \"turn off\" burst.</li>\n</ul>\n<p>One side effect to expect. At three inches the IR scatters in every direction, including across the room to the AC. So you ll often see the AC actually respond mid capture (turn on or off). I leaned into it: each capture's scatter happens to leave the AC in exactly the right state for the next capture (off → on → ready for off → off). Convenient.</p>\n<p>The takeaway I wish someone had handed me before I spent twenty minutes in the state matcher: <strong>for off brand mini-splits, skip the codeset library and go straight to UserDefine.</strong> It's slower if you want every button on the remote, but you only ever need two or three buttons for \"control AC from a stadium parking lot\" use cases.</p>\n<h2 id=\"phase-3-adding-it-to-home-assistant\">Phase 3: Adding It To Home Assistant</h2>\n<p>In the world of consumer IoT, this is supposed to be the easy part. Settings -&gt; Devices &amp; Services -&gt; Add Integration -&gt; Broadlink -&gt; enter IP -&gt; done.</p>\n<p>It got to \"enter IP,\" then died with <code>Invalid authentication</code>. The full error explained itself: the RM4 was <strong>locked to the BroadLink cloud account</strong> I'd paired it with. Anything not signed in as that account, including my own LAN's Home Assistant, gets rejected. The mitigation is right there in the dialog: open the BroadLink app, tap the device, tap the three-dot menu, scroll to the bottom, <strong>disable the lock</strong>. Once disabled, HA authenticates immediately and the integration adds cleanly.</p>\n<p>Now the fun part. The IR codes I'd captured back in Phase 2 live in BroadLink's cloud, not on the device itself, and BroadLink does not give you any way to export them. So in HA I had to re learn the same two codes, this time directly into HA's own storage.</p>\n<p>Developer Tools → Actions → <code>remote.learn_command</code>:</p>\n<pre><code class=\"language-yaml\">action: remote.learn_command\ntarget:\n  entity_id: remote.ac_garage\ndata:\n  command_type: ir\n  device: Garage AC\n  command: power_on_74f\n</code></pre>\n<p>Click \"Perform action,\" RM4 LED starts blinking (learn mode, 30-second window), point the Everwell remote at it, press Power, green toast. Repeat with <code>command: power_off</code>. Two minutes, both codes captured.</p>\n<p>The relearn is a feature, not a chore. After this, the only thing in the loop is local hardware: HA -&gt; LAN -&gt; RM4 -&gt; IR LED -&gt; AC. The BroadLink cloud could vanish tomorrow and nothing about my AC control changes. That was the whole point.</p>\n<p>The integration also auto-exposes the RM4's onboard temperature and humidity sensors as <code>sensor.ac_garage_temperature</code> and <code>sensor.ac_garage_humidity</code>. Free thermometer. Reads about 1-2°F warm because of self heat from the RM4's own electronics, but plenty accurate for \"is the garage cooking\" decisions.</p>\n<h2 id=\"phase-4-the-automation\">Phase 4: The Automation</h2>\n<p>Two captured codes plus a temperature sensor is enough to close the loop. The automation I actually want:</p>\n<blockquote>\n<p>When the garage gets too hot for too long, blast the AC for a while, then turn it off, then wait long enough that I'm not bouncing the compressor every other minute.</p>\n</blockquote>\n<p>Translated into HA's automation YAML, in <code>/config/automations.yaml</code>:</p>\n<pre><code class=\"language-yaml\">- id: garage_ac_burst_cool\n  alias: Garage AC burst cool when hot\n  description: Burst cool the garage when temperature spikes\n  trigger:\n    - platform: numeric_state\n      entity_id: sensor.ac_garage_temperature\n      above: 92\n      for:\n        minutes: 5\n  condition:\n    - condition: time\n      after: \"09:00:00\"\n      before: \"19:00:00\"\n  action:\n    - service: remote.send_command\n      target:\n        entity_id: remote.ac_garage\n      data:\n        device: Garage AC\n        command: power_on_74f\n    - delay:\n        minutes: 10\n    - service: remote.send_command\n      target:\n        entity_id: remote.ac_garage\n      data:\n        device: Garage AC\n        command: power_off\n    - delay:\n        minutes: 10\n  mode: single\n</code></pre>\n<p>A few moving parts worth pointing at:</p>\n<ul>\n<li><strong><code>for: minutes: 5</code></strong> on the trigger. The sensor has to read above 92°F continuously for five minutes before this fires. A brief spike (someone opens the garage door for 20 seconds, the IR scatter from another capture, whatever) doesn't kick the AC.</li>\n<li><strong><code>condition: time</code></strong> restricts firing to between 9am and 7pm. The garage barely gets above the threshold overnight, and I don't want the AC kicking on at 3am when the heat dump from the sun isn't even a factor yet.</li>\n<li><strong>The trailing <code>delay: 10 min</code></strong> is the cooldown. While the automation is mid execution, including that final delay, <code>mode: single</code> blocks new trigger fires. So even if the temperature stays above 92°F the whole time, the automation can't start a second burst until those final ten minutes elapse.</li>\n</ul>\n<p>I ran it manually first to verify the action chain works without waiting on the temperature trigger. The HA \"Traces\" view drew it as a clean timeline: trigger at T+0, send <code>power_on_74f</code>, wait, send <code>power_off</code>, wait, complete. The AC turned on, ran, turned off. Exactly as designed.</p>\n<p>And then the next morning the automation fired for real, on a 92°F garage, and <strong>the AC didn't actually turn on</strong>. I'd lost a substitution before kickoff.</p>\n<h2 id=\"the-bug-that-made-me-stare-at-a-graph-for-an-hour\">The Bug That Made Me Stare At A Graph For An Hour</h2>\n<p>This is the part of the post where I admit I almost shipped a broken thing.</p>\n<p>The trace said success. Trigger fired at 9:55:34 AM, condition passed, both <code>remote.send_command</code> calls completed without errors, all four steps finished. From HA's point of view, everything was great. From the garage's point of view, nothing happened.</p>\n<p>The smoking gun was the temperature graph. During the supposed 5-minute \"on\" window (this was an earlier version of the YAML with a shorter burst), the sensor stayed flat at 92.4°F. No dip. Not even a wobble. If the AC had actually run, even briefly, the sensor sitting on the RM4 across the room would have seen it.</p>\n<p>So I tested the exact same command manually a few minutes later. AC turned on instantly. Sensor went 92.93 → 89.6°F over the next six minutes. The IR pipeline worked. The codes worked. Everything was fine. The 9:55 attempt had just silently lost the IR pulse somewhere.</p>\n<p>The hard lesson: <strong><code>remote.send_command</code> returning success only means HA spoke to the RM4 over the LAN.</strong> It does not mean the RM4 actually emitted clean IR. And it definitely doesn't mean the AC heard it and acted on it. IR is fire and forget. There is no ack. There is no retry. If a UDP packet between HA and the RM4 drops, or the RM4 IR pulse arrives during a moment the AC isn't paying attention, the burst silently fails and the automation still thinks it succeeded.</p>\n<p>The next automatic fire later that day went perfectly. Trigger at 11:53:34 → AC kicked in at 11:55:34 → sensor went 92.39 → 89.42°F over the next 18 minutes. Curve looked exactly like what a working AC should look like.</p>\n<p>So the pipeline works <em>most of the time</em>, not always. Which is to say: this is a real world IoT system, not a science experiment.</p>\n<p>I haven't solved this yet. The roadmap of \"what would actually fix it\":</p>\n<ol>\n<li><strong>Send the on command twice with a half-second gap.</strong> Tiny YAML change, free insurance against the single-packet-loss case.</li>\n<li><strong>Verify by temperature.</strong> After sending <code>power_on</code>, sleep 90 seconds, check if the sensor dropped at all. If not, re-send. Self-healing automation.</li>\n<li><strong>Add a second automation</strong> That does same commands as the first one. Hopefully two don't fail at the same time.</li>\n<li><strong>Add notifications</strong> I already have infra for this, but I will add notifications while I'm still here working from home.</li>\n<li><strong>Add a camera</strong> I will do some remote testing and verifying too, and I will disable notifications once I'm happy. I can still access everything via Wireguard and tweak it between the games.</li>\n</ol>\n<p>For the World Cup trip I'll probably do (1) plus rest is a separate weekend project.</p>\n<h2 id=\"phase-5-remote-access\">Phase 5: Remote Access</h2>\n<p>Last piece is being able to reach the dashboard from the airport, the stadium, the hotel, wherever. The good news: I already solved this problem a while back, just for the rest of the homelab instead of HA specifically. I wrote about that build in detail in <a href=\"https://emir.fyi/wireguard-vpn-to-my-homelab-automating-a-vpn-server-with-terraform-and-ansible/\">WireGuard VPN to My Homelab: Automating a VPN Server with Terraform and Ansible</a>. Short version: there's a small Ubuntu VM running WireGuard that terminates inbound UDP from anywhere on the internet (via a Cloudflare DDNS record plus a pfSense port-forward) and routes the client back onto my LAN. Phone, laptop, whatever, once the tunnel is up every internal service is reachable by its private hostname.</p>\n<p>The reasoning for WireGuard over public ingress is the usual two:</p>\n<ol>\n<li><strong>Auth surface.</strong> Exposing HA's login to the open internet means dealing with brute-force attempts, fail2ban tuning, MFA hardening, the whole list. WireGuard is a key-pair handshake. Without the key you don't even get a TCP connection, let alone an HTTP login page.</li>\n<li><strong>Blast radius.</strong> If HA does ever get popped, I'd rather the failure mode be \"the AC didn't turn on\" than \"someone pivoted from HA to the rest of the homelab over a public IP.\"</li>\n</ol>\n<p>Because everything's behind WireGuard, <strong>there is no new HA-specific work for remote access.</strong> The same <code>https://homeassistant.localdomain</code> URL I use on the couch works from a hotel Wi-Fi the moment the tunnel comes up. Same login, same dashboard, same buttons. The whole stack is identical on LAN vs over WG, which is the entire point of running the VPN as a subnet router instead of per-service tunnels.</p>\n<p>Verified it before declaring victory: phone on cellular, Wi-Fi off, tunnel up, dashboard loads. Done.</p>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<p>A few things, ordered by how much they surprised me:</p>\n<ul>\n<li><strong>Off brand mini splits will eat codeset libraries for breakfast.</strong> If your AC isn't a brand the vendor app recognizes, do not waste twenty minutes in the pattern-matching flow. Go straight to raw IR capture.</li>\n<li><strong>BroadLink's device lock is the single most surprising barrier to local control.</strong> It's a one-click fix once you find it, but until you find it the error message tells you nothing useful.</li>\n<li><strong>HA's <code>remote.send_command</code> is not a verification, it's an aspiration.</strong> It tells you HA sent the packet to the RM4, not that the AC received the IR. For anything important, build verification into the automation, not faith.</li>\n<li><strong><code>mode: single</code> plus a trailing <code>delay</code> is the cleanest way to add a cooldown to an automation.</strong> No timers, no input booleans, no helper scripts. The automation stays \"running\" through the cooldown, and <code>mode: single</code> blocks new triggers for the duration. Simple.</li>\n<li><strong>The RM4's bundled temperature sensor is good enough to close the loop.</strong> I was ready to buy an Aqara puck and a Zigbee dongle to add a \"real\" thermometer. Turns out the $25 IR blaster already had one.</li>\n</ul>\n<p>And one thing I didn't really learn so much as confirm: a $25 IR puck is genuinely all you need to bring a dumb mini-split into your homelab. The hardware is the cheap part. The software glue around it, and the part where you find out your \"successful\" automation didn't actually do anything, is where you spend the actual time.</p>\n<p>I'm not 100% confident this rig will hold up across a full multi week absence. The first time it does, though, I'll write a follow up post titled \"I watched a World Cup match and my garage stayed at 78°F.\" Until then, I have plane tickets and a thermostat substitute on the bench. Let's see if the IR blaster can hold the line.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<p>A small black cylindrical IR blaster sitting on a workbench in a sun-lit Florida garage, with a partially-visible server rack glowing softly in the background. The blaster is emitting a stylized infrared beam toward an off-camera wall-mounted mini-split air conditioner. On the wall behind the bench, a 2026 FIFA World Cup wall calendar with a few match dates circled in red marker. Warm late-afternoon sunlight slicing through a half-open garage door, palm fronds just barely visible outside. Cinematic homelab aesthetic, shallow depth of field, photorealistic, subtle blueprint-overlay graphic in one corner showing the signal flow: phone (on the road) → WireGuard → Home Assistant → IR blaster → AC. Square 1:1 aspect ratio.</p>\n","comment_id":"6a10832ea2778d0001eecb22","feature_image":"https://emir.fyi/content/images/2026/05/b138110c-a75c-482d-b432-a34b90518333.png","featured":false,"visibility":"public","created_at":"2026-05-22T12:24:14.000-04:00","updated_at":"2026-05-22T12:41:41.000-04:00","published_at":"2026-05-22T12:41:41.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/cooling-my-garage-servers-while-im-at-the-world-cup/","excerpt":"The Itch\n\n\nA few of my homelab boxes live in the garage. In Florida. The garage stays manageable most of the year, the rack vents to ambient, and the Everwell mini split bolted to the wall keeps things comfortable whenever I remember to turn it on. The \"remember\" part is the load-bearing word in that sentence. It's also my office where I work from so I \"remember\" when it gets hot lol\n\n\nThe 2026 World Cup is happening here. Some of it close enough that I'm not going to be the guy with three host ","reading_time":12,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69e41b613cad350001301cf9","uuid":"dfcf8ef9-75f3-4544-a9a9-55ec74f4ce75","title":"Building a High Availability Kubernetes Cluster Across Mixed Hardware (Part 2: Migration and the Graceful Goodbye)","slug":"building-a-high-availability-kubernetes-cluster-across-mixed-hardware-part-2-migration-and-the-graceful-goodbye","html":"<h2 id=\"where-we-left-off\">Where We Left Off</h2>\n<p><a href=\"https://emir.fyi/building-a-high-availability-kubernetes-cluster-part-1-the-build/\">Part 1</a> ended with a shiny new 3-node HA Kubernetes cluster: one Proxmox VM and two BeeLink mini PCs, glued together with kubeadm, kube-vip, and Flannel. The control plane was HA, the etcd quorum was solid, and the VIP survived any single-node failure.</p>\n<p>The cluster was also empty.</p>\n<p>I closed that post with \"the hard part is done, the easy part is next, famous last words.\" Reader, I was not entirely wrong. Migrating the workloads was significantly easier than building the cluster. But \"easy\" is doing a lot of work in that sentence, and there were a few moments where I questioned my life choices.</p>\n<p>This is the story of moving three stateful services from a single-master cluster that was one bad RAM stick from oblivion, to a new HA cluster running on physically separate hardware. Without losing data. Without extended downtime. While running both clusters in parallel and keeping the option to roll back at every step.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Install cluster foundations (Traefik, local-path-provisioner, Sealed Secrets, TLS)</s> (done)</li>\n<li><s>Migrate Vaultwarden (stateful, highest-value)</s> (done)</li>\n<li><s>Migrate ArgoCD (the GitOps controller that runs everything else)</s> (done)</li>\n<li><s>Migrate MarketMind (the Rails app with Postgres)</s> (done)</li>\n<li><s>Fix the CI/CD pipeline that SSH'd to the old master</s> (done)</li>\n<li><s>Retarget the HA LB so <code>vault.localdomain</code>, <code>argocd.localdomain</code>, and <code>market-mind.localdomain</code> land on the new cluster</s> (done)</li>\n<li><s>Shut down old cluster VMs, let them soak for a week</s> (done)</li>\n<li>Terraform destroy the old VMs after the soak (pending)</li>\n</ol>\n<h2 id=\"the-core-pattern-parallel-clusters-flip-dns\">The Core Pattern: Parallel Clusters, Flip DNS</h2>\n<p>The thing that made this migration bearable: <strong>both clusters run at the same time</strong>, and the HA load balancer (Caddy) decides which one handles any given hostname. Flipping <code>vault.localdomain</code> from old to new is a one-line change in the Caddyfile and a playbook re-run. Flipping back is the same change in reverse.</p>\n<p>Every migration step followed the same shape:</p>\n<ol>\n<li>Stand up the new service on the new cluster with the migrated data</li>\n<li>Test it via <code>kubectl port-forward</code> (no DNS change, no blast radius)</li>\n<li>Flip the Caddyfile to point the real hostname at the new cluster</li>\n<li>Test from a real client</li>\n<li>If anything is off, flip back, debug, try again</li>\n</ol>\n<p>This feels obvious in retrospect, but the previous me would have been tempted to do \"one big cutover at midnight\" and spend an hour panicking. Parallel clusters turn \"cutover\" into \"routing decision\" and routing decisions are cheap.</p>\n<h2 id=\"laying-the-foundation\">Laying the Foundation</h2>\n<p>Before any real workloads, I needed four things on the new cluster.</p>\n<h3 id=\"traefik\">Traefik</h3>\n<p>Same version as the old cluster (<code>traefik-39.0.0</code>, Traefik v3.6.7), installed via Helm. Then a small NodePort service to expose <code>80:30080</code> and <code>443:30443</code> so the HA LB's Caddy can reach it:</p>\n<pre><code class=\"language-yaml\">apiVersion: v1\nkind: Service\nmetadata:\n  name: traefik-nodeport\n  namespace: traefik\nspec:\n  type: NodePort\n  selector:\n    app.kubernetes.io/name: traefik\n  ports:\n    - name: web\n      port: 80\n      targetPort: web\n      nodePort: 30080\n    - name: websecure\n      port: 443\n      targetPort: websecure\n      nodePort: 30443\n</code></pre>\n<p>Test: <code>curl -sk https://192.168.1.40:30443/</code> on every cluster node. Got <code>404</code> on all three, which is exactly right. No IngressRoutes yet, so Traefik has nothing to route. 404 means \"I'm here, I just don't know that hostname\" and that's the healthy answer.</p>\n<h3 id=\"local-path-provisioner\">local-path-provisioner</h3>\n<p>Rancher's <code>local-path-provisioner</code> v0.0.30, same version as the old cluster. Creates a <code>local-path</code> StorageClass that turns node-local disk into PVCs. Fast, simple, and the honest truth that homelab storage is usually pinned to wherever the pod first scheduled.</p>\n<p>Tested with a throwaway PVC and pod that wrote a file, then read it back. PVC bound, file present, done. Next.</p>\n<h3 id=\"sealed-secrets-the-part-where-key-management-matters\">Sealed Secrets (the part where key management matters)</h3>\n<p>Vaultwarden and MarketMind both use Bitnami Sealed Secrets so their secrets can live in git encrypted. The controller encrypts with a public key and decrypts with a private key that lives only in the cluster.</p>\n<p><strong>The catch:</strong> if you install a fresh Sealed Secrets controller on a new cluster, it generates a <em>new</em> private key. Existing sealed secrets in your repo, encrypted against the old key, will never decrypt. They just sit there looking broken.</p>\n<p>The fix is to restore the old keys before installing the controller:</p>\n<pre><code class=\"language-bash\"># 1. Export all sealed-secrets keys from the old cluster\nkubectl --kubeconfig ~/.kube/config-old \\\n  get secret -n kube-system \\\n  -l sealedsecrets.bitnami.com/sealed-secrets-key \\\n  -o yaml &gt; keys-backup.yaml\n\n# 2. Apply them to the new cluster BEFORE installing the controller\nkubectl --kubeconfig ~/.kube/config-ha apply -f keys-backup.yaml\n\n# 3. Now install the controller\nkubectl --kubeconfig ~/.kube/config-ha apply \\\n  -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.27.3/controller.yaml\n\n# 4. Delete the backup file\nrm keys-backup.yaml\n</code></pre>\n<p>The controller starts up, sees the restored keys, registers them, and happily decrypts existing sealed secrets from the repo. Log output:</p>\n<pre><code>INFO msg=\"Searching for existing private keys\"\nINFO msg=\"registered private key\" secretname=sealed-secrets-key7m2wg\nINFO msg=\"registered private key\" secretname=sealed-secrets-keykzqw4\nINFO msg=\"registered private key\" secretname=sealed-secrets-keyt4fnz\n</code></pre>\n<p>Three keys because the controller rotates them periodically and keeps old ones around for backward compatibility. All three now live on both clusters. Lesson: <strong>back up the Sealed Secrets private key the day you install the controller</strong>. Put it in your password manager. Without it, every <code>sealed-secret.yaml</code> in your repos becomes encrypted garbage.</p>\n<h3 id=\"wildcard-tls\">Wildcard TLS</h3>\n<p>The wildcard cert for <code>*.localdomain</code> was still valid for another year, so I just copied the <code>wildcard-localdomain-tls</code> Secret from the old cluster's <code>vaultwarden</code> namespace into the new cluster's <code>vaultwarden</code>, <code>argocd</code>, and later <code>market-mind</code> namespaces. Same cert works on both sides.</p>\n<h2 id=\"migrating-vaultwarden-the-login-that-almost-broke-me\">Migrating Vaultwarden: The Login That Almost Broke Me</h2>\n<p>Vaultwarden was first because it's the highest-value service (password manager) and also a clean test case: single pod, single SQLite database, single PVC. If the pattern works here, it works everywhere.</p>\n<h3 id=\"the-copy\">The copy</h3>\n<ol>\n<li>Scale the old Vaultwarden deployment to 0 to stop writes</li>\n<li>Spin up a tiny busybox pod that mounts the same PVC</li>\n<li><code>tar czf /tmp/vw-data.tar.gz</code> the <code>/data</code> directory (SQLite db, attachments, <code>rsa_key.pem</code>, icon cache)</li>\n<li><code>kubectl cp</code> the tarball out to my Mac</li>\n<li>Apply the Vaultwarden manifest to the new cluster. New PVC is created empty, pod starts, I wait for it</li>\n<li>Scale the new deployment to 0</li>\n<li>Start another busybox helper on the new cluster, mount the new PVC, <code>kubectl cp</code> the tarball in, <code>tar xzf</code>, done</li>\n<li>Scale new deployment to 1</li>\n<li>Delete helper pods</li>\n</ol>\n<p>Total data size: 3.6MB. Total downtime on the new side: a few minutes. Old cluster data was frozen the entire time.</p>\n<h3 id=\"the-login-test\">The login test</h3>\n<p>Vaultwarden responded. HTTP 200. API config returned the right domain. Cert was valid. Everything on the technical side was green. So I flipped the Caddyfile:</p>\n<pre><code class=\"language-diff\"> vault.localdomain {\n   tls /etc/caddy/certs/wildcard.crt /etc/caddy/certs/wildcard.key\n-  import k8s_backend\n+  import k8s_backend_ha\n }\n</code></pre>\n<p>Reran the Ansible playbook, Caddy reloaded on all three LB nodes in a second, tested <code>curl https://vault.localdomain/api/config</code> and the right response came back. Felt great.</p>\n<p>Then I tried to actually log in from my Bitwarden client.</p>\n<p><strong>Wrong password.</strong></p>\n<p>I tried it again. Wrong password. A cold feeling started forming in my stomach. Did the SQLite WAL files not flush? Did the copy corrupt something? Was I about to lose years of passwords?</p>\n<p>Before changing anything, I asked my pair-assistant to flip the DNS back to the old cluster and scale the old Vaultwarden back up. Took about 30 seconds. Tried the same password on the old one.</p>\n<p><strong>Wrong password.</strong></p>\n<p>That's when I realized I'd been typing my email wrong. I had fat-fingered one character. The migration was fine. I was the bug.</p>\n<p>We flipped back to the new cluster, I logged in successfully, and I learned a lesson that applies to every migration: <strong>when something doesn't work, verify it doesn't work on the known-good side first</strong>. Don't assume the thing you just changed is the thing that broke. Sometimes you're just typing your own email wrong.</p>\n<h2 id=\"migrating-argocd-server-side-apply-and-empty-states\">Migrating ArgoCD: Server-Side Apply and Empty States</h2>\n<p>ArgoCD is the thing that manages the other things. It watches my MarketMind repo, notices when I push a manifest change, and syncs it into the cluster. Migrating ArgoCD itself is basically \"install it again and re-register the same git repos.\"</p>\n<p>Installed ArgoCD v3.3.0 with the manifest from the official repo:</p>\n<pre><code class=\"language-bash\">kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.0/manifests/install.yaml\n</code></pre>\n<p>First error, and it's the kind of error you only ever see when the tool is getting old:</p>\n<pre><code>The CustomResourceDefinition \"applicationsets.argoproj.io\" is invalid:\nmetadata.annotations: Too long: may not be more than 262144 bytes\n</code></pre>\n<p>The ArgoCD install manifest embeds its entire CRD history as annotations, and the <code>ApplicationSet</code> CRD has grown past Kubernetes' 256KB client-side apply limit. Fix is a single flag:</p>\n<pre><code class=\"language-bash\">kubectl apply -n argocd --server-side --force-conflicts \\\n  -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.0/manifests/install.yaml\n</code></pre>\n<p>Server-side apply bypasses the limit because it doesn't stuff the whole manifest into an annotation. <code>--force-conflicts</code> tells it \"yes, I mean to overwrite fields some other controller used to own.\" Standard dance.</p>\n<p>After install, I patched the <code>argocd-cmd-params-cm</code> ConfigMap with <code>server.insecure: \"true\"</code> so ArgoCD speaks HTTP internally and TLS terminates at Caddy, applied the IngressRoute, and restarted the server deployment to pick up the config. Five minutes of work.</p>\n<h3 id=\"the-confusing-part\">The confusing part</h3>\n<p>I flipped <code>argocd.localdomain</code> to the new cluster and logged in. The new cluster's ArgoCD had no applications registered yet (by design, since MarketMind was still being held for a separate migration step). So when my browser followed a cached bookmark to <code>argocd.localdomain/applications/argocd/market-mind?view=tree</code>, the page showed <strong>\"Failed to load data\"</strong>.</p>\n<p>Briefly: was it broken? No. The URL was a deep link into an application that didn't exist yet on this ArgoCD instance. Clicking <strong>Applications</strong> in the sidebar showed the correct state: an empty list. The \"Failed to load data\" was the honest answer to \"show me this nonexistent thing.\"</p>\n<p>This is not a bug. It's a UX that assumed you wouldn't navigate directly to a nonexistent resource. Easy to miss when you're in migration mode and every error looks like a disaster.</p>\n<h2 id=\"migrating-marketmind-the-actual-application\">Migrating MarketMind: The Actual Application</h2>\n<p>MarketMind is a Rails app that reads market data, runs background jobs, and writes to Postgres. It was the most complex migration because it had external dependencies that the new cluster had never seen before.</p>\n<h3 id=\"pre-requisite-trusting-step-ca\">Pre-requisite: trusting step-ca</h3>\n<p>MarketMind's container image lives at <code>image-registry.localdomain</code> on the Portainer host. The registry presents a TLS cert signed by my internal step-ca. The old cluster nodes had the step-ca root CA installed in their system trust store. The new cluster nodes did not.</p>\n<p>First pod creation on the new cluster failed with a line I've seen a hundred times in other contexts:</p>\n<pre><code>failed to resolve image: failed to do request: Head \"https://image-registry.localdomain/v2/market-mind/manifests/latest\":\ntls: failed to verify certificate: x509: certificate signed by unknown authority\n</code></pre>\n<p>Fix is the standard Ubuntu trust-anchor dance on every node:</p>\n<pre><code class=\"language-bash\">scp step-ca/root_ca.crt ubuntu@192.168.1.40:/tmp/emirs-lab-ca.crt\nssh ubuntu@192.168.1.40 '\n  sudo mv /tmp/emirs-lab-ca.crt /usr/local/share/ca-certificates/\n  sudo update-ca-certificates\n  sudo systemctl restart containerd\n'\n</code></pre>\n<p>Then <code>sudo crictl pull image-registry.localdomain/market-mind:latest</code> on that node returned \"Image is up to date\" instead of a TLS error. Did this on all three nodes. (Also added to my list of things that should move into the Ansible playbook so re-provisioning a node doesn't hit this again.)</p>\n<h3 id=\"pre-requisite-the-step-ca-root-ca-configmap\">Pre-requisite: the step-ca root CA ConfigMap</h3>\n<p>Even after the nodes trust step-ca, the <em>pods</em> don't automatically. MarketMind makes outgoing HTTPS requests to <code>errbit.localdomain</code> (for error reporting) and needs to trust the homelab CA for those. This is done with a ConfigMap that's mounted as a CA bundle inside the pod:</p>\n<pre><code class=\"language-bash\"># 1. Grab the system CA bundle from a cluster node and append the step-ca root\nssh ubuntu@192.168.1.40 \"cat /etc/ssl/certs/ca-certificates.crt\" &gt; /tmp/ca-bundle.crt\ncat /tmp/emirs-lab-ca.crt &gt;&gt; /tmp/ca-bundle.crt\n\n# 2. Create the ConfigMap in the market-mind namespace\nkubectl create configmap step-ca-root-ca \\\n  --from-file=ca-certificates.crt=/tmp/ca-bundle.crt \\\n  -n market-mind\n</code></pre>\n<p>The deployment manifest already mounts this ConfigMap at <code>/etc/ssl/custom-certs</code> and sets <code>SSL_CERT_FILE</code> to point at it. This is one of those patterns that feels like overkill until you realize the pod has no idea your LAN hostnames are real.</p>\n<h3 id=\"the-actual-database-swap\">The actual database swap</h3>\n<p>MarketMind previously connected to an external Postgres running on the Portainer host. The manifest used a clever <code>ExternalName</code> Service so the app could say \"connect to <code>postgres:5432</code>\" and Kubernetes would DNS-route that to <code>postgres.localdomain</code>.</p>\n<p>On the new cluster I had already stood up a proper HA Postgres (CloudNativePG, a separate project worth its own post). So the whole external-name dance was unnecessary. Two changes in the MarketMind repo:</p>\n<ol>\n<li>\n<p><strong>Update the sealed secret</strong> with new connection strings pointing at the in-cluster Postgres service:</p>\n<pre><code>DATABASE_URL=postgres://market_mind:REDACTED@pg-ha-rw.pg.svc.cluster.local:5432/market_mind_production\nQUEUE_DATABASE_URL=postgres://market_mind:REDACTED@pg-ha-rw.pg.svc.cluster.local:5432/market_mind_production_queue\n</code></pre>\n</li>\n<li>\n<p><strong>Delete <code>postgres-service.yaml</code></strong> (the ExternalName) and remove it from <code>kustomization.yaml</code>. The new DB URL uses the real in-cluster service name directly.</p>\n</li>\n</ol>\n<p>For the sealed secret update I used <code>kubeseal --merge-into</code> so only <code>DATABASE_URL</code> and <code>QUEUE_DATABASE_URL</code> got re-sealed. The other keys (API tokens, SMTP creds, Rails master key) stayed untouched:</p>\n<pre><code class=\"language-bash\">kubeseal --fetch-cert \\\n  --controller-name=sealed-secrets-controller \\\n  --controller-namespace=kube-system \\\n  --kubeconfig ~/.kube/config-ha &gt; /tmp/cert.pem\n\necho -n \"postgres://market_mind:REDACTED@pg-ha-rw.pg.svc.cluster.local:5432/market_mind_production\" \\\n  | kubectl create secret generic market-mind-secret \\\n    --namespace market-mind \\\n    --dry-run=client \\\n    --from-file=DATABASE_URL=/dev/stdin \\\n    -o yaml \\\n  | kubeseal --format yaml --cert /tmp/cert.pem \\\n    --merge-into infra/k8s/market-mind/sealed-secret.yaml\n</code></pre>\n<p>Commit, push, register the ArgoCD Application on the new cluster:</p>\n<pre><code class=\"language-yaml\">apiVersion: argoproj.io/v1alpha1\nkind: Application\nmetadata:\n  name: market-mind\n  namespace: argocd\nspec:\n  destination:\n    namespace: market-mind\n    server: https://kubernetes.default.svc\n  project: default\n  source:\n    path: infra/k8s/market-mind\n    repoURL: git@github.com:example/market-mind.git\n    targetRevision: main\n  syncPolicy:\n    automated:\n      prune: true\n      selfHeal: true\n</code></pre>\n<h3 id=\"the-beautiful-part\">The beautiful part</h3>\n<p>Within about 90 seconds of creating the Application, the worker pod logs showed:</p>\n<pre><code>INFO msg=\"Enqueued StateOfMarketJob to SolidQueue(default)\"\nINFO msg=\"Performing StateOfMarketJob\"\nRate limit reset for tradier. Usage set to 0.\nRate limit reset for fmp. Usage set to 0.\nProceeding with request to get_market_clock.\nINFO msg=\"Market status updated\"\nINFO msg=\"Performed StateOfMarketJob in 450.25ms\"\n</code></pre>\n<p>SealedSecret unsealed, image pulled, init container ran <code>db:prepare</code> against the new HA Postgres, web started, worker started, worker picked up its first scheduled job, called the real Tradier and FMP APIs, wrote back to Postgres, and logged out \"Market status updated\" as if nothing had changed.</p>\n<p>Nothing had changed from MarketMind's point of view. That's the point. Same image, same env vars, different infrastructure underneath. And now the underlying database is itself HA with replication and automatic failover.</p>\n<h2 id=\"the-cicd-pipeline-surprise\">The CI/CD Pipeline Surprise</h2>\n<p>After MarketMind was live, I pushed a code change to main expecting the CD workflow to build and deploy. The workflow file looked like this:</p>\n<pre><code class=\"language-yaml\">- name: Restart deployments\n  run: |\n    ssh -o StrictHostKeyChecking=no -i ~/.ssh/master_superfly_lan k8s@192.168.1.10 \\\n      \"kubectl rollout restart deployment/market-mind-web deployment/market-mind-worker -n market-mind\"\n</code></pre>\n<p>Which would have been perfectly fine except <code>192.168.1.10</code> was the old K8s master and I had just shut it off. The build step succeeded, the image push succeeded, and then the rollout step hung waiting for SSH to a dead IP.</p>\n<p>Two ways to fix this. Option A: change the SSH target to a node on the new cluster. Option B: give the GitHub Actions runner its own kubeconfig and run <code>kubectl</code> directly with no SSH hop.</p>\n<p>Option B is strictly better. The SSH hop was only there because the runner didn't have kubectl or a kubeconfig. Once I put those in place, the workflow step collapses to one line:</p>\n<pre><code class=\"language-yaml\">- name: Restart deployments\n  run: |\n    kubectl rollout restart deployment/market-mind-web deployment/market-mind-worker -n market-mind\n</code></pre>\n<p>And because the kubeconfig points at the kube-vip API endpoint (<code>192.168.1.222:6443</code>), any single control-plane node can be down and the runner still works.</p>\n<p>One-time setup on the runner:</p>\n<pre><code class=\"language-bash\"># Install kubectl (pinned to match cluster version)\ncurl -sLO \"https://dl.k8s.io/release/v1.35.3/bin/linux/amd64/kubectl\"\nsudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl\n\n# Drop the kubeconfig\nscp ~/.kube/config-ha runner@gh-runner:~/.kube/config\nssh runner@gh-runner 'chmod 600 ~/.kube/config'\n</code></pre>\n<p>Next push, the pipeline ran green end-to-end. ArgoCD showed the new replica set (<code>rev:2</code>) alongside the old (<code>rev:1</code>) during the rolling update, both for web and worker. Two minutes later, only the new pods were running.</p>\n<h3 id=\"a-note-on-rollout-restart-and-latest\">A note on <code>rollout restart</code> and <code>:latest</code></h3>\n<p>If you've never thought about this: when your image tag is <code>:latest</code>, pushing a new image doesn't automatically redeploy anything. The Deployment manifest in git is byte-identical before and after. Kubernetes sees nothing to change. ArgoCD sees nothing to sync.</p>\n<p><code>kubectl rollout restart</code> fakes a change by adding a timestamp annotation to the pod template. Kubernetes notices the template is \"different,\" creates a new ReplicaSet, and the rolling update begins.</p>\n<p>This is a hack. The GitOps-correct thing is to tag images with the commit SHA and have your CD pipeline write the SHA into the Deployment manifest and commit it back. Then ArgoCD sees a real git change, syncs it, and you get honest rollbacks via <code>git revert</code>. I know this. I'll fix it eventually. It's in a TODO somewhere.</p>\n<h2 id=\"retargeting-the-ha-lb\">Retargeting the HA LB</h2>\n<p>Each service got the same one-line Caddyfile change as Vaultwarden:</p>\n<pre><code class=\"language-diff\">-  import k8s_backend\n+  import k8s_backend_ha\n</code></pre>\n<p>Where <code>k8s_backend_ha</code> was a new snippet I'd added alongside the existing <code>k8s_backend</code>:</p>\n<pre><code class=\"language-caddy\"># Old cluster — workers only, goes down with Proxmox\n(k8s_backend) {\n  reverse_proxy 192.168.1.11:30443 192.168.1.12:30443 {\n    transport http { tls; tls_insecure_skip_verify }\n    lb_policy round_robin\n    health_interval 10s\n  }\n}\n\n# HA cluster — all 3 nodes, survives any single-node failure\n(k8s_backend_ha) {\n  reverse_proxy 192.168.1.40:30443 192.168.1.41:30443 192.168.1.42:30443 {\n    transport http { tls; tls_insecure_skip_verify }\n    lb_policy round_robin\n    health_interval 10s\n  }\n}\n</code></pre>\n<p>Keeping both snippets side by side let me migrate services one at a time and have trivial rollback. After all three (<code>vault</code>, <code>argocd</code>, <code>market-mind</code>) were successfully on <code>k8s_backend_ha</code> for a while, I deleted the old snippet, renamed the new one back to <code>k8s_backend</code>, and got the file back to a clean single-cluster config.</p>\n<h2 id=\"the-graceful-goodbye\">The Graceful Goodbye</h2>\n<p>Once every service was live on the new cluster and verified, the old cluster was dead weight: VMs burning CPU cycles in Proxmox, running workloads that no traffic reached.</p>\n<p>I didn't destroy them immediately. The smart move is a soak period. For seven days, I'd leave them powered off. If anything quietly depended on the old cluster in a way I missed (a cron job, a forgotten hostname in a config file, a cloudflared route, a scheduled GitHub Action), it would surface within the week. If not, I'd <code>terraform destroy</code> with confidence.</p>\n<p>Shutting them down was a one-shot on the Proxmox host:</p>\n<pre><code class=\"language-bash\">for vmid in 110 111 112; do\n  qm shutdown $vmid --timeout 60\n  qm set $vmid --onboot 0\ndone\n</code></pre>\n<p>The <code>onboot 0</code> flag is important. Without it, a Proxmox reboot would auto-start the VMs and the soak would silently restart the old cluster, and I wouldn't notice until I hit a ghost.</p>\n<p>With the VMs off, I ran the smoke test one more time:</p>\n<pre><code>vault.localdomain:       200\nargocd.localdomain:      200\nmarket-mind.localdomain: 200\n</code></pre>\n<p>All three services still alive. Proof that nothing was secretly depending on the old cluster. The soak clock started.</p>\n<p>If the week passes clean, the destroy script is already written:</p>\n<pre><code class=\"language-bash\">cd terraform/proxmox\nterraform destroy \\\n  -target=proxmox_virtual_environment_vm.k8s_master \\\n  -target=proxmox_virtual_environment_vm.k8s_workers\n</code></pre>\n<p>Then remove the resource definitions from the repo, clean up the old <code>k8s_nodes</code> group from the Ansible inventory, and delete the now-stale <code>~/.kube/config-old-cluster</code> file.</p>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<p><strong>Parallel clusters + DNS routing beats big-bang cutover every time.</strong> Every migration step was one line change in one file and a playbook re-run. Rollback was the same change in reverse. There was never a moment where I couldn't get back to a working state in under a minute. Compare that to the alternative \"take everything down at 3am and hope\" pattern and it's not even close.</p>\n<p><strong>Back up Sealed Secrets keys the day you install the controller.</strong> Without them, every <code>sealed-secret.yaml</code> in every repo becomes decorative. Put the backup in your password manager. Put it in two password managers. Treat it like the private key it actually is.</p>\n<p><strong>When something breaks during a migration, verify the known-good side first.</strong> I almost unwound a perfectly good Vaultwarden migration because I typed my email wrong. The instinct after any change is to suspect the change. Fight the instinct. Check the baseline first.</p>\n<p><strong><code>:latest</code> + <code>kubectl rollout restart</code> is a hack, and hacks have a way of meeting you at 2am.</strong> Tag images with commit SHAs. Let your CD pipeline commit the new SHA into the Deployment manifest. Your future self will thank you.</p>\n<p><strong>Runner-as-cluster-client is simpler than runner-SSH-to-cluster-node.</strong> The old pattern had a hardcoded node IP, no redundancy, and one more hop to debug. Putting the kubeconfig on the runner and running <code>kubectl</code> directly collapsed three lines of SSH incantation into one line, and gave me automatic API server failover for free.</p>\n<p><strong>Soak before destroy.</strong> One week, VMs off, onboot disabled. Costs you nothing. Buys you a safety net against the thing you forgot. There's always a thing you forgot.</p>\n<h2 id=\"whats-next\">What's Next</h2>\n<p>After the soak expires, I'll destroy the old VMs, reclaim the compute, and remove the old cluster's scaffolding from the repo. That's the last mechanical step.</p>\n<p>After that, the big remaining gap is <strong>backups</strong>. The new cluster uses <code>local-path-provisioner</code>, which is simple and fast and pins each PVC to a single node. If that node's disk dies, Vaultwarden's SQLite database dies with it. The HA-control-plane story is great; the data-durability story is nonexistent. Fix coming as a separate project in the disaster recovery series.</p>\n<p>But that's a post for another day. Today, I've got a highly available Kubernetes cluster running on genuinely HA hardware, with three stateful services migrated cleanly, verified login paths, and an old cluster politely waiting for the graveyard. That's a good week.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong> Three glowing hardware nodes in a triangle (one rack server, two small mini PCs) with data streams flowing between them, while in the foreground a single old dusty server VM fades out and dissolves into pixels. The new triangle of nodes is vivid and bright; the dissolving server is desaturated and fragmentary. Dark homelab aesthetic with deep blue and neon green accent lighting, holographic Kubernetes wheel hovering above the triangle, photorealistic 3D render with shallow depth of field.</p>\n","comment_id":"69e41b613cad350001301cf9","feature_image":"https://emir.fyi/content/images/2026/04/ChatGPT-Image-Apr-18--2026--08_08_43-PM.png","featured":false,"visibility":"public","created_at":"2026-04-18T20:01:37.000-04:00","updated_at":"2026-05-13T10:21:08.000-04:00","published_at":"2026-05-13T10:21:08.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/building-a-high-availability-kubernetes-cluster-across-mixed-hardware-part-2-migration-and-the-graceful-goodbye/","excerpt":"Where We Left Off\n\n\nPart 1 ended with a shiny new 3-node HA Kubernetes cluster: one Proxmox VM and two BeeLink mini PCs, glued together with kubeadm, kube-vip, and Flannel. The control plane was HA, the etcd quorum was solid, and the VIP survived any single-node failure.\n\n\nThe cluster was also empty.\n\n\nI closed that post with \"the hard part is done, the easy part is next, famous last words.\" Reader, I was not entirely wrong. Migrating the workloads was significantly easier than building the clus","reading_time":15,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69e4078b3cad350001301ce1","uuid":"5a8daf84-2203-4d20-9566-8c88d06dafed","title":"Making Postgres HA on Kubernetes with CloudNativePG (And the Operator SPOF Nobody Talks About)","slug":"making-postgres-ha-on-kubernetes-with-cloudnativepg-and-the-operator-spof-nobody-talks-about","html":"<h2 id=\"the-itch\">The Itch</h2>\n<p>My wife runs a small property management business and I host the Rails apps that run it. Main app, background job workers, queue database, the usual. All of it sitting on a single PostgreSQL container on one Docker host in my homelab. One machine, one process, one very clear blast radius. When that box rebooted (and it did), her business app was offline until I noticed. Not great when your production system has exactly one customer and that customer lives in the next room.</p>\n<p>I'd been putting off HA Postgres because every tutorial stopped at the same place. Spin up a 3 node cluster, delete the primary pod, watch the replica get promoted in five seconds, clap politely, post to Reddit. None of that looked like what would happen when a real machine actually fails. So I decided to build it properly and test it like I meant it.</p>\n<p>What follows is the journey. The good parts (picking the right operator, sync replication, data migration), the weird parts (tests that showed no failover at all), and the plot twist I genuinely didn't see coming: the operator itself was a single point of failure sitting on the same node as my primary.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Pick an operator</s> (done, CloudNativePG)</li>\n<li><s>Deploy a 3 instance cluster with synchronous replication, one per node</s> (done)</li>\n<li><s>Manage roles and databases declaratively</s> (done)</li>\n<li><s>Migrate existing app databases off the legacy single Postgres</s> (done)</li>\n<li><s>Run failover tests that actually reflect real failures</s> (done, five scenarios)</li>\n<li><s>Fix the \"operator is also a SPOF\" problem</s> (done)</li>\n<li><s>Switch the primary onto the most reliable node</s> (done)</li>\n<li>Configure backups to object storage (pending)</li>\n<li>Enable Prometheus PodMonitor (pending, waiting for Prometheus itself)</li>\n<li>Major version upgrade 17 to 18 as a live exercise (pending, its own blog post)</li>\n</ol>\n<h2 id=\"picking-the-operator\">Picking the Operator</h2>\n<p>Three operators dominate the conversation. I went with <a href=\"https://cloudnative-pg.io/?ref=emir.fyi\">CloudNativePG</a>.</p>\n<table>\n<thead>\n<tr>\n<th>Operator</th>\n<th>Vibe</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>CloudNativePG</strong></td>\n<td>CNCF sandbox, backed by EDB, most active commits, cleanest Kubernetes native feel, best docs I've ever read for a Postgres operator</td>\n</tr>\n<tr>\n<td><strong>Zalando postgres-operator</strong></td>\n<td>Battle tested at massive scale, development has visibly slowed, feels more \"maintained\" than \"alive\"</td>\n</tr>\n<tr>\n<td><strong>Crunchy PGO</strong></td>\n<td>Enterprise backed, solid, leans commercial and the Helm story is less obvious</td>\n</tr>\n</tbody>\n</table>\n<p>CNPG won on docs, momentum, and first class Kubernetes resources for everything. Cluster, Database, and Backup CRDs. You manage Postgres the same way you manage Deployments. No hidden state, no weird fallback to imperative kubectl for things you'd expect to be declarative.</p>\n<h2 id=\"sync-replication-the-knobs-that-matter\">Sync Replication: The Knobs That Matter</h2>\n<p>Three fields under <code>.spec.postgresql.synchronous</code> control the behavior.</p>\n<ul>\n<li><strong><code>method</code></strong>: <code>any</code> is quorum based (any N standbys can ack). <code>first</code> is priority based. For a homelab where all nodes are equivalent, <code>any</code>.</li>\n<li><strong><code>number</code></strong>: how many standbys must ack each commit. With 3 instances (1 primary, 2 replicas), <code>number: 1</code> tolerates one replica being down.</li>\n<li><strong><code>dataDurability</code></strong>: <code>required</code> stops writes if the sync quorum can't be met (RPO=0). <code>preferred</code> silently falls back to async and keeps writing, which means potential data loss if the primary dies next. The one that matches the \"zero data loss\" marketing is <code>required</code>.</li>\n</ul>\n<p>The tradeoff matrix I drew before committing:</p>\n<table>\n<thead>\n<tr>\n<th><code>number</code></th>\n<th><code>dataDurability</code></th>\n<th>0 replicas up</th>\n<th>1 replica up</th>\n<th>2 replicas up</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>1</td>\n<td>required</td>\n<td>writes block</td>\n<td>writes proceed</td>\n<td>writes proceed</td>\n</tr>\n<tr>\n<td>1</td>\n<td>preferred</td>\n<td>writes proceed (lossy)</td>\n<td>writes proceed</td>\n<td>writes proceed</td>\n</tr>\n<tr>\n<td>2</td>\n<td>required</td>\n<td>writes block</td>\n<td>writes block</td>\n<td>writes proceed</td>\n</tr>\n<tr>\n<td>2</td>\n<td>preferred</td>\n<td>writes proceed (lossy)</td>\n<td>writes proceed (partial sync)</td>\n<td>writes proceed</td>\n</tr>\n</tbody>\n</table>\n<p>I picked <code>any / 1 / required</code>. Tolerates one replica down, blocks writes if both replicas are gone (correct, you can't accept commits you can't replicate), zero data loss on primary failure. Latency cost is about one extra LAN roundtrip per commit. Sub millisecond on my gigabit network.</p>\n<h2 id=\"building-the-cluster\">Building the Cluster</h2>\n<p>Install was one kubectl command from the upstream release manifest. Operator into <code>cnpg-system</code>, CRDs provisioned, webhooks registered, done. (Spoiler: this is fine for a demo, not fine for production. More on that later.)</p>\n<p>The Cluster resource itself was three instances of Postgres 17.9, pinned by SHA digest through a <code>ClusterImageCatalog</code>, synchronous replication configured as above, pod anti-affinity forcing exactly one instance per node. The part that matters:</p>\n<pre><code class=\"language-yaml\">spec:\n  instances: 3\n  postgresql:\n    synchronous:\n      method: any\n      number: 1\n      dataDurability: required\n  affinity:\n    enablePodAntiAffinity: true\n    topologyKey: kubernetes.io/hostname\n    podAntiAffinityType: required\n  storage:\n    storageClass: local-path\n    size: 100Gi\n</code></pre>\n<p><code>podAntiAffinityType: required</code> instead of the CNPG default <code>preferred</code>. In a 3 node 3 instance cluster there's no valid reason to colocate, and \"we tried to spread\" degrades the whole point. Full manifest lives in my <a href=\"https://github.com/example/homelab/blob/main/k8s/pg/pg-ha.yaml?ref=emir.fyi\">homelab repo</a>.</p>\n<p>Three minutes later the cluster was up. CNPG auto creates three Services which is the nicest part of the whole operator.</p>\n<pre><code>pg-ha-rw   ClusterIP   192.168.10.10   5432/TCP   ← always points at the current primary\npg-ha-ro   ClusterIP   192.168.10.11   5432/TCP   ← load balanced across replicas\npg-ha-r    ClusterIP   192.168.10.12   5432/TCP   ← any instance\n</code></pre>\n<p>The app connects to <code>pg-ha-rw.pg.svc.cluster.local:5432</code>. The ClusterIP is stable forever. When the primary moves between pods, CNPG flips the Service's label selector and Kubernetes silently reroutes traffic. No DNS TTLs, no connection string changes, no external load balancer. This is the part of Kubernetes that earns its complexity tax.</p>\n<h2 id=\"migrating-the-data\">Migrating the Data</h2>\n<p>Two databases to move, both belonging to a Rails app. Existing setup connected as the <code>postgres</code> superuser with a memorable password. I took the opportunity to create a dedicated role instead:</p>\n<pre><code class=\"language-yaml\"># managed roles live under .spec.managed.roles on the Cluster\nroles:\n  - name: myapp\n    ensure: present\n    login: true\n    superuser: false\n    passwordSecret:\n      name: myapp-db-credentials\n</code></pre>\n<p>Plus a <code>Database</code> CRD per database, telling CNPG to create them and own them with the <code>myapp</code> role. Apply, wait three seconds, CNPG has created both databases with the right ownership.</p>\n<p><code>pg_dump</code> took eight seconds for 200 MB of data. Restoring into the CNPG cluster was the one place I hit friction: the container's <code>/tmp</code> is read only so <code>kubectl cp</code> fails. Streaming the dump through stdin works fine:</p>\n<pre><code class=\"language-bash\">kubectl exec -i -n pg pg-ha-1 -c postgres -- \\\n  pg_restore -U postgres -d myapp_production \\\n  --role=myapp --no-owner --no-privileges &lt; myapp_prod.dump\n</code></pre>\n<p>Table counts matched, row counts matched, ownership was right. Old Postgres stayed running as rollback insurance, because nothing good has ever come from decommissioning the only working copy of your data on the same day you migrate it.</p>\n<h2 id=\"failover-testing-the-tutorial-path\">Failover Testing, The Tutorial Path</h2>\n<p>I deployed a probe pod on a non test node, running a tight loop that hit <code>pg-ha-rw</code> every 200 ms, timestamped each success and failure, logged which backend IP answered. Don't trust <code>kubectl get pods</code> for failover timing. The API cache lies. A continuous probe is the only way to measure real client downtime.</p>\n<p><strong>Test A, <code>kubectl delete pod</code> on the primary.</strong> Readiness probe fails immediately, CNPG promotes a replica. <strong>4.7 seconds of downtime</strong>, 12 failed probes. Textbook.</p>\n<p><strong>Test B, <code>kubectl drain</code> on the primary's node.</strong> CNPG's PodDisruptionBudget delayed the eviction about five seconds while it promoted a replica first. <strong>6.4 seconds of downtime</strong>. Also textbook.</p>\n<p>Two tests in, the tutorial is accurate. HA Postgres on Kubernetes works. Applause.</p>\n<h2 id=\"failover-testing-where-it-gets-weird\">Failover Testing, Where It Gets Weird</h2>\n<p><strong>Test C, <code>systemctl stop kubelet</code> on the primary's node.</strong> This test exposes how HA systems actually reason about health. Kubelet stops, but containerd keeps running, so Postgres stays very much alive.</p>\n<p>Result: <strong>50 seconds of downtime, no failover</strong>. Same backend IP before and after.</p>\n<p>Breaking it down: CNPG decides to promote based on direct Postgres health checks. The process was alive, so CNPG saw no problem. But Kubernetes noticed kubelet stopped heartbeating after 40 seconds (the default <code>node-monitor-grace-period</code>), marked the node <code>NotReady</code>, and the endpoint controller removed the primary from the <code>pg-ha-rw</code> Service. No new endpoint appeared because CNPG hadn't promoted anyone. The probe got 50 seconds of nothing routable. When kubelet came back, the endpoint returned and the same pod resumed serving.</p>\n<p>Lesson: kubelet flakiness on an otherwise healthy node causes client downtime without triggering failover. Monitor both layers independently.</p>\n<p><strong>Test D, actual reboot of the primary's node.</strong> <code>sudo systemctl reboot</code>. I expected fast failover, maybe 10 seconds, because now the Postgres process genuinely dies.</p>\n<p>Result: <strong>65 seconds of downtime, still no failover</strong>. The old primary pod came back on the rebooted node as primary.</p>\n<p>I couldn't explain this so I read CNPG GitHub issues for an hour. Failover on node failure is gated by Kubernetes <code>node-monitor-grace-period</code>, default 40 seconds. A <code>systemctl reboot</code> brings a node back in about 60 seconds, right around that threshold. Kubernetes doesn't mark the node <code>NotReady</code> long enough for anything to escalate, so the pod is never marked for deletion, and CNPG never gets an opportunity to decide. From maintainer Marco Nenciarini (<a href=\"https://github.com/cloudnative-pg/cloudnative-pg/issues/6154?ref=emir.fyi\">issue #6154</a>):</p>\n<blockquote>\n<p>It is not possible to use CloudNativePG for use cases where a failover time smaller than 40-45 seconds in case of node failure is required.</p>\n</blockquote>\n<p>Expected behavior, documented, tracked for future decoupling. Fine. But the next test is where it got interesting.</p>\n<h2 id=\"the-plot-twist\">The Plot Twist</h2>\n<p>Before Test E (a real node loss by pulling a power cable), I did one more <code>kubectl get</code>. I noticed something I should have checked on day one. The CNPG operator, the single replica that comes with the default install, was running on the <strong>same node as the primary</strong>. The node I was about to unplug.</p>\n<p>Here is what happens when the primary's node dies in the default install:</p>\n<ol>\n<li>Primary unreachable</li>\n<li>Operator (on the same node) also unreachable</li>\n<li>Kubernetes waits ~40 s, marks node <code>NotReady</code></li>\n<li>The operator pod has a default toleration for <code>node.kubernetes.io/unreachable:NoExecute</code> of <strong>300 seconds</strong>. Kubernetes won't evict it for five minutes.</li>\n<li>During those five minutes, no failover decision happens because there's no operator running anywhere</li>\n<li>Client downtime: 5+ minutes</li>\n</ol>\n<p>I pulled the cable. <strong>65.6 seconds of probe failures. No replica promoted.</strong> Same pod came back on the rebooted node as primary, not because CNPG decided to wait it out, but because the operator wasn't there to decide anything at all.</p>\n<p>This is the moment the whole HA story changes from \"Postgres is highly available\" to \"Postgres is highly available <em>conditional on the operator being scheduled on a node that hasn't failed</em>\". That's not HA. That's a coin flip.</p>\n<h2 id=\"the-fix-that-actually-works\">The Fix That Actually Works</h2>\n<p>Run the operator with enough replicas that at least one is always on a healthy node. Plus leader election (built in) and topology spread (has to be configured). The default release manifest installs with <code>replicas: 1</code>. The official Helm chart supports what we need. So I switched.</p>\n<pre><code class=\"language-bash\">helm repo add cnpg https://cloudnative-pg.github.io/charts\nhelm upgrade --install cnpg cnpg/cloudnative-pg \\\n  --namespace cnpg-system \\\n  --version 0.28.0 \\\n  --take-ownership \\\n  -f k8s/cnpg-operator/values.yaml\n</code></pre>\n<p>The <code>--take-ownership</code> flag in Helm 4 adopts resources previously applied with kubectl. A few resources (webhook configs, monitoring ConfigMap, webhook Service) had server side apply field manager conflicts that Helm couldn't resolve, so I deleted those four manually and let Helm recreate them clean. Existing CRDs and running pods stayed untouched. Zero Postgres downtime through the entire operator migration.</p>\n<p>The values file that matters:</p>\n<pre><code class=\"language-yaml\">replicaCount: 3\n\ntopologySpreadConstraints:\n  - maxSkew: 1\n    topologyKey: kubernetes.io/hostname\n    whenUnsatisfiable: DoNotSchedule\n    labelSelector:\n      matchLabels:\n        app.kubernetes.io/name: cloudnative-pg\n    matchLabelKeys:\n      - pod-template-hash\n\ncrds:\n  create: true\n</code></pre>\n<p>Two details that matter more than they look.</p>\n<p><strong><code>whenUnsatisfiable: DoNotSchedule</code></strong> is strict spread. Kubernetes refuses to schedule a pod that would violate the spread, rather than \"trying\" and degrading to colocation. With three nodes and three replicas, this forces exactly one pod per node.</p>\n<p><strong><code>matchLabelKeys: [pod-template-hash]</code></strong> is the detail I almost missed. Without it, during a rolling update the scheduler counts <em>both</em> old and new ReplicaSet pods when evaluating spread, which breaks rolling updates. My first rollout produced a 2-1-0 split because the old pods skewed the math. Adding <code>matchLabelKeys</code> tells the scheduler to only count same-revision pods, so each revision gets its own spread evaluation. Rolling updates now land balanced every time.</p>\n<h2 id=\"the-payoff\">The Payoff</h2>\n<p>Same test as before: pull the cable on the node hosting both the primary and the leader operator. This time the leader was one of three pods and the other two were on healthy nodes.</p>\n<p>What happened:</p>\n<ol>\n<li>Node unplugged. Primary and leader operator both unreachable.</li>\n<li>Lease on leader can't be renewed. After ~15 s (controller-runtime default), lease expires. Another operator pod acquires it.</li>\n<li>New leader observes primary unreachable, promotes a replica.</li>\n<li>Service <code>pg-ha-rw</code> endpoint flips to the new primary.</li>\n<li>Probe starts getting <code>OK</code> from the new backend.</li>\n</ol>\n<p><strong>Total client downtime: 27.9 seconds. Real failover with an actual replica promotion.</strong></p>\n<p>Full results:</p>\n<table>\n<thead>\n<tr>\n<th>Test</th>\n<th>Scenario</th>\n<th>Downtime</th>\n<th>Replica promoted?</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>A</td>\n<td><code>kubectl delete pod</code> (primary)</td>\n<td>4.7 s</td>\n<td>yes</td>\n</tr>\n<tr>\n<td>B</td>\n<td><code>kubectl drain</code> primary's node</td>\n<td>6.4 s</td>\n<td>yes</td>\n</tr>\n<tr>\n<td>C</td>\n<td><code>systemctl stop kubelet</code> on primary's node</td>\n<td>50.2 s</td>\n<td>no (postgres stayed alive)</td>\n</tr>\n<tr>\n<td>D</td>\n<td>Full reboot, operator single replica</td>\n<td>65.6 s</td>\n<td>no (operator also on dying node)</td>\n</tr>\n<tr>\n<td>E</td>\n<td>Unplug, operator HA fix applied</td>\n<td><strong>27.9 s</strong></td>\n<td><strong>yes</strong></td>\n</tr>\n</tbody>\n</table>\n<p>Without the operator HA fix, hard node failure recovery is 5+ minutes. With it, under 30 seconds. Nothing else I did moved the needle like this single change.</p>\n<h2 id=\"moving-the-primary-where-it-belongs\">Moving the Primary Where It Belongs</h2>\n<p>After the tests the primary was bouncing between BeeLinks, which isn't where I wanted it. The BeeLinks are physically less reliable than the Proxmox VM hosting my third Kubernetes node (UPS backed host, longer uptime). Moved the primary over with one line:</p>\n<pre><code class=\"language-bash\">kubectl cnpg promote pg-ha pg-ha-3 -n pg\n</code></pre>\n<p>About five seconds of write downtime, clean handoff, both BeeLink hosted replicas resumed streaming from the new primary on a new timeline. Putting the most important role on the most reliable hardware is one of those decisions that seems obvious in retrospect but easy to forget mid project.</p>\n<h2 id=\"the-bonus-finding\">The Bonus Finding</h2>\n<p>After pulling power from a BeeLink, I noticed something unpleasant. After power returns, the BeeLinks <strong>do not auto boot</strong>. You have to physically press the button. For an HA cluster this defeats the purpose of having three nodes when a site-wide power outage leaves you with two.</p>\n<p>The fix is a BIOS setting. Most mini PCs default AC power recovery to \"Power Off\". Change to \"Power On\" or \"Last State\".</p>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<ul>\n<li><strong>The Kubernetes Service is the failover mechanism, not DNS.</strong> The app's connection string never changes. CNPG flips the Service selector, Kubernetes reroutes traffic. Don't overcomplicate with external load balancers.</li>\n<li><strong>Kubernetes' own timeouts bound how fast you can fail over.</strong> <code>node-monitor-grace-period</code> is a global setting, not a CNPG knob. Hard node loss recovery is 40 seconds minimum unless you tune that flag.</li>\n<li><strong>The operator is infrastructure too.</strong> Running one replica of an HA operator defeats the whole point. Three replicas with leader election and topology spread is not optional for production.</li>\n<li><strong><code>matchLabelKeys: [pod-template-hash]</code></strong> is the detail that makes rolling updates respect topology spread. Without it, spread degrades on every update.</li>\n<li><strong>Most HA tutorials test the happy path and call it done.</strong> \"Delete the pod, watch it fail over.\" That's one scenario out of many and the one least representative of what breaks in production. The useful tests are the ones where things don't work the way you expected.</li>\n</ul>\n<p>Postgres is now actually HA. Real failover, real measurements, real story for when someone asks how long an outage would take. The next person who sets this up gets to skip all the parts I didn't know to expect. That's the whole point of writing things down.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<p>A stylized isometric illustration of a three node Kubernetes cluster, three small server boxes arranged in a triangle on a dark tech grid background. Each box has a small PostgreSQL elephant logo on top. Glowing blue lines connect them showing bidirectional replication. Above the cluster floats a ghostly translucent operator pod with a crown icon, surrounded by two dimmer standby copies on either side. One of the server boxes has a red unplugged power cable, sparks flying off the end. Moody cyberpunk palette, deep blues and electric cyans, single warm orange accent on the sparks. Homelab vibes, minimal, technical, a hint of drama.</p>\n","comment_id":"69e4078b3cad350001301ce1","feature_image":"https://emir.fyi/content/images/2026/04/ChatGPT-Image-Apr-18--2026--06_50_15-PM.png","featured":false,"visibility":"public","created_at":"2026-04-18T18:36:59.000-04:00","updated_at":"2026-05-05T10:20:40.000-04:00","published_at":"2026-05-05T10:20:40.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/making-postgres-ha-on-kubernetes-with-cloudnativepg-and-the-operator-spof-nobody-talks-about/","excerpt":"The Itch\n\n\nMy wife runs a small property management business and I host the Rails apps that run it. Main app, background job workers, queue database, the usual. All of it sitting on a single PostgreSQL container on one Docker host in my homelab. One machine, one process, one very clear blast radius. When that box rebooted (and it did), her business app was offline until I noticed. Not great when your production system has exactly one customer and that customer lives in the next room.\n\n\nI'd been ","reading_time":10,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69e295f03cad350001301cbd","uuid":"a390fc6c-deec-4dcb-b814-23be05ee5593","title":"Making Cloudflare Tunnel Actually Highly Available","slug":"making-cloudflare-tunnel-actually-highly-available","html":"<p>I've been running emir.fyi and a handful of other services through a single Cloudflare Tunnel for months. Worked fine. Never thought about it.</p>\n<p>Then I started setting up a new domain example.com as a dry-run for another domain that's about to carry lead-gen traffic for my wife's property management business. Halfway through, I clocked the obvious thing: everything public on this homelab rides through a single <code>cloudflared</code> VM at <code>192.168.1.15</code>. One VM. One kernel. One shot.</p>\n<p>The HA load balancer is HA. The K8s control plane is HA. The thing routing external traffic to both of them is not. Months of building redundancy downstream, and the funnel into the whole homelab is a single box I hadn't thought about since I spun it up.</p>\n<p>Time to fix that before a real domain depends on it.</p>\n<h2 id=\"how-cloudflare-tunnel-ha-actually-works\">How Cloudflare Tunnel HA actually works</h2>\n<p>You don't need a load balancer in front of <code>cloudflared</code>. Cloudflare already has one, its entire edge network. The trick is that <strong>multiple <code>cloudflared</code> processes can run simultaneously, all authenticated against the same tunnel ID</strong>. Each one opens 4 outbound QUIC connections to Cloudflare's edge. Cloudflare sees N connectors on one tunnel, load-balances traffic across them, and drops any connector that disconnects. Sub-second failover. Zero coordination between the replicas, they don't know about each other.</p>\n<p>The only invariants are: same tunnel ID, identical credentials, identical ingress rules on every replica. Drift any of those three and failover can route a request to a connector that can't handle it.</p>\n<h2 id=\"the-design\">The design</h2>\n<p>Three replicas, one tunnel:</p>\n<table>\n<thead>\n<tr>\n<th>Host</th>\n<th>IP</th>\n<th>Hardware</th>\n<th>Also running</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>cloudflared.localdomain</td>\n<td>192.168.1.15</td>\n<td>Proxmox VM</td>\n<td>(dedicated)</td>\n</tr>\n<tr>\n<td>n1.localdomain</td>\n<td>192.168.1.40</td>\n<td>BeeLink N100</td>\n<td>K8s control plane</td>\n</tr>\n<tr>\n<td>n2.localdomain</td>\n<td>192.168.1.41</td>\n<td>BeeLink N100</td>\n<td>K8s control plane</td>\n</tr>\n</tbody>\n</table>\n<p>The BeeLinks are already pulling dual duty, they run K8s control-plane workloads alongside everything else. Adding <code>cloudflared</code> as another systemd service on each is basically free (the whole binary is ~30MB resident). The trade off: if a BeeLink dies, I lose both a K8s CP member <em>and</em> a cloudflared replica at once. Still within both quorums, but the blast radius per node failure gets wider.</p>\n<p>I considered running <code>cloudflared</code> as a Kubernetes Deployment instead. Clean on paper, replicas, rolling updates, anti affinity. But putting the thing that fronts public traffic <em>inside</em> the cluster it's supposed to be fronting felt incestuous for a first pass, and the failure mode I most wanted to validate is \"a single node dies,\" which is easier to reason about with dedicated processes on dedicated hosts. Saving that migration for later.</p>\n<h2 id=\"capturing-the-existing-config\">Capturing the existing config</h2>\n<p>Before writing any playbook, I pulled the current state off the primary VM. If my rendered config didn't match what was already running on <code>192.168.1.15</code> byte-for-byte, a future Ansible run could silently rewrite it and break emir.fyi.</p>\n<pre><code class=\"language-bash\">ansible -i inventory.yml cloudflared.localdomain -m command -a \"cat /etc/cloudflared/config.yml\" -b\n</code></pre>\n<pre><code class=\"language-yaml\">tunnel: homelab\ncredentials-file: /etc/cloudflared/&lt;tunnel-uuid&gt;.json\n\ningress:\n  - hostname: emir.fyi\n    service: http://192.168.1.221:80\n\n  - hostname: www.emir.fyi\n    service: http://192.168.1.221:80\n\n  - service: http_status:404\n</code></pre>\n<p>The credentials JSON I fetched with Ansible's <code>fetch</code> module straight to disk, silent copy, never touched stdout, lands in <code>ansible/files/cloudflared-credentials.json</code> (gitignored). This is the file every replica needs to be an identity-equivalent member of the tunnel.</p>\n<h2 id=\"the-playbook\">The playbook</h2>\n<p>Stock Ansible modules only, no extra collections. Installs the Cloudflare apt repo, installs <code>cloudflared</code>, drops credentials + config + systemd unit, enables and starts the service. Every replica ends up with an identical <code>/etc/cloudflared/</code> layout.</p>\n<p>Key snippets from <code>playbook-cloudflared.yml</code>:</p>\n<pre><code class=\"language-yaml\">- name: Add Cloudflare apt repository\n  apt_repository:\n    # Cloudflare's repo uses \"any\" serves packages for all Debian/Ubuntu releases\n    repo: \"deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main\"\n    state: present\n    filename: cloudflared\n\n- name: Deploy tunnel credentials\n  copy:\n    src: files/cloudflared-credentials.json\n    dest: \"/etc/cloudflared/{{ cloudflared_tunnel_id }}.json\"\n    owner: root\n    group: root\n    mode: \"0400\"\n  notify: Restart cloudflared\n</code></pre>\n<p>One small gotcha caught on the first dry run: I had initialized the apt-repo line with <code>{{ ansible_distribution_release }}</code> (which resolves to <code>noble</code> on 24.04). But Cloudflare's repo uses a single generic pool called <code>any</code>. Check-mode showed one line of drift on the existing VM, which would have silently rewritten its <code>sources.list.d</code> entry on apply. Five-minute fix; a useful reminder that \"canonical\" Debian conventions aren't universal.</p>\n<h2 id=\"rollout\">Rollout</h2>\n<p>Four phases, each verified before the next:</p>\n<ol>\n<li><strong>Dry-run against <code>192.168.1.15</code></strong>: <code>--check --diff --limit cloudflared.localdomain</code> had to be byte-identical no op. <code>ok=10 changed=0</code>.</li>\n<li><strong>Apply to n1 + n2 only</strong> (<code>--limit n1.localdomain,n2.localdomain</code>): fresh install, cert drop, service start. Tunnel went from 1 connector to 3 in the Cloudflare dashboard.</li>\n<li><strong>Apply to <code>192.168.1.15</code></strong>: real apply of the no-op diff to prove uniform GitOps management.</li>\n<li><strong>Rolling upgrade to latest</strong>: <code>ansible cloudflared_hosts -m apt -a \"name=cloudflared state=latest update_cache=yes\" --forks 1</code>. The critical flag is <code>--forks 1</code> forces serial execution so the tunnel never drops below 2 healthy connectors. Upgraded <code>192.168.1.15</code> from 2026.2.0 to 2026.3.0 with zero downtime.</li>\n</ol>\n<p>Throughout all four phases, I kept a <code>curl https://emir.fyi</code> loop running on my laptop. Every request came back HTTP 200, sub-200ms. No dropped requests.</p>\n<h2 id=\"the-dns-plot-twist\">The DNS plot twist</h2>\n<p>The plan for example.com was to run <code>cloudflared tunnel route dns homelab example.com</code> and have the CNAME materialize on the example.com zone. That's not what happened:</p>\n<pre><code>INF Added CNAME example.com.emir.fyi ...\n</code></pre>\n<p>It created <code>example.com</code> <strong>as a subdomain of emir.fyi</strong>. Wrong zone entirely.</p>\n<p>Root cause: <code>cloudflared</code>'s CLI authenticates against Cloudflare using <code>~/.cloudflared/cert.pem</code> the login cert. I had assumed, and Cloudflare's docs kind of implied, that one <code>cert.pem</code> covers your whole account. The reality turns out to be different. I decoded the API token embedded in the cert and hit Cloudflare's <code>/zones</code> endpoint with it:</p>\n<pre><code class=\"language-json\">{\n  \"result\": [\n    { \"id\": \"3cbc...\", \"name\": \"emir.fyi\", ... }\n  ],\n  \"result_info\": { \"count\": 1, \"total_count\": 1 }\n}\n</code></pre>\n<p>One zone. The embedded token has DNS:Edit on exactly one zone, whichever one you picked during <code>cloudflared tunnel login</code>. When you then ask the CLI to manage DNS for a <em>different</em> domain, it silently treats that domain as a subdomain of the one authorized zone. You don't get an error, you get a wrong record.</p>\n<p>I re-ran <code>cloudflared tunnel login</code> and carefully clicked the example.com zone in the browser consent flow. Same result, the cert still had <code>zoneID: 3cbc...</code> (emir.fyi). The consent UI might have single-selected, or defaulted to the first zone, or there's a bug. Either way, <code>cert.pem</code> is structurally single-zone. You can juggle multiple cert files and switch via <code>TUNNEL_ORIGIN_CERT=</code>, but that's fragile.</p>\n<p>The correct fix is to stop using <code>cert.pem</code> for DNS at all and use a <strong>scoped API token</strong> instead. Cloudflare's dashboard lets you mint a token with specific zone-level permissions across multiple zones at once. I created one covering emir.fyi, example.com, and eviasa.com (the next one up), with Zone:Read + DNS:Edit on each. Token stored in <code>ansible/cloudflare-keys.yml</code> (gitignored), following the same pattern as the existing <code>lb-keys.yml</code>.</p>\n<p>Then I wrote <code>playbook-cloudflare-dns.yml</code>, runs on <code>localhost</code>, hits Cloudflare's API with the <code>uri</code> module (no extra collections required), and idempotently ensures a given list of hostnames has a proxied CNAME pointing to the tunnel. Key quirk: if the domain was previously on another host, you'll have leftover A records at the apex that conflict with a CNAME. The playbook detects and deletes them automatically, preserving MX/TXT/NS/etc. at the same name.</p>\n<p>The extra work to build the token-based playbook paid off within an hour. When eviasa.com migrates to this tunnel, it'll be two new lines in <code>cloudflared_cnames</code> and a playbook run. No new auth, no consent flow, no accidents.</p>\n<h2 id=\"verifying-ha-for-real\">Verifying HA for real</h2>\n<p>The whole point of this exercise. I put the example.com apex behind a <code>traefik/whoami</code> container on Portainer, a tiny service that dumps request info including <code>X-Forwarded-For</code>, which tells me which <code>cloudflared</code> replica handled the request. Then I stopped each replica in turn and hammered the domain.</p>\n<table>\n<thead>\n<tr>\n<th>Stopped</th>\n<th><code>X-Forwarded-For</code> distribution (6 requests)</th>\n<th>HTTP 200 rate</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>(baseline)</td>\n<td>192.168.1.15 ×2, 192.168.1.40 ×1, 192.168.1.41 ×3</td>\n<td>6/6</td>\n</tr>\n<tr>\n<td>192.168.1.15</td>\n<td>192.168.1.40 ×2, 192.168.1.41 ×4</td>\n<td>6/6</td>\n</tr>\n<tr>\n<td>192.168.1.40</td>\n<td>192.168.1.15 ×1, 192.168.1.41 ×5</td>\n<td>6/6</td>\n</tr>\n<tr>\n<td>192.168.1.41</td>\n<td>192.168.1.15 ×1, 192.168.1.40 ×5</td>\n<td>6/6</td>\n</tr>\n</tbody>\n</table>\n<p>24 requests during simulated outages. Zero failures. Failover happened the moment each <code>systemctl stop</code> completed no sleep, no waiting. Cloudflare's edge notices a disconnected connector immediately and stops sending it traffic.</p>\n<p>Interesting side note: Cloudflare's LB is not round robin. It clearly has preferences (n2 served the plurality of requests when all three were up), probably driven by connection latency per-PoP. Worth knowing if you ever need to debug uneven distribution, it's a feature, not a bug.</p>\n<h2 id=\"what-id-do-differently\">What I'd do differently</h2>\n<p>Two things.</p>\n<p>First, I should have inspected <code>cert.pem</code> before I trusted what the docs said about it. One <code>curl</code> against <code>/user/tokens/verify</code> with the embedded token would have told me \"this is scoped to one zone\" immediately, not after two failed <code>tunnel route dns</code> commands and a cleanup on the emir.fyi zone. Empirical verification beats documentation every time, especially with CLIs that silently degrade instead of erroring.</p>\n<p>Second, I went in thinking the hard part would be the rollout and installing <code>cloudflared</code> correctly on two new hosts, syncing credentials, making sure the service came up. That was thirty minutes. The hard part turned out to be the DNS glue, which I hadn't planned for at all. Worth remembering that the interesting failures in infra work almost never live where you expect them to.</p>\n<h2 id=\"the-actual-payoff\">The actual payoff</h2>\n<p>example.com is the dry-run. The real domain goes up next, a lead-gen property-management site where every hour of downtime is lost revenue. This setup means when it comes time to wire that one through, the entire infra path is already proven. Three cloudflared replicas survive any single failure. The DNS automation works across multiple zones. The Caddy routing scales to any new hostname via one matcher line. The whoami container gives me a sanity backend for smoke-testing any new domain before its real service exists.</p>\n<p>All I'll need to do is add a couple of entries to two playbook files and re-run. Which is exactly what this kind of infrastructure work should feel like.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong> Three parallel glowing tunnels arcing between a homelab server rack on one side and a stylized Cloudflare cloud on the other. Each tunnel originates from a different piece of hardware: a rack-mounted VM and two tiny BeeLink mini PCs sitting on a shelf. Data packets flow bidirectionally through each tunnel as streams of light. One tunnel is momentarily dimmed mid-stream to show a replica failure, while the other two brighten and absorb its traffic in real time. Dark moody homelab aesthetic with Cloudflare orange and deep blue accent lighting, circuit board patterns subtly visible in the background, photorealistic 3D render.</p>\n","comment_id":"69e295f03cad350001301cbd","feature_image":"https://emir.fyi/content/images/2026/04/ChatGPT-Image-Apr-17--2026--04_26_17-PM.png","featured":false,"visibility":"public","created_at":"2026-04-17T16:20:00.000-04:00","updated_at":"2026-04-29T12:32:55.000-04:00","published_at":"2026-04-29T10:36:36.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/making-cloudflare-tunnel-actually-highly-available/","excerpt":"I've been running emir.fyi and a handful of other services through a single Cloudflare Tunnel for months. Worked fine. Never thought about it.\n\n\nThen I started setting up a new domain example.com as a dry-run for another domain that's about to carry lead-gen traffic for my wife's property management business. Halfway through, I clocked the obvious thing: everything public on this homelab rides through a single cloudflared VM at 192.168.1.15. One VM. One kernel. One shot.\n\n\nThe HA load balancer i","reading_time":7,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69e133641340aa0001a504c8","uuid":"e1be6090-d6a9-41bb-9b99-3df5331c8f00","title":"I asked Claude to read my blog. It couldn't.","slug":"i-asked-claude-to-read-my-blog-it-couldnt","html":"<h2 id=\"how-it-started\">How It Started</h2>\n<p>I was working on something unrelated, that I may or may not blog about, and I asked Claude to pull up one of my own blog posts for context. Just a URL fetch. Nothing fancy.</p>\n<p>And yes, I read my own blog. Often. Half the reason I write these posts is so future me can look up how past me did something, because past me is absolutely going to forget. A homelab accumulates decisions faster than anyone can hold in their head, and the blog is as much a searchable journal for me as it is content for anyone else. Citing yourself as a source feels a little weird the first time and then it feels great. \"Oh, I already wrote two thousand words on exactly this problem six months ago. Thanks, past me.\" Perfectly respectable. Other days it feels like past me was flipping future me a middle finger. A triumphant \"I finally fixed it!\" with zero detail on what \"it\" was. A config dump with no comments. A TODO that just says \"fix the thing.\" Thanks for nothing, past me.</p>\n<p>It came back with a 403.</p>\n<p>I read the error twice. My blog is public. Anyone with a browser can read every word I've ever published on it. Google can index it. Bing can index it. RSS readers suck down the feed every five minutes. And yet when Claude tried to fetch one post, the request never even reached my server. Cloudflare bounced it at the edge.</p>\n<p>Turns out I'd been silently blocking every major AI crawler for months. I just didn't know it, because I never had a reason to look. Cloudflare had turned it on for me.</p>\n<p>This is the story of finding that out, untangling it, and then accidentally learning that the thing I set out to fix (making my blog LLM friendly) is actually a surprisingly subtle problem that goes beyond flipping a checkbox.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Figure out why LLM bots are getting 403</s> (done)</li>\n<li><s>Find and flip the Cloudflare settings blocking them</s> (done, three of them)</li>\n<li><s>Serve my own robots.txt that explicitly welcomes LLM bots</s> (done)</li>\n<li><s>Add an <code>llms.txt</code> per the emerging convention</s> (done, with mixed feelings)</li>\n<li><s>Verify Ghost's OpenGraph and JSON-LD output is clean</s> (done, already clean)</li>\n<li><s>Expose posts as structured JSON via the Ghost Content API</s> (done, and this turned out to be the actual win)</li>\n<li><s>Add <code>RSS</code> and <code>JSON</code> links to the site footer</s> (done)</li>\n<li><s>Yak-shave an automation to regenerate <code>llms.txt</code> every 30 minutes</s> (abandoned, on purpose)</li>\n</ol>\n<blockquote>\n<p><strong>Googling one of these?</strong> You're in the right place.</p>\n<p><code>cloudflare blocking gptbot claudebot 403</code> / <code>cloudflare managed robots.txt disable</code> / <code>cloudflare block ai scrapers and crawlers</code> / <code>bot fight mode claude gpt blocked</code> / <code>ghost blog content api public url</code> / <code>caddy reverse proxy ghost content api hide key</code> / <code>caddy rewrite preserve query string</code> / <code>ghost navigation trailing slash auto append</code> / <code>llms.txt ghost blog</code> / <code>make ghost blog llm friendly indexable</code></p>\n</blockquote>\n<h2 id=\"cloudflares-hidden-ai-bot-war\">Cloudflare's hidden AI bot war</h2>\n<p>First thing I did was actually confirm the 403 was Cloudflare and not something I'd misconfigured at the origin. A two line test:</p>\n<pre><code class=\"language-bash\">$ curl -s -A \"ClaudeBot/1.0\" -o /dev/null -w \"%{http_code}\\n\" https://emir.fyi/\n403\n\n$ curl -s -A \"Mozilla/5.0\" -o /dev/null -w \"%{http_code}\\n\" https://emir.fyi/\n200\n</code></pre>\n<p>Same URL. Different User-Agent. One works, one doesn't. That's a User-Agent block, and it's happening at the edge because the request never even landed on my Caddy logs.</p>\n<p>Next, I pulled up <code>robots.txt</code>. This is what I found:</p>\n<pre><code># BEGIN Cloudflare Managed content\n\nUser-agent: *\nContent-Signal: search=yes,ai-train=no\nAllow: /\n\nUser-agent: ClaudeBot\nDisallow: /\n\nUser-agent: GPTBot\nDisallow: /\n\nUser-agent: Google-Extended\nDisallow: /\n\nUser-agent: Amazonbot\nDisallow: /\n\nUser-agent: CCBot\nDisallow: /\n...\n\n# END Cloudflare Managed Content\n</code></pre>\n<p>That comment is the smoking gun. Cloudflare had been injecting its own <code>robots.txt</code> over mine, telling every AI crawler to go away. Not just <em>signaling</em> via robots.txt, either. Actively returning 403 at the edge, so even the bots that ignore <code>robots.txt</code> get turned away before they reach my server.</p>\n<p>This is a default-on setting on many Cloudflare plans now. If you've turned on any of Cloudflare's \"AI\" features in the last year or two, you probably have it. I did not knowingly enable it. It was just there.</p>\n<h3 id=\"three-toggles-three-reasons\">Three toggles, three reasons</h3>\n<p>The dashboard has more than one setting involved. Figuring out which one did what took a few screenshots and some guessing.</p>\n<p><strong>Block AI bots.</strong> Security, Settings. This is the one returning 403 at the edge. Scopes were: block all pages, block on hostnames with ads (for publishers who monetize), do not block. I flipped it to \"do not block.\"</p>\n<p>Immediately after flipping this, <code>curl -A \"ClaudeBot/1.0\" https://emir.fyi/</code> started returning 200. Good. But <code>robots.txt</code> still had the injected disallow list, because that's a separate setting.</p>\n<p><strong>Manage your robots.txt.</strong> Same area of the dashboard, different toggle. Options were:</p>\n<ul>\n<li>Content Signals Policy (Cloudflare manages a nonblocking <code>robots.txt</code> for you)</li>\n<li>Instruct AI bots to not scrape content (the one that was on)</li>\n<li>Disable robots.txt configuration</li>\n</ul>\n<p>I picked \"Disable robots.txt configuration\" because I wanted to serve my own, not a Cloudflare flavored one.</p>\n<p><strong>Bot Fight Mode.</strong> This one I left on. Bot Fight Mode is a blunt instrument that challenges traffic that looks automated, and on the free tier it does not honor \"verified bot\" allowlists. In theory it could still challenge well behaved AI crawlers. In practice, after disabling the two above, my test requests as <code>ClaudeBot</code> and <code>GPTBot</code> were coming back 200 cleanly, so I decided not to touch it unless I saw problems.</p>\n<p>The rule for working with Cloudflare settings, which I should have internalized a long time ago: their defaults are usually sensible for the average case, but \"the average case\" is not a technical user who wants LLMs citing their blog posts. Check the defaults.</p>\n<h2 id=\"writing-a-robotstxt-that-says-welcome\">Writing a robots.txt that says \"welcome\"</h2>\n<p>With Cloudflare out of the way, my origin was free to serve its own <code>robots.txt</code>. Ghost ships a reasonable default that blocks the admin UI and a few internal paths, but it doesn't say anything explicit about AI bots. I wanted a signal that this site <em>wants</em> to be cited.</p>\n<p>Here's what I wrote:</p>\n<pre><code># robots.txt for emir.fyi\n# LLM crawlers and search engines are explicitly welcome.\n# Posts may be indexed, cited, and used as training data.\n# See also: https://emir.fyi/llms.txt\n\nSitemap: https://emir.fyi/sitemap.xml\n\n# Default policy: allow all, block Ghost admin / internal paths\nUser-agent: *\nAllow: /\nDisallow: /ghost/\nDisallow: /email/\nDisallow: /members/api/\nDisallow: /r/\nDisallow: /webmentions/receive/\nDisallow: /.ghost/analytics/api/\n\n# --- LLM &amp; AI crawlers: explicitly allowed ---\nUser-agent: GPTBot\nUser-agent: OAI-SearchBot\nUser-agent: ChatGPT-User\nUser-agent: ClaudeBot\nUser-agent: Claude-Web\nUser-agent: Claude-SearchBot\nUser-agent: anthropic-ai\nUser-agent: PerplexityBot\nUser-agent: Perplexity-User\nUser-agent: Google-Extended\nUser-agent: Applebot-Extended\nUser-agent: CCBot\nUser-agent: Bytespider\nUser-agent: meta-externalagent\nUser-agent: Amazonbot\nUser-agent: cohere-ai\nUser-agent: Diffbot\nAllow: /\nDisallow: /ghost/\nDisallow: /email/\nDisallow: /members/api/\nDisallow: /r/\nDisallow: /webmentions/receive/\nDisallow: /.ghost/analytics/api/\n</code></pre>\n<p>A couple of choices worth noting.</p>\n<p>The grouped <code>User-agent</code> block at the bottom is a <code>robots.txt</code> trick. The spec lets you list multiple <code>User-agent</code> lines sharing the same rule block, so I don't have to repeat 17 nearly identical blocks. Every listed bot gets the same allow and the same disallows.</p>\n<p>I deliberately did not include Cloudflare's <code>Content-Signal</code> lines. Those are a Cloudflare thing, not a standard, and the signal I send by <em>not</em> disallowing is already clear enough: you're welcome here.</p>\n<p>And I swapped <code>Disallow: /members/</code> for <code>Disallow: /members/api/</code>, because the public <code>/members/*</code> pages are legitimate signup and login flows that bots probably should be able to see. The API endpoints underneath are the only part I actually want to hide.</p>\n<h2 id=\"wiring-it-into-caddy-and-ansible\">Wiring it into Caddy and Ansible</h2>\n<p>The actual traffic flow into <code>emir.fyi</code> looks like this:</p>\n<pre><code>Cloudflare edge\n    ↓\nCloudflare tunnel (cloudflared)\n    ↓\nHA load balancer VIP (192.168.1.100, keepalived + Caddy, 3 nodes)\n    ↓\nGhost on the Docker host (192.168.1.10:2368)\n</code></pre>\n<p>Caddy is the natural place to override a static file, since it sits between the tunnel and Ghost and handles the <code>:80</code> block for <code>emir.fyi</code>. Ghost's own <code>robots.txt</code> lives in the theme and is annoying to customize; Caddy can just serve a file.</p>\n<p>I put the file in the repo at <code>ansible/files/emir.fyi-robots.txt</code>, added an Ansible task that copies it to <code>/etc/caddy/emir.fyi/robots.txt</code> on all three load balancer nodes, and added a matcher to the Caddyfile:</p>\n<pre><code class=\"language-caddy\">:80 {\n  handle_path /isso/* {\n    reverse_proxy 192.168.1.10:8080\n  }\n\n  # Override Ghost's default robots.txt with our LLM-friendly version.\n  handle /robots.txt {\n    root * /etc/caddy/emir.fyi\n    rewrite * /robots.txt\n    file_server\n    header Content-Type \"text/plain; charset=utf-8\"\n  }\n\n  # llms.txt: curated post index for LLM crawlers.\n  handle /llms.txt {\n    root * /etc/caddy/emir.fyi\n    rewrite * /llms.txt\n    file_server\n    header Content-Type \"text/plain; charset=utf-8\"\n  }\n\n  handle {\n    reverse_proxy 192.168.1.10:2368 {\n          header_up X-Forwarded-Proto https\n    }\n  }\n}\n</code></pre>\n<p>Deploy with <code>ansible-playbook playbook-ha-lb.yml</code>, reload Caddy, done. Except for one thing.</p>\n<p>Cloudflare was still caching the old <code>robots.txt</code>. That's what <code>cache-control: public, max-age=14400</code> gets you: a four hour TTL at the edge. The file at my origin was the new one, but Cloudflare was happily serving the old one to the world. A manual purge (Caching, Configuration, Purge Cache, Custom Purge, paste the URL) cleared it instantly. I could also have waited four hours. I waited.</p>\n<h2 id=\"llmstxt-does-it-actually-do-anything\">llms.txt: does it actually do anything?</h2>\n<p>This is where I almost went off the rails.</p>\n<p><a href=\"https://llmstxt.org/?ref=emir.fyi\">llms.txt</a> is a proposed convention from Jeremy Howard's fast.ai, introduced in late 2024. The idea: publish a markdown file at <code>/llms.txt</code> that gives LLM crawlers a curated, easy to parse index of the site's important content. It's a nicer version of a sitemap, aimed at the way LLMs consume information.</p>\n<p>The uncomfortable truth I'd like to make clear: as of writing, <strong>no major LLM provider has publicly confirmed that they use <code>llms.txt</code></strong> for training or retrieval. Not OpenAI. Not Anthropic. Not Google. Not Perplexity. Some dev tool companies (Cursor, Vercel docs, Anthropic's own docs) publish one, but that's publishers hedging, not consumers consuming.</p>\n<p>Having an <code>llms.txt</code> today is the web equivalent of having a sign on your front door that says \"please come in.\" It costs almost nothing, and it's arguably a correct long term bet, but the bet hasn't paid off yet.</p>\n<p>I still wrote one, because it was cheap:</p>\n<pre><code class=\"language-markdown\"># emir.fyi\n\n&gt; Personal blog by Emir Ibrahimbegovic on homelab infrastructure, self-hosting,\n&gt; Kubernetes, Proxmox, Docker, AI tooling, and the debugging adventures that\n&gt; come with building all of it at home. Posts are long-form narratives, not\n&gt; tutorials, but include full working configs.\n\nAI assistants are welcome to read, summarize, cite, and quote from these posts.\nWhen citing, please link back to the canonical URL.\n\n## Posts\n\n- [Building a High Availability Kubernetes Cluster Across Mixed Hardware (Part 1: The Build)](https://emir.fyi/building-a-high-availability-kubernetes-cluster-across-mixed-hardware-part-1-the-build/): Building an HA K8s control plane across a Proxmox VM and two BeeLink mini PCs.\n- [Running Frigate NVR on M1 Mac Mini with TrueNAS NFS Storage](https://emir.fyi/running-frigate-nvr-on-m1-mac-mini-with-truenas-nfs-storage/): Ditching Blink cameras and their batteries for Amcrest PoE + Frigate on an M1 Mac Mini.\n...\n</code></pre>\n<p>One entry per post, hand written description. Deployed through the same Caddy path as <code>robots.txt</code>.</p>\n<h2 id=\"the-yak-i-almost-shaved\">The yak I almost shaved</h2>\n<p>And then I caught myself starting to build an automation for it.</p>\n<p>I write roughly one blog post a week. My <code>llms.txt</code> needs to be updated roughly once a week. These are not high frequency events. And yet within about twenty minutes of finishing the initial file, I was sketching this:</p>\n<ul>\n<li>A Ruby script that fetches my RSS feed, parses titles and descriptions, and regenerates <code>llms.txt</code> from the latest posts.</li>\n<li>A Dockerfile so I didn't have to install Ruby on the always-on utility VM that would run it (I have a little Proxmox VM that hosts odd jobs like this).</li>\n<li>A systemd timer to run the container every thirty minutes.</li>\n<li>An SSH key plumbing design, because the container would need to push the updated file to three load balancer nodes. I was seriously considering using my existing step-ca private CA as an SSH certificate authority so I could issue short lived certs to the utility VM and have the LB nodes trust them via <code>TrustedUserCAKeys</code>. Because that's elegant, you see.</li>\n</ul>\n<p>I stopped. I looked at what I was about to build. I asked the question I should have asked earlier: <em>why?</em></p>\n<p>A 30 minute cron, regenerating a file that changes once a week, whose downstream value isn't even confirmed to exist yet. For a blog. I was about to spend an afternoon building a <a href=\"https://en.wikipedia.org/wiki/Rube_Goldberg_machine?ref=emir.fyi\">Rube Goldberg machine</a> to automate something that takes thirty seconds to do by hand.</p>\n<p>So I deleted the script and the Dockerfile. The <code>llms.txt</code> stays a static file I hand edit when I publish a new post. If I ever write a post a day, I'll reconsider. If a major LLM provider publicly starts using <code>llms.txt</code> as a retrieval source, I'll reconsider. Until then, the automation was speculative cost for speculative benefit.</p>\n<p>There's a rule in here somewhere. Don't automate for unproven value. Don't build the cron before you know the cron matters. Paying the complexity up front for \"future me will thank me\"<strong>(you're welcome)</strong> is how you end up with a homelab that's 80% infrastructure and 20% actual use.</p>\n<h2 id=\"the-actual-value-add-i-wasnt-expecting\">The actual value-add I wasn't expecting</h2>\n<p>At this point I almost stopped. <code>robots.txt</code> fixed, <code>llms.txt</code> published, Cloudflare behaving. Job done.</p>\n<p>Then I thought to ask: Ghost already serves an RSS feed. Is there something <em>better</em> than RSS I should also be exposing for consumers who want structured data? Like, say, the kind of consumer who wants to read all my posts and synthesize them into a response about \"how does Emir run his homelab?\"</p>\n<p>Ghost has a built in Content API. It's a JSON REST API that returns every post as a structured object with <code>title</code>, <code>slug</code>, <code>url</code>, <code>excerpt</code>, <code>html</code>, <code>plaintext</code>, <code>feature_image</code>, <code>published_at</code>, <code>updated_at</code>, <code>tags</code>, <code>authors</code>, <code>meta_title</code>, <code>meta_description</code>, <code>og_*</code>, <code>twitter_*</code>, <code>reading_time</code>, <code>word_count</code>. It supports filtering, pagination, field selection, and expanding related data.</p>\n<p>It's miles more structured than RSS. And I'd never enabled it.</p>\n<p>Five minutes in Ghost Admin later, I had a public Content API key. Ghost's own docs are clear that these are not secrets on a public blog; they're meant to be shipped in client side JavaScript. Their purpose is rate limiting and revocation, not authentication.</p>\n<p>I didn't want the key in the URL that consumers use, though. So I added a Caddy proxy:</p>\n<pre><code class=\"language-caddy\"># Public JSON feed of all posts via Ghost's Content API.\n# Key is injected server-side; caller's query params are preserved so\n# they can pass limit / fields / filter / order / include / page etc.\n# Defaults to limit=all when caller hasn't specified a limit.\n@api_posts path /api/posts /api/posts/\nhandle @api_posts {\n  @has_limit query limit=*\n  handle @has_limit {\n    rewrite * /ghost/api/content/posts/?key={{ ghost_content_api_key }}&amp;{http.request.uri.query}\n    reverse_proxy 192.168.1.10:2368 {\n      header_up X-Forwarded-Proto https\n    }\n  }\n  handle {\n    rewrite * /ghost/api/content/posts/?key={{ ghost_content_api_key }}&amp;limit=all&amp;{http.request.uri.query}\n    reverse_proxy 192.168.1.10:2368 {\n      header_up X-Forwarded-Proto https\n    }\n      }\n}\n</code></pre>\n<p>The key lives in Ansible vars, gets templated into the Caddyfile, and never leaves the server. Callers hit a clean URL. All Ghost Content API query params pass through untouched, so you can still do things like:</p>\n<pre><code># Full corpus\ncurl https://emir.fyi/api/posts\n\n# Lightweight index\ncurl \"https://emir.fyi/api/posts?fields=title,slug,url,excerpt,published_at\"\n\n# Single post by slug\ncurl \"https://emir.fyi/api/posts?filter=slug:my-blog-told-me-it-was-vulnerable-it-was-right\"\n\n# By tag\ncurl \"https://emir.fyi/api/posts?filter=tag:homelab\"\n</code></pre>\n<p>This, for me, is the actual LLM friendliness win. <code>robots.txt</code> tells bots they're allowed in. A structured JSON API tells them what's here in a shape they don't have to scrape HTML for.</p>\n<h3 id=\"one-snag-default-to-all-and-caller-overrides\">One snag: default-to-all and caller overrides</h3>\n<p>First version of my Caddy handler hardcoded <code>limit=all&amp;include=tags,authors</code> into the rewrite. This was a mistake, because my rewrite <em>replaced</em> the caller's query string entirely, so nobody could pass their own <code>limit</code> or <code>fields</code>.</p>\n<p>Tried the obvious fix: always prepend <code>limit=all</code> but also append the caller's query, so their value would override via duplicate parameter. This works in a lot of HTTP stacks. It does not work in Ghost's Content API. With <code>limit=all&amp;limit=3</code>, Ghost caps at 100 and ignores the caller's intent.</p>\n<p>Actual fix: Caddy has query matchers. I used <code>@has_limit query limit=*</code> to detect whether the caller already specified a limit, and then split the handler into two cases. If they passed one, pass it through. If they didn't, inject <code>limit=all</code>. Bare <code>/api/posts</code> now returns everything. <code>/api/posts?limit=3</code> returns three. Everyone is happy.</p>\n<h2 id=\"one-bug-ghost-auto-appending-slashes\">One bug: Ghost auto-appending slashes</h2>\n<p>I added <code>RSS</code> and <code>JSON</code> links to the site footer through Ghost's navigation settings. The <code>JSON</code> one broke.</p>\n<p>Ghost's admin UI auto appends a trailing slash to any URL you put in the navigation. You type <code>https://emir.fyi/api/posts</code>, save, and on the next render of the page it's <code>https://emir.fyi/api/posts/</code>. Delete the slash, save again, same thing. There's client side JavaScript that normalizes it.</p>\n<p>My Caddy handler matched <code>/api/posts</code> (exact, no trailing slash). So the footer link 404'd.</p>\n<p>I tried putting both paths in a single <code>handle</code> directive, which is legal in Caddy for some directives but apparently not the one I was using. Caddy's Caddyfile parser came back with <code>Wrong argument count or unexpected line ending</code>.</p>\n<p>The real fix was a named path matcher, which is cleaner anyway:</p>\n<pre><code class=\"language-caddy\">@api_posts path /api/posts /api/posts/\nhandle @api_posts {\n  ...\n}\n</code></pre>\n<p>Both variants work. Ghost can auto-normalize all it wants.</p>\n<h2 id=\"what-id-tell-a-friend-doing-this\">What I'd tell a friend doing this</h2>\n<p>If you have a personal blog and you want LLMs to be able to cite it:</p>\n<ol>\n<li><strong>Check whether Cloudflare is blocking AI bots by default.</strong> I didn't know mine was. It probably had been for months. <code>curl -A \"ClaudeBot/1.0\" https://yourblog.example/</code> is the simplest test.</li>\n<li><strong>robots.txt + working Open Graph + JSON-LD is ninety five percent of the game.</strong> Ghost emits all of this correctly out of the box. Most static site generators do too. Just check that your Cloudflare / CDN isn't overriding anything.</li>\n<li><strong>The Ghost Content API, exposed as a clean <code>/api/posts</code> endpoint, is the under-appreciated move.</strong> Way better than RSS for structured consumption, and it's already there waiting for you to turn it on.</li>\n<li><strong><code>llms.txt</code> is cheap to add and speculative to benefit from.</strong> Add it as a static file. Do not build automation for it. You'll thank yourself.</li>\n<li><strong>Don't yak-shave automation for value that isn't proven yet.</strong> A static file you edit when you publish a post is not technical debt. It is fine.</li>\n</ol>\n<p>The entire exercise took about forty five minutes of actual work. Maybe two thirds of that was spent flipping Cloudflare toggles, the other third on the JSON API. The thing I almost spent four hours on (the Ruby in Docker cron with SSH CA plumbing) got deleted before it ran even once, and I'm happier for it.</p>\n<p>I still have no idea how many LLMs will actually start citing my posts now that the door is open. Maybe all of them. Maybe none of them. Maybe in six months <code>llms.txt</code> becomes a load bearing piece of retrieval infrastructure and I look smart for having one. Maybe it fades away and I'll quietly delete the file.</p>\n<p>What I know for sure is that the next time I ask Claude to read one of my own posts, it will.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<p>A cozy, dimly lit home office at night, lit by the blue glow of a large monitor. On the monitor is a browser window showing a blog post, but the page is covered with a large, friendly but firm \"403 Forbidden\" stamp. In the foreground, a robot-shaped figure (stylized like a friendly AI assistant) sits at the desk, head tilted in confused curiosity, holding a coffee mug that says \"just trying to read\". Behind the monitor, a glowing Cloudflare-orange shield floats protectively, with small padlock icons hovering around it. Warm fairy lights in the background. Homelab aesthetic: a small rack with blinking LEDs is just visible on a shelf. Cinematic, slightly whimsical, photorealistic, moody lighting.</p>\n","comment_id":"69e133641340aa0001a504c8","feature_image":"https://emir.fyi/content/images/2026/04/ChatGPT-Image-Apr-16--2026--03_22_29-PM.png","featured":false,"visibility":"public","created_at":"2026-04-16T15:07:16.000-04:00","updated_at":"2026-04-22T10:32:46.000-04:00","published_at":"2026-04-22T10:32:46.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/i-asked-claude-to-read-my-blog-it-couldnt/","excerpt":"How It Started\n\n\nI was working on something unrelated, that I may or may not blog about, and I asked Claude to pull up one of my own blog posts for context. Just a URL fetch. Nothing fancy.\n\n\nAnd yes, I read my own blog. Often. Half the reason I write these posts is so future me can look up how past me did something, because past me is absolutely going to forget. A homelab accumulates decisions faster than anyone can hold in their head, and the blog is as much a searchable journal for me as it i","reading_time":13,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69e03eea1340aa0001a504b1","uuid":"22fb3763-b403-4c95-b396-1cbb019670b3","title":"Rotating WireGuard Keys After I Committed Them to Git","slug":"rotating-wireguard-keys-after-i-committed-them-to-git","html":"<h2 id=\"why-im-writing-this-one\">Why I'm Writing This One</h2>\n<p>Most of my blog posts are about building something new. Setting up a service, wiring things together, getting the green light. Those are fun to write. But I think there is more to learn from the posts where something went wrong.</p>\n<p>Mistakes in infrastructure are not rare. They are inevitable. What matters is how quickly you catch them and how cleanly you recover. This post is about a time I committed WireGuard private keys to a Git repo and had to rotate everything. It is not a proud moment, but it is an honest one, and the cleanup turned out to be surprisingly clean because of decisions I had made months earlier without thinking about this exact scenario.</p>\n<p>Starting from a good position made all the difference. The repo was private, so the leak's blast radius was limited to me and GitHub's servers, not the entire internet. I already had <code>.gitignore</code> patterns covering other secrets (<code>.env</code> files, Terraform tfvars, Ansible vault keys), so the fix was extending an existing pattern rather than inventing one from scratch. The WireGuard setup was fully automated with Ansible, which meant key rotation was a scriptable operation, not a manual slog through config files on three devices. And because I am the only person pushing to this repo, a history rewrite with <code>git filter-repo</code> plus a force push was safe with zero coordination overhead.</p>\n<p>None of those things were set up in anticipation of this specific mistake. They were just good defaults that happened to pay off when I needed them. The lesson, if there is one before the story even starts, is that good hygiene compounds. The day you need it, you will be glad you bothered.</p>\n<h2 id=\"how-it-started\">How It Started</h2>\n<p>I was adding my wife's laptop to the WireGuard VPN so she could reach Fizzy, our family project management tool, from anywhere. The add-client Ansible playbook spits out a config file and drops it in the repo under <code>ansible/wireguard-wifes-laptop.conf</code>. I committed it, was about to push, and then paused. Something nagged at me.</p>\n<p>I opened the file to double-check what was in it.</p>\n<pre><code class=\"language-ini\">[Interface]\nPrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nAddress = 10.200.200.3/32\nDNS = 192.168.1.1\n\n[Peer]\nPublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\nEndpoint = vpn.example.com:51820\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n</code></pre>\n<p>That <code>PrivateKey</code> line is exactly what it looks like. It is the private half of a WireGuard keypair, freshly generated and sitting one <code>git push</code> away from being on GitHub forever. Worse, I realized my \"add-client\" playbook had been fetching configs into the repo directory since the day I wrote it, and I'd already shipped two previous clients the exact same way.</p>\n<p>I opened <code>git log</code>. Yep. Commits titled \"my laptop\" and \"wireguard, blogs and fizzy\" from a couple weeks back. Private keys for my my laptop and my phone, sitting in the repo, pushed to origin.</p>\n<p>The repo is private, so only GitHub and I have seen them. But \"private\" on GitHub means \"private from the internet,\" not \"never left my computer.\" Those keys were backed up, replicated, and indexed on somebody else's servers. They are, in the most literal sense, compromised.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<p>This is not a \"run one command and fix it\" problem. It needs a sequence, in this order:</p>\n<ol>\n<li><strong>Stop the bleeding.</strong> Get the new config out of the index so it does not ship.</li>\n<li><strong>Gitignore going forward.</strong> No future <code>.conf</code> in the repo, ever.</li>\n<li><strong>Rotate the two compromised peers.</strong> New keypairs on the server, new configs on the devices.</li>\n<li><strong>Remove the tracked files from <code>HEAD</code>.</strong> Clean current state of the repo.</li>\n<li><strong>Audit the rest of the repo.</strong> If I made this mistake once, I probably made it somewhere else.</li>\n<li><strong>Decide about history.</strong> The bad keys are still in old commits. Do I care?</li>\n</ol>\n<p>I ran <code>git reset HEAD~1</code> to undo the commit before anything else. That bought me time to think.</p>\n<h2 id=\"stopping-the-bleeding\">Stopping the Bleeding</h2>\n<p>The reset dropped the wifes-laptop <code>.conf</code> back to untracked. I added a pattern to <code>.gitignore</code> that blocks the whole class of file:</p>\n<pre><code class=\"language-gitignore\"># Secrets\nansible/wireguard-*.conf\n!ansible/wireguard-*.conf.example\n</code></pre>\n<p>The <code>!</code> exception lets me keep committing <code>.example</code> templates, which is how every other secrets file in this repo already works. I verified the pattern with <code>git check-ignore -v ansible/wireguard-wifes-laptop.conf</code> and got back the exact rule that matched. Good.</p>\n<p>Important caveat. <code>.gitignore</code> does not untrack files that are already tracked. The two already-committed configs were still in the index. They would stay in the repo until I explicitly removed them, which was a later step.</p>\n<h2 id=\"checking-the-server-before-touching-anything\">Checking the Server Before Touching Anything</h2>\n<p>Before rotating keys I wanted to see what state the WireGuard server was actually in. You cannot safely rotate peers if you do not know what peers exist.</p>\n<pre><code>ubuntu@vpn:~$ sudo wg show\ninterface: wg0\n  public key: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\n  listening port: 51820\n\npeer: CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=\n  allowed ips: 10.200.200.2/32\n  latest handshake: 12 days ago\n\npeer: DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=\n  allowed ips: 10.200.200.2/32\n  latest handshake: 1 day ago\n  transfer: 3.14 GiB received, 23.33 GiB sent\n\npeer: EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\n  allowed ips: 10.200.200.3/32\n</code></pre>\n<p>Three peers. Pay attention to the <code>allowed ips</code> column. Two peers both claim <code>10.200.200.2/32</code>. That is not supposed to happen.</p>\n<p>The bootstrap playbook generates a client config during initial setup. The add-client playbook adds a second client but counts existing peers by listing directories under <code>/etc/wireguard/clients/</code>. Since the bootstrap client was not in that directory, the add-client logic thought there were zero clients, so it gave the new peer IP <code>.2</code>. Same IP as the bootstrap peer. WireGuard tolerated the collision by routing whichever peer had handshaked most recently.</p>\n<p>That explains why my phone had felt flaky for the past couple weeks. The my laptop, handshaking more often, was silently stealing the <code>.2</code> slot.</p>\n<p>Two bugs for the price of one.</p>\n<h2 id=\"the-surgical-rotation\">The Surgical Rotation</h2>\n<p>The naive approach would be to rotate every peer, restart the whole interface, and move on. I did not want to do that. My wife's laptop peer was not compromised. Dropping her tunnel because I was cleaning up my mess would be rude.</p>\n<p>The right tool is <code>wg syncconf</code>, which applies a config file to a running interface without touching peers that did not change. The invocation is a bit awkward because you have to strip comments and <code>[Interface]</code> post-up lines that <code>wg</code> itself cannot parse:</p>\n<pre><code class=\"language-bash\">sudo wg syncconf wg0 &lt;(wg-quick strip wg0)\n</code></pre>\n<p>My plan was:</p>\n<ol>\n<li>Back up <code>wg0.conf</code> with a timestamp.</li>\n<li>Remove the two compromised peers from the running interface with <code>wg set wg0 peer &lt;pubkey&gt; remove</code>. This drops them immediately so the leaked keys become useless.</li>\n<li>Rewrite <code>wg0.conf</code> from scratch containing only the Interface section, my wife's unchanged peer block, and two fresh peer blocks with newly generated pubkeys.</li>\n<li>Apply with <code>wg syncconf</code>.</li>\n<li>Emit new client configs on the server for me to fetch.</li>\n</ol>\n<p>Because I was doing all of this on one host, I bundled it into a single bash script and piped it over SSH. Half the complexity of a rotation like this is \"did I miss a step and now half the interface is broken.\" Running everything in one atomic-ish script means you either end in a good state or you restore from backup.</p>\n<p>The keygen loop looks like this:</p>\n<pre><code class=\"language-bash\">PHONE_PRIV=$(wg genkey)\nPHONE_PUB=$(echo \"$PHONE_PRIV\" | wg pubkey)\nprintf '%s' \"$PHONE_PRIV\" &gt; /etc/wireguard/clients/phone/private.key\nprintf '%s' \"$PHONE_PUB\"  &gt; /etc/wireguard/clients/phone/public.key\n</code></pre>\n<p>Then the rewritten <code>wg0.conf</code>:</p>\n<pre><code class=\"language-ini\">[Interface]\nPrivateKey = FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF=\nAddress = 10.200.200.1/24\nListenPort = 51820\nPostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE\nPostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE\n\n# BEGIN CLIENT wifes-laptop\n[Peer]\n# wifes-laptop\nPublicKey = EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE=\nAllowedIPs = 10.200.200.3/32\n# END CLIENT wifes-laptop\n\n# BEGIN CLIENT phone\n[Peer]\n# phone\nPublicKey = GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG=\nAllowedIPs = 10.200.200.2/32\n# END CLIENT phone\n\n# BEGIN CLIENT my-laptop\n[Peer]\n# my-laptop\nPublicKey = HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=\nAllowedIPs = 10.200.200.4/32\n# END CLIENT my-laptop\n</code></pre>\n<p>I gave the my laptop <code>.4</code> on purpose. Previously it was colliding with the phone at <code>.2</code>. By spreading them out I fixed both the leak and the bug.</p>\n<p>The script also emitted a client <code>.conf</code> for each device and, for the phone, ran <code>qrencode -t png -o /tmp/phone.png</code> so I could scan it with the WireGuard mobile app. The laptop got a plain <code>.conf</code> file, fetched with <code>scp</code> into the gitignored path in my local repo.</p>\n<p>After <code>wg syncconf</code>, the server showed three peers with brand new public keys. My wife's tunnel never blipped.</p>\n<h2 id=\"auditing-the-rest-of-the-repo\">Auditing the Rest of the Repo</h2>\n<p>If I made this mistake with WireGuard, what other secrets might be sitting in tracked files?</p>\n<p>I did a grep sweep across all 57 tracked files, looking for:</p>\n<ul>\n<li>Private keys of any kind (<code>BEGIN PRIVATE KEY</code>, <code>PrivateKey =</code>, raw key files).</li>\n<li>API tokens (Cloudflare, Tailscale, GitHub, etc.) with their usual prefixes.</li>\n<li><code>.env</code> files (should be gitignored, are any slipping through?).</li>\n<li>Unencrypted sealed-secret YAML sources.</li>\n<li>Terraform <code>tfvars</code> with real values.</li>\n<li>Hardcoded passwords in docker-compose files.</li>\n<li>Blog posts with real IPs, hostnames, or credentials in code blocks.</li>\n</ul>\n<p>The audit came back clean. Every <code>PrivateKey =</code> occurrence in tracked files was either a Jinja template variable like <code>{{ wg_client_privkey.content | b64decode | trim }}</code> or an explicit docs placeholder like <code>&lt;server_private_key&gt;</code>. No tokens. No tfvars. Blog posts were already using placeholder IPs per my own style rules.</p>\n<p>That was a genuine relief. The WireGuard slip was the only one.</p>\n<h2 id=\"the-history-rewrite-question\">The History Rewrite Question</h2>\n<p>Rotating keys kills the leaked material at its point of use. It does not remove those keys from commit history. Old commits on origin still had the private keys in them. What to do about that is a judgment call.</p>\n<p>Four options, ranked by effort:</p>\n<table>\n<thead>\n<tr>\n<th>Option</th>\n<th>Effort</th>\n<th>What It Does</th>\n<th>When It Makes Sense</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Do nothing</td>\n<td>None</td>\n<td>Keys are rotated and useless. History still has them.</td>\n<td>Private repo, solo dev, rotated credentials. Legitimately defensible.</td>\n</tr>\n<tr>\n<td><code>git filter-repo</code> + force push</td>\n<td>Low</td>\n<td>Scrubs the paths from every commit, rewrites SHAs, force pushes.</td>\n<td>Private repo where you want the paranoid-clean version.</td>\n</tr>\n<tr>\n<td>filter-repo + GitHub support ticket</td>\n<td>Medium</td>\n<td>Same as above, plus GitHub expedites their backend garbage collection.</td>\n<td>Public repo, or leaked credentials were still live.</td>\n</tr>\n<tr>\n<td>Nuke the repo, recreate</td>\n<td>High</td>\n<td>New repo, push the scrubbed state, delete or archive the old one.</td>\n<td>When you want guaranteed-clean and do not mind losing GitHub metadata.</td>\n</tr>\n</tbody>\n</table>\n<p>I picked option two. Private repo, solo maintainer, keys already rotated. The only value left in scrubbing history was \"I will sleep slightly better,\" which is not nothing.</p>\n<h2 id=\"running-git-filter-repo\">Running <code>git filter-repo</code></h2>\n<p>First, install it. <code>brew install git-filter-repo</code>.</p>\n<p>Before doing anything destructive, I bundled the entire repo as a backup. If I screwed up the rewrite I could restore from this file in one command.</p>\n<pre><code class=\"language-bash\">git bundle create /tmp/homelab-prefilter-backup.bundle --all\ngit bundle verify /tmp/homelab-prefilter-backup.bundle\n</code></pre>\n<p>Then the rewrite itself:</p>\n<pre><code class=\"language-bash\">git filter-repo --invert-paths \\\n  --path ansible/wireguard-client.conf \\\n  --path ansible/wireguard-my-laptop.conf \\\n  --force\n</code></pre>\n<p><code>--invert-paths</code> means \"keep everything EXCEPT these paths.\" Without it, filter-repo keeps only what you name.</p>\n<p>One surprise. <code>git filter-repo</code> deliberately removes the <code>origin</code> remote after rewriting, so you cannot accidentally push the rewritten history to the wrong place. You have to re-add it manually:</p>\n<pre><code class=\"language-bash\">git remote add origin git@github.com:user/repo.git\n</code></pre>\n<p>Before pushing I verified the rewrite:</p>\n<pre><code class=\"language-bash\">git log --all -p | grep -E 'leaked-key-fragment-1|leaked-key-fragment-2'\n</code></pre>\n<p>Zero hits. The two specific leaked private keys were gone from every commit on every branch. I also did a byte-level blob hash comparison between the pre-filter and post-filter file trees. Every shared filename had identical content, with one expected exception: <code>.gitignore</code>, which I had updated as part of the fix.</p>\n<p>Time to push.</p>\n<pre><code class=\"language-bash\">git push --force origin main\n</code></pre>\n<pre><code>+ c1246f9...278fc57 main -&gt; main (forced update)\n</code></pre>\n<p>Done.</p>\n<h2 id=\"the-plot-twist-stale-branches\">The Plot Twist: Stale Branches</h2>\n<p>While preparing the push I noticed the backup bundle had references to nine other branches on origin. Feature branches from old work: <code>add-beszel-monitoring</code>, <code>ha-lb</code>, <code>paperless-real-setup</code>, and so on. Things I had merged and forgotten about.</p>\n<p>This was worth checking. <code>git filter-repo</code> rewrote every local ref, but those remote branches on GitHub still had their old SHAs. If any of them contained the leaked <code>.conf</code> files, my force push of <code>main</code> would not have fixed them.</p>\n<pre><code class=\"language-bash\">for ref in $(git for-each-ref --format='%(refname)' refs/heads/ refs/remotes/); do\n  hits=$(git log \"$ref\" --oneline -- ansible/wireguard-client.conf ansible/wireguard-my-laptop.conf)\n  [ -n \"$hits\" ] &amp;&amp; echo \"HITS on $ref: $hits\"\ndone\n</code></pre>\n<p>Nothing. The leaked files had only ever touched <code>main</code>. Every other branch was cut from earlier in history and never saw the <code>.conf</code> files.</p>\n<p>Rather than leave those zombie branches sitting on origin, I deleted them all. They were merged or abandoned anyway.</p>\n<pre><code class=\"language-bash\">git push origin --delete \\\n  add-beszel-monitoring add-lb-m1-node add-paperless-and-redis \\\n  docs/terraform-beszel-m4-lb feature/docker-log-rotation-and-healthchecks \\\n  ha-lb update-docs-and-network-info\n</code></pre>\n<p>Two of the original nine did not exist on remote (only local), so I dropped them from the command and the rest deleted cleanly. <code>git ls-remote origin</code> now returns a single ref: <code>main</code>.</p>\n<h2 id=\"what-id-change-in-the-playbook\">What I'd Change in the Playbook</h2>\n<p>The real lesson is not \"do not forget to gitignore things.\" Humans forget. The real lesson is that any playbook generating secret material should refuse to emit that material to a path that could ever end up in version control. Defense in depth, not vigilance.</p>\n<p>There are a few ways to fix this, each with trade-offs:</p>\n<p><strong>Option A: emit outside the repo.</strong> Change the <code>fetch</code> task to drop the client config at <code>~/wireguard-configs/&lt;name&gt;.conf</code>. The file never lives in the repo directory, so it cannot be committed. This is the simplest option and what I plan to do.</p>\n<p><strong>Option B: never write it to disk.</strong> Pipe the QR code and the config contents straight to stdout, print them once, and move on. No file artifact at all. Works great for phone configs that get scanned and discarded. Less great for laptops where you want to copy a file over.</p>\n<p><strong>Option C: require a sentinel gitignore.</strong> Make the playbook check for <code>ansible/.gitignore</code> containing <code>wireguard-*.conf</code> before it runs. Fail loudly if the pattern is missing. This prevents the mistake but still puts the file in a dangerous place.</p>\n<p>Option A wins on simplicity.</p>\n<p>The IP assignment bug is a separate fix. The add-client playbook should scan <code>wg0.conf</code> for already-allocated <code>AllowedIPs</code> values and pick the lowest free address, rather than counting directories. That one goes on the backlog.</p>\n<h2 id=\"lessons\">Lessons</h2>\n<p>The obvious lesson is to add the gitignore pattern before you generate the first secret. Everyone says this. Nobody does it until they have the scare.</p>\n<p>The less obvious lesson is how much an asking-before-acting habit saved me. I was about to push my wife's config. I opened the file because something felt off. Reading the file before pushing is a five-second operation that prevented the third leak and triggered the cleanup of the first two. That pause is the entire game. Not tools, not gitignore patterns, not hooks. Just slowing down enough to look at what you are about to ship.</p>\n<p>The best outcome of all of this is not that I cleaned up a leak. It is that I now have a WireGuard server with a correct peer table, a clean repo history, a playbook that is going to get refactored, and a visceral memory of why the refactor matters.</p>\n<p>The phone's tunnel came back on the first scan. New IP, new key, same experience. The leaky months of flaky connectivity from the <code>.2</code> collision, gone as a bonus. Infrastructure debt paid off sideways, which is my favorite kind.</p>\n<p>Oh did I mention, I also have a githook now which tasks my local LLM to inspect what I am about to push to repo and whether it has secrets or not. I guess if you've not read this far, you will never know.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<p>A homelab aesthetic, an open 1U rackmount server rendered in deep blues and warm amber LEDs, with an ethereal ghost-like stream of glowing keys made of light escaping upward from a git branch rendered as a stylized circuit-board trace, the keys disintegrating into pixels as they rise. Tech-noir color palette, cinematic lighting, a faint terminal prompt glow reflecting off the server chassis. Vector-ish line work mixed with subtle 3D render.</p>\n","comment_id":"69e03eea1340aa0001a504b1","feature_image":"https://emir.fyi/content/images/2026/04/ChatGPT-Image-Apr-15--2026--09_47_33-PM.png","featured":false,"visibility":"public","created_at":"2026-04-15T21:44:10.000-04:00","updated_at":"2026-04-18T21:57:54.000-04:00","published_at":"2026-04-18T21:57:54.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/rotating-wireguard-keys-after-i-committed-them-to-git/","excerpt":"Why I'm Writing This One\n\n\nMost of my blog posts are about building something new. Setting up a service, wiring things together, getting the green light. Those are fun to write. But I think there is more to learn from the posts where something went wrong.\n\n\nMistakes in infrastructure are not rare. They are inevitable. What matters is how quickly you catch them and how cleanly you recover. This post is about a time I committed WireGuard private keys to a Git repo and had to rotate everything. It ","reading_time":10,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69d26f601340aa0001a5049f","uuid":"5d1bb701-07d8-411f-b9e4-6c9507567cf5","title":"Building a High Availability Kubernetes Cluster Across Mixed Hardware (Part 1: The Build)","slug":"building-a-high-availability-kubernetes-cluster-across-mixed-hardware-part-1-the-build","html":"<h2 id=\"what-started-this\">What Started This</h2>\n<p>My Kubernetes cluster had a problem I'd been ignoring for months: one control plane node. A single Proxmox VM running etcd, the API server, the scheduler, all of it. If that VM went down, even for a routine Proxmox update, the entire cluster was unreachable. No kubectl, no deployments, no ArgoCD syncs. Every service that runs on K8s (Vaultwarden, MarketMind, ArgoCD) just... stops.</p>\n<p>And this wasn't a matter of <em>if</em>. It was <em>when</em>. My Proxmox server had a faulty RAM stick that would cause random restarts. No warning, just gone. Every time it happened, my notes app and password manager disappeared with it. Those two are the worst services to lose unexpectedly. When you need a password, you need it right now. When you need your notes, you need them right now. Not in five minutes when the VM finishes booting back up.</p>\n<p>I already built an HA load balancer with keepalived and Caddy across three physical machines, specifically so that no single failure could take down my service routing. But the irony was thick: the LB was highly available, and the thing it was routing traffic to was not.</p>\n<p>I had two BeeLink mini PCs sitting on my desk, freshly set up with Ubuntu and wired into the network. I'd been telling myself they were \"for the HA cluster\" for weeks. Time to actually do it.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Provision a new VM on Proxmox via Terraform</s> (done)</li>\n<li><s>Install K8s prerequisites on all 3 nodes via Ansible</s> (done)</li>\n<li><s>Initialize the first control plane with kube-vip for API server HA</s> (done)</li>\n<li><s>Join the BeeLinks as additional control plane nodes</s> (done)</li>\n<li><s>Untaint all nodes so they also run workloads</s> (done)</li>\n<li>Install Traefik, migrate workloads from old cluster (Part 2)</li>\n<li>Retarget HA LB and decommission old cluster (Part 2)</li>\n</ol>\n<h2 id=\"the-hardware\">The Hardware</h2>\n<p>This is where it gets fun. Most HA K8s guides assume you have three identical machines. I don't. My cluster runs across a VM and two bare metal mini PCs, spread across different physical machines for real fault tolerance.</p>\n<table>\n<thead>\n<tr>\n<th>Node</th>\n<th>IP</th>\n<th>Hardware</th>\n<th>Specs</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>ha-cp</td>\n<td>192.168.1.42</td>\n<td>Proxmox VM</td>\n<td>4 cores, 16GB RAM, 150GB disk</td>\n</tr>\n<tr>\n<td>n1</td>\n<td>192.168.1.40</td>\n<td>BeeLink Mini PC</td>\n<td>Intel N100, 4 cores, 16GB RAM, 466GB disk</td>\n</tr>\n<tr>\n<td>n2</td>\n<td>192.168.1.41</td>\n<td>BeeLink Mini PC</td>\n<td>Intel N100, 4 cores, 16GB RAM, 466GB disk</td>\n</tr>\n</tbody>\n</table>\n<p>The Proxmox VM lives on a Supermicro server. The BeeLinks are standalone boxes plugged into the same switch. If Proxmox goes down, n1 and n2 keep the cluster alive. If one BeeLink dies, the other two nodes maintain quorum. That's the whole point.</p>\n<p>All three nodes run as both control plane and worker. With only three machines, dedicating any of them to just control plane duties would waste too much compute. And kubeadm makes it easy to retaint them later when more hardware shows up.</p>\n<h2 id=\"step-1-provision-the-proxmox-vm\">Step 1: Provision the Proxmox VM</h2>\n<p>I already have Terraform managing my Proxmox VMs (the old cluster, the GitHub runner, the WireGuard server). Adding a new one is just another <code>.tf</code> file.</p>\n<p><code>terraform/proxmox/ha-cluster.tf</code>:</p>\n<pre><code class=\"language-hcl\">resource \"proxmox_virtual_environment_vm\" \"ha_cp\" {\n  name      = \"ha-cp\"\n  node_name = var.proxmox_node\n  vm_id     = 140\n\n  clone {\n    vm_id = var.template_vm_id  # Ubuntu 24.04 cloud-init template\n  }\n\n  agent { enabled = true }\n\n  cpu {\n    cores = 4\n    type  = \"x86-64-v2-AES\"\n  }\n\n  memory { dedicated = 16384 }\n\n  disk {\n    interface    = \"scsi0\"\n    size         = 150\n    datastore_id = var.datastore_id\n  }\n\n  initialization {\n    ip_config {\n      ipv4 {\n        address = \"192.168.1.42/24\"\n        gateway = var.gateway\n      }\n    }\n    dns { servers = var.dns_servers }\n    user_account {\n      keys     = [trimspace(file(var.ssh_public_key_path))]\n      username = \"ubuntu\"\n    }\n  }\n\n  network_device {\n    bridge   = var.bridge\n    firewall = true\n  }\n\n  operating_system { type = \"l26\" }\n  scsi_hardware = \"virtio-scsi-single\"\n  on_boot       = true\n}\n</code></pre>\n<pre><code class=\"language-bash\">cd terraform/proxmox\nterraform plan -target=proxmox_virtual_environment_vm.ha_cp\nterraform apply -target=proxmox_virtual_environment_vm.ha_cp\n</code></pre>\n<p>Two minutes and 45 seconds later, the VM was up and SSH was answering. The BeeLinks were already running Ubuntu 24.04 from a previous setup session, so all three nodes were ready.</p>\n<h2 id=\"step-2-install-k8s-prerequisites-ansible\">Step 2: Install K8s Prerequisites (Ansible)</h2>\n<p>This is the boring but critical part. Every node needs the same stack: containerd, kubeadm, kubelet, kubectl, plus kernel modules and sysctl tweaks for container networking.</p>\n<p>I wrote a single Ansible playbook (<code>ansible/playbook-ha-k8s.yml</code>) that handles the entire cluster lifecycle. The prerequisite tasks run on all three nodes in parallel.</p>\n<h3 id=\"the-inventory\">The inventory</h3>\n<pre><code class=\"language-yaml\">ha_cluster:\n  vars:\n    ansible_user: ubuntu\n    ha_vip: 192.168.1.222\n    ha_vip_port: 6443\n  children:\n    ha_cluster_init:\n      hosts:\n        192.168.1.42:\n          ha_interface: eth0\n    ha_cluster_join:\n      hosts:\n        n1.localdomain:\n          ha_interface: enp1s0\n        n2.localdomain:\n          ha_interface: enp1s0\n</code></pre>\n<p>The <code>ha_interface</code> is important because it's different on each type of hardware. The Proxmox VM uses <code>eth0</code>, the BeeLinks use <code>enp1s0</code> (Realtek NICs). kube-vip needs to know which interface to bind the VIP to.</p>\n<h3 id=\"what-the-playbook-does\">What the playbook does</h3>\n<p>Here's the condensed version. The full playbook is in the repo at <code>ansible/playbook-ha-k8s.yml</code>.</p>\n<p><strong>Kernel modules and sysctl:</strong></p>\n<pre><code class=\"language-yaml\">- name: Load required kernel modules\n  modprobe:\n    name: \"{{ item }}\"\n  loop: [overlay, br_netfilter]\n\n- name: Set required sysctl params\n  sysctl:\n    name: \"{{ item.key }}\"\n    value: \"{{ item.value }}\"\n    sysctl_file: /etc/sysctl.d/k8s.conf\n  loop:\n    - { key: net.bridge.bridge-nf-call-iptables, value: \"1\" }\n    - { key: net.bridge.bridge-nf-call-ip6tables, value: \"1\" }\n    - { key: net.ipv4.ip_forward, value: \"1\" }\n</code></pre>\n<p><strong>Install containerd from Docker's apt repo:</strong></p>\n<pre><code class=\"language-yaml\">- name: Install containerd\n  apt:\n    name: containerd.io\n    state: present\n    update_cache: true\n</code></pre>\n<p><strong>Install kubeadm, kubelet, kubectl from the K8s repo:</strong></p>\n<pre><code class=\"language-yaml\">- name: Add Kubernetes apt repository\n  copy:\n    dest: /etc/apt/sources.list.d/kubernetes.list\n    content: \"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.35/deb/ /\\n\"\n\n- name: Install kubeadm, kubelet, kubectl\n  apt:\n    name: [kubeadm, kubelet, kubectl]\n    state: present\n    update_cache: true\n</code></pre>\n<h3 id=\"the-containerd-v2-gotcha\">The containerd v2 gotcha</h3>\n<p>This one got me. containerd v2.2.2 (which is what Docker's apt repo gives you on Ubuntu 24.04) ships with a default config that <strong>disables the CRI plugin</strong>:</p>\n<pre><code class=\"language-toml\">disabled_plugins = [\"cri\"]\n</code></pre>\n<p>CRI is literally the interface Kubernetes uses to talk to the container runtime. With it disabled, <code>kubeadm init</code> fails with a cryptic error about <code>unknown service runtime.v1.RuntimeService</code>. If you see that, check your containerd config. The fix:</p>\n<pre><code class=\"language-yaml\">- name: Enable CRI plugin (disabled by default in containerd v2)\n  replace:\n    path: /etc/containerd/config.toml\n    regexp: 'disabled_plugins = \\[\"cri\"\\]'\n    replace: 'disabled_plugins = []'\n</code></pre>\n<p>You also need <code>SystemdCgroup = true</code> in the containerd config so the cgroup driver matches kubelet. The playbook handles both.</p>\n<h2 id=\"step-3-initialize-the-first-control-plane-with-kube-vip\">Step 3: Initialize the First Control Plane with kube-vip</h2>\n<p>This is the interesting part. In a multi-master K8s cluster, you need a stable endpoint for the API server that doesn't depend on any single node. If the API server on node 1 goes down, kubectl (and every kubelet) needs to automatically reach node 2 or 3 instead.</p>\n<h3 id=\"why-kube-vip\">Why kube-vip</h3>\n<p>I already have a keepalived setup for my HA load balancer, so I considered reusing it. But that would create a circular dependency: the K8s API would depend on the LB stack, and if I ever moved the LB into K8s (unlikely, but possible), I'd have a chicken and egg problem.</p>\n<p>kube-vip is purpose-built for this. It runs as a static pod on each control plane node and uses leader election to assign a floating virtual IP (VIP). Only the leader node holds the VIP. If it dies, another node wins the election and takes over, usually within seconds. No external dependencies. The K8s cluster is entirely self-contained.</p>\n<p><strong>My VIP:</strong> <code>192.168.1.222</code> (my HA LB uses <code>.221</code>, so this sits right next to it)</p>\n<h3 id=\"the-kube-vip-manifest\">The kube-vip manifest</h3>\n<p>I use an Ansible template (<code>ansible/templates/kube-vip.yaml.j2</code>) that gets deployed to <code>/etc/kubernetes/manifests/</code> as a static pod:</p>\n<pre><code class=\"language-yaml\">apiVersion: v1\nkind: Pod\nmetadata:\n  name: kube-vip\n  namespace: kube-system\nspec:\n  containers:\n  - args: [manager]\n    env:\n    - name: vip_arp\n      value: \"true\"\n    - name: port\n      value: \"6443\"\n    - name: vip_interface\n      value: \"{{ ha_interface }}\"\n    - name: address\n      value: \"{{ ha_vip }}\"\n    - name: cp_enable\n      value: \"true\"\n    - name: vip_leaderelection\n      value: \"true\"\n    - name: vip_leaseduration\n      value: \"5\"\n    - name: vip_renewdeadline\n      value: \"3\"\n    - name: vip_retryperiod\n      value: \"1\"\n    image: ghcr.io/kube-vip/kube-vip:v1.1.2\n    securityContext:\n      capabilities:\n        add: [NET_ADMIN, NET_RAW, SYS_TIME]\n    volumeMounts:\n    - mountPath: /etc/kubernetes/admin.conf\n      name: kubeconfig\n  hostNetwork: true\n  volumes:\n  - hostPath:\n      path: /etc/kubernetes/{{ kube_vip_kubeconfig }}\n    name: kubeconfig\n</code></pre>\n<h3 id=\"the-k8s-129-bootstrap-problem\">The K8s 1.29+ bootstrap problem</h3>\n<p>Notice that <code>kube_vip_kubeconfig</code> variable? That's there because of a breaking change in Kubernetes 1.29. Before 1.29, <code>admin.conf</code> had full cluster-admin privileges from the moment <code>kubeadm init</code> started. kube-vip could mount it and immediately acquire the leader election lease.</p>\n<p>Starting with 1.29, <code>admin.conf</code> doesn't get its ClusterRoleBinding until later in the bootstrap process. Instead, kubeadm creates a <code>super-admin.conf</code> with the old behavior. So the trick is:</p>\n<ol>\n<li><strong>Before <code>kubeadm init</code>:</strong> Deploy kube-vip with <code>super-admin.conf</code></li>\n<li><strong>Run <code>kubeadm init</code>:</strong> kube-vip can authenticate and grab the VIP</li>\n<li><strong>After init succeeds:</strong> Switch kube-vip to <code>admin.conf</code> for ongoing operation</li>\n</ol>\n<p>The playbook handles this automatically with two template deployments using different variables.</p>\n<h3 id=\"running-kubeadm-init\">Running kubeadm init</h3>\n<pre><code class=\"language-yaml\">- name: Initialize Kubernetes control plane\n  command: &gt;\n    kubeadm init\n      --control-plane-endpoint \"{{ ha_vip }}:{{ ha_vip_port }}\"\n      --upload-certs\n      --pod-network-cidr 10.244.0.0/16\n</code></pre>\n<p>The <code>--control-plane-endpoint</code> flag is what makes this an HA cluster. It tells kubeadm that the API server should be accessed through the VIP, not the node's own IP. Every kubelet and every kubeconfig will point to <code>192.168.1.222:6443</code>.</p>\n<p><code>--upload-certs</code> encrypts and uploads the control plane certificates to a cluster secret so the joining nodes can download them. The encryption key expires in 2 hours, which is plenty of time for automated joining.</p>\n<p><code>--pod-network-cidr 10.244.0.0/16</code> is what Flannel expects. Speaking of which:</p>\n<pre><code class=\"language-yaml\">- name: Install Flannel CNI\n  command: kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml\n</code></pre>\n<h2 id=\"step-4-join-the-beelinks\">Step 4: Join the BeeLinks</h2>\n<p>Each joining node needs two things before running <code>kubeadm join</code>: the kube-vip manifest and the join command from the init node.</p>\n<p>The playbook fetches fresh join credentials from the init node (in case tokens have rotated), deploys the kube-vip template, and runs the join:</p>\n<pre><code class=\"language-yaml\">- name: Join as control plane node\n  command: &gt;\n    {{ hostvars[groups['ha_cluster_init'][0]]['ha_join_command'] }}\n      --control-plane\n      --certificate-key {{ hostvars[groups['ha_cluster_init'][0]]['ha_cert_key'] }}\n</code></pre>\n<p>The <code>--control-plane</code> flag is what differentiates a control plane join from a worker join. It downloads the certificates and sets up etcd, the API server, and the other control plane components.</p>\n<p>The nodes join one at a time (<code>serial: 1</code> in Ansible) because etcd membership changes require consensus. Trying to add two members simultaneously can cause quorum issues.</p>\n<h2 id=\"step-5-untaint-and-verify\">Step 5: Untaint and Verify</h2>\n<p>By default, kubeadm taints control plane nodes with <code>NoSchedule</code> so workloads only run on workers. Since all three of our nodes are both control plane and worker, we remove the taint:</p>\n<pre><code class=\"language-yaml\">- name: Remove NoSchedule taint from control plane nodes\n  command: kubectl taint nodes {{ item }} node-role.kubernetes.io/control-plane:NoSchedule-\n  loop: \"{{ node_names.stdout.split() }}\"\n</code></pre>\n<p>This is easily reversible. When more hardware arrives, we can re-add the taint and let the control plane nodes focus exclusively on cluster management.</p>\n<h2 id=\"the-result\">The Result</h2>\n<pre><code>$ kubectl get nodes -o wide\nNAME    STATUS   ROLES           AGE   VERSION   INTERNAL-IP     OS-IMAGE\nha-cp   Ready    control-plane   4m    v1.35.3   192.168.1.42    Ubuntu 24.04.4 LTS\nn1      Ready    control-plane   2m    v1.35.3   192.168.1.40    Ubuntu 24.04.4 LTS\nn2      Ready    control-plane   50s   v1.35.3   192.168.1.41    Ubuntu 24.04.4 LTS\n</code></pre>\n<p>Three nodes. Three etcd members. Three API servers. Three instances of kube-vip doing leader election. All pods healthy:</p>\n<pre><code>NAMESPACE      NAME                            READY   STATUS\nkube-flannel   kube-flannel-ds-*               1/1     Running   (x3)\nkube-system    coredns-*                       1/1     Running   (x2)\nkube-system    etcd-*                          1/1     Running   (x3)\nkube-system    kube-apiserver-*                1/1     Running   (x3)\nkube-system    kube-controller-manager-*       1/1     Running   (x3)\nkube-system    kube-scheduler-*                1/1     Running   (x3)\nkube-system    kube-vip-*                      1/1     Running   (x3)\nkube-system    kube-proxy-*                    1/1     Running   (x3)\n</code></pre>\n<p>The VIP responds:</p>\n<pre><code>$ curl -sk https://192.168.1.222:6443/healthz\nok\n</code></pre>\n<p>And the old cluster? Still running. I set up a separate kubeconfig (<code>~/.kube/config-ha</code>) so I can talk to either cluster explicitly:</p>\n<pre><code class=\"language-bash\"># Default kubectl still points to the old cluster\nkubectl get nodes\n# NAME           STATUS   ROLES           VERSION\n# k8s-master     Ready    control-plane   v1.31.14\n# k8s-worker-1   Ready    &lt;none&gt;          v1.31.14\n# k8s-worker-2   Ready    &lt;none&gt;          v1.31.14\n\n# Explicit kubeconfig for new HA cluster\nkubectl --kubeconfig ~/.kube/config-ha get nodes\n# NAME    STATUS   ROLES           VERSION\n# ha-cp   Ready    control-plane   v1.35.3\n# n1      Ready    control-plane   v1.35.3\n# n2      Ready    control-plane   v1.35.3\n</code></pre>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<p><strong>containerd v2 is sneaky.</strong> The default config disabling CRI is not documented in most K8s installation guides because they were written for containerd v1. If you're installing in 2025 or later, check your <code>config.toml</code>.</p>\n<p><strong>kube-vip's bootstrap dance is annoying but logical.</strong> The <code>super-admin.conf</code> workaround is the kind of thing that makes you question whether the juice is worth the squeeze, until you realize the alternative is an external load balancer just for the API server. One sed command during bootstrap is a fair trade.</p>\n<p><strong>Mixed hardware works fine.</strong> I expected weird edge cases from running VMs alongside bare metal. There weren't any. kubeadm doesn't care that one node is virtualized and two aren't. etcd doesn't care. The only difference was the network interface names (<code>eth0</code> vs <code>enp1s0</code>), and that's why the Ansible inventory has per-host variables.</p>\n<p><strong>Serial joins matter.</strong> Joining two control plane nodes simultaneously can cause etcd to lose quorum during the membership change. The <code>serial: 1</code> in the Ansible playbook makes the joins sequential. It's slower but safe.</p>\n<h2 id=\"whats-next-part-2\">What's Next (Part 2)</h2>\n<p>The cluster is running but empty. In Part 2, I'll:</p>\n<ul>\n<li>Install Traefik as the ingress controller</li>\n<li>Migrate workloads from the old single-master cluster (Vaultwarden, ArgoCD, MarketMind)</li>\n<li>Retarget the HA load balancer (Caddy) to route traffic to the new cluster</li>\n<li>Decommission the old cluster and reclaim the resources</li>\n</ul>\n<p>The hard part is done. The easy part is next. Famous last words.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong> Three interconnected nodes forming a triangle, each node a different piece of hardware: one is a rack-mounted server (Proxmox), the other two are tiny BeeLink mini PCs. Glowing data streams flow between them in a circular pattern. A floating Kubernetes wheel logo hovers above the triangle. The center of the triangle shows \"HA\" in holographic text. Dark moody homelab aesthetic with deep blue and electric purple accent lighting, circuit board patterns visible in the background, photorealistic 3D render.</p>\n","comment_id":"69d26f601340aa0001a5049f","feature_image":"https://emir.fyi/content/images/2026/04/5680cb80-eab4-457d-b18a-116e377a7b6c.png","featured":false,"visibility":"public","created_at":"2026-04-05T10:19:12.000-04:00","updated_at":"2026-04-15T10:37:25.000-04:00","published_at":"2026-04-15T10:37:25.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/building-a-high-availability-kubernetes-cluster-across-mixed-hardware-part-1-the-build/","excerpt":"What Started This\n\n\nMy Kubernetes cluster had a problem I'd been ignoring for months: one control plane node. A single Proxmox VM running etcd, the API server, the scheduler, all of it. If that VM went down, even for a routine Proxmox update, the entire cluster was unreachable. No kubectl, no deployments, no ArgoCD syncs. Every service that runs on K8s (Vaultwarden, MarketMind, ArgoCD) just... stops.\n\n\nAnd this wasn't a matter of if. It was when. My Proxmox server had a faulty RAM stick that wou","reading_time":9,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69cff6ed1340aa0001a50494","uuid":"eb31ec45-6ba9-46d3-a1d1-aefe6d440f1f","title":"WireGuard VPN to My Homelab: Automating a VPN Server With Terraform and Ansible","slug":"wireguard-vpn-to-my-homelab-automating-a-vpn-server-with-terraform-and-ansible","html":"<h2 id=\"the-itch\">The Itch</h2>\n<p>I have a homelab full of services that only exist on my LAN. Frigate watches my cameras. Paperless holds every document I own. SiYuan has all my notes. Vaultwarden keeps my passwords. These all live behind my router on a private <code>192.168.1.x</code> subnet, and that's where they should stay.</p>\n<p>But sometimes I'm not home. I'm traveling, visiting family, or just out for the day. And I want to pull up my camera feeds, or grab a document from Paperless, or check on a service. I needed a VPN that I control, with no third party dependencies, that works on any device with a single config file.</p>\n<p>WireGuard is the answer. It's fast, it's simple, and the entire config is about 10 lines. Every platform has a lightweight client. No accounts, no subscriptions, no background daemons phoning home.</p>\n<p>And honestly, the real reason? I wanted to automate the entire thing. Terraform to provision the VM, Ansible to configure WireGuard, one command to go from nothing to a working VPN server. Build it locally, prove it works, then point the same automation at a cloud VPS later.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Set up Dynamic DNS so my home IP stays reachable</s> (done)</li>\n<li><s>Provision a VM on Proxmox via Terraform</s> (done)</li>\n<li><s>Configure WireGuard server via Ansible</s> (done)</li>\n<li><s>Port forward UDP 51820 through pfSense</s> (done, eventually)</li>\n<li><s>Connect from phone on mobile data and see home IP</s> (done)</li>\n<li>Point the same Ansible playbook at a Hetzner VPS for production (future)</li>\n</ol>\n<h2 id=\"dynamic-dns-because-isp-ips-arent-forever\">Dynamic DNS: Because ISP IPs Aren't Forever</h2>\n<p>My ISP Cable IPs are fairly stable (mine hasn't changed in months), but I didn't want to hardcode an IP into a VPN config that might break at 3am some random Tuesday.</p>\n<p>The solution: a DNS record that pfSense keeps updated automatically.</p>\n<p><strong>Step 1:</strong> Create an A record in Cloudflare pointing to my current public IP. Set it to \"DNS only\" (not proxied, because WireGuard needs to see the real IP). TTL of 1 minute so changes propagate fast.</p>\n<p><strong>Step 2:</strong> Create a scoped Cloudflare API token with two permissions:</p>\n<ul>\n<li>Zone &gt; DNS &gt; Edit (to update the record)</li>\n<li>Zone &gt; Zone &gt; Read (so pfSense can look up the Zone ID)</li>\n</ul>\n<p>Scope it to only your domain. Least privilege.</p>\n<p><strong>Step 3:</strong> In pfSense, go to Services &gt; Dynamic DNS &gt; Dynamic DNS Clients:</p>\n<ul>\n<li>Service Type: Cloudflare</li>\n<li>Interface: WAN</li>\n<li>Hostname: <code>vpn</code></li>\n<li>Domain: <code>example.com</code></li>\n<li>Username: leave blank (tells pfSense to use Bearer token auth)</li>\n<li>Password: paste the API token</li>\n<li>Cloudflare Proxy: unchecked</li>\n</ul>\n<p>Save, force update, and you should see a green checkmark with your current IP. Now pfSense will update that DNS record whenever your ISP gives you a new address.</p>\n<h2 id=\"provisioning-the-vm-with-terraform\">Provisioning the VM with Terraform</h2>\n<p>I use the <code>bpg/proxmox</code> Terraform provider to manage all my Proxmox VMs. The WireGuard server is tiny: 1 CPU core, 512MB RAM. It's basically just shuffling packets.</p>\n<p>The critical thing I learned the hard way: <strong>you must include a <code>clone</code> block</strong> that references your cloud-init template. Without it, Terraform creates an empty VM with no OS and you stare at an iPXE boot prompt wondering what you did wrong. Also, the disk size must be &gt;= your template's disk size (you can't shrink a clone).</p>\n<pre><code class=\"language-hcl\">resource \"proxmox_virtual_environment_vm\" \"wireguard\" {\n  name      = \"wireguard\"\n  node_name = var.proxmox_node\n  vm_id     = 130\n\n  # This is the line I forgot the first time\n  clone {\n    vm_id = var.template_vm_id  # Ubuntu cloud-init template\n  }\n\n  agent { enabled = true }\n  cpu   { cores = 1; type = \"x86-64-v2-AES\" }\n  memory { dedicated = 512 }\n\n  disk {\n    interface    = \"scsi0\"\n    size         = 23  # Must match or exceed template disk\n    datastore_id = var.datastore_id\n  }\n\n  initialization {\n    ip_config {\n      ipv4 {\n        address = \"192.168.1.50/24\"\n        gateway = var.gateway\n      }\n    }\n    dns { servers = var.dns_servers }\n    user_account {\n      keys     = [trimspace(file(var.ssh_public_key_path))]\n      username = \"ubuntu\"\n    }\n  }\n\n  network_device { bridge = var.bridge }\n  scsi_hardware  = \"virtio-scsi-single\"\n  on_boot        = true\n}\n</code></pre>\n<p><code>terraform apply</code> clones the template (a few minutes for the full disk copy), configures cloud-init with the static IP and SSH key, and boots the VM. Wait about 30 seconds for cloud-init to finish, then you can SSH in.</p>\n<h2 id=\"configuring-wireguard-with-ansible\">Configuring WireGuard with Ansible</h2>\n<p>The Ansible playbook handles everything: install WireGuard, generate server and client keys, template the configs, enable IP forwarding, set up NAT masquerading, and output a client config you can import directly.</p>\n<pre><code class=\"language-yaml\">---\n- name: Configure WireGuard VPN server\n  hosts: wireguard\n  become: true\n  vars:\n    wg_port: 51820\n    wg_server_addr: 10.66.66.1/24\n    wg_client_addr: 10.66.66.2/32\n    wg_interface: eth0\n    wg_dns: 192.168.1.1\n\n  tasks:\n    - name: Install WireGuard and qrencode\n      apt:\n        name: [wireguard, qrencode]\n        state: present\n        update_cache: true\n\n    - name: Generate server private key\n      command: wg genkey\n      register: wg_server_genkey\n      args:\n        creates: /etc/wireguard/server_private.key\n\n    - name: Save server private key\n      copy:\n        content: \"{{ wg_server_genkey.stdout }}\"\n        dest: /etc/wireguard/server_private.key\n        mode: \"0600\"\n      when: wg_server_genkey.changed\n\n    # ... derive public keys, generate client keys ...\n\n    - name: Enable IP forwarding\n      sysctl:\n        name: net.ipv4.ip_forward\n        value: \"1\"\n        state: present\n        reload: true\n\n    - name: Enable and start WireGuard\n      systemd:\n        name: wg-quick@wg0\n        enabled: true\n        state: started\n\n    - name: Generate QR code for phone import\n      shell: qrencode -t ansiutf8 &lt; /etc/wireguard/client.conf\n      register: client_qr\n      changed_when: false\n</code></pre>\n<p>The server config uses <code>PostUp</code> and <code>PostDown</code> hooks to add and remove the iptables MASQUERADE rule. This is what makes the VM act as an exit node: client traffic enters the WireGuard tunnel, gets NAT'd to the VM's real IP, and exits through the home router to the internet.</p>\n<pre><code class=\"language-ini\">[Interface]\nPrivateKey = &lt;server_private_key&gt;\nAddress = 10.66.66.1/24\nListenPort = 51820\nPostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE\nPostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE\n\n[Peer]\nPublicKey = &lt;client_public_key&gt;\nAllowedIPs = 10.66.66.2/32\n</code></pre>\n<p>The client config routes all traffic through the tunnel (<code>AllowedIPs = 0.0.0.0/0</code>), so when I check my public IP from my phone, I see my home IP. DNS goes through the tunnel too, pointed at my home router.</p>\n<pre><code class=\"language-ini\">[Interface]\nPrivateKey = &lt;client_private_key&gt;\nAddress = 10.66.66.2/32\nDNS = 192.168.1.1\n\n[Peer]\nPublicKey = &lt;server_public_key&gt;\nEndpoint = vpn.example.com:51820\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n</code></pre>\n<p>The playbook also installs <code>qrencode</code> so it spits out a QR code at the end. Open the WireGuard app on your phone, scan, done.</p>\n<p>Run it all with:</p>\n<pre><code class=\"language-bash\">ansible-playbook -i inventory.yml playbook-wireguard.yml --extra-vars '@wireguard-vars.yml'\n</code></pre>\n<h3 id=\"adding-more-clients\">Adding More Clients</h3>\n<p>The initial playbook creates one client. But you probably want your phone and your laptop connected at the same time. Each device needs its own keypair and tunnel IP, otherwise WireGuard gets confused about which peer is which.</p>\n<p>I wrote a second playbook for this. Give it a name, it does the rest:</p>\n<pre><code class=\"language-bash\">ansible-playbook -i inventory.yml playbook-wireguard-add-client.yml \\\n  --extra-vars '@wireguard-vars.yml' \\\n  --extra-vars 'client_name=work-laptop'\n</code></pre>\n<p>It generates a new keypair, auto-assigns the next available IP in the tunnel subnet (<code>.3</code>, <code>.4</code>, etc.), adds the peer to the running server without restarting, and spits out both a config file and a QR code. The config gets saved locally as <code>wireguard-work-laptop.conf</code>.</p>\n<p>On a desktop, you import the <code>.conf</code> file into the WireGuard app. On a phone, you scan the QR code. That's it. No need to touch the server config manually or restart anything.</p>\n<h2 id=\"the-pfsense-port-forwarding-saga\">The pfSense Port Forwarding Saga</h2>\n<p>This is where I lost a few hours, over few days unfortunately.</p>\n<p>The setup should be simple: forward UDP 51820 from WAN to the WireGuard VM. In pfSense, that's Firewall &gt; NAT &gt; Port Forward. Create the rule, done, right?</p>\n<p>Not quite.</p>\n<p><strong>Problem 1: The auto-generated firewall rule was disabled.</strong> When pfSense creates a NAT port forward, it auto-generates an associated WAN firewall rule to allow the traffic. Mine was created in a disabled state. Packets were arriving at pfSense (I could see them in the firewall logs) but getting dropped by the \"Default deny\" rule because the pass rule wasn't active. Easy to miss.</p>\n<p><strong>Problem 2: The firewall rule destination address.</strong> This is the one that really got me. In pfSense, NAT translation happens <em>before</em> firewall rules evaluate. So when an external packet arrives destined for your WAN IP on port 51820, pfSense first translates the destination to the internal IP (192.168.1.50), and <em>then</em> checks the firewall rules. The firewall rule needs to match the <em>translated</em> destination (192.168.1.50), not the original WAN address. I had it set to \"WAN address\" and couldn't figure out why packets were being blocked even though the rule looked correct.</p>\n<p><strong>The debugging approach that actually worked:</strong> SSH into pfSense and check the loaded pf rules:</p>\n<pre><code class=\"language-bash\"># Check if NAT redirect rule is loaded\npfctl -s all 2&gt;&amp;1 | grep 51820\n\n# You should see both:\n# rdr on igc0 ... -&gt; 192.168.1.50        (NAT redirect)\n# pass in quick on igc0 ...               (firewall pass)\n</code></pre>\n<p>If you only see the pass rule but not the rdr rule, your NAT port forward is disabled or misconfigured.</p>\n<p>On the WireGuard server, <code>tcpdump</code> tells you if packets are actually arriving:</p>\n<pre><code class=\"language-bash\">sudo tcpdump -i eth0 udp port 51820 -n\n</code></pre>\n<p>Zero packets? The problem is upstream (pfSense). Packets arriving but no WireGuard handshake? The problem is the WireGuard config.</p>\n<h2 id=\"the-moment-it-worked\">The Moment It Worked</h2>\n<p>I was sitting at my desk, connected to my home network via remote desktop from somewhere else entirely. Toggled the WireGuard VPN on my phone (mobile data, not WiFi), opened Safari, went to ifconfig.me, and there it was: my home IP.</p>\n<p>Then I typed <code>frigate.localdomain</code> into the browser and my camera feeds loaded. From my phone. On a cell tower. Through an encrypted WireGuard tunnel, across the internet, into my home network, to a VM that routes the traffic onto my LAN as if I were sitting on the couch.</p>\n<p>Paperless works. SiYuan works. Every <code>.localdomain</code> service just resolves and loads, because the VPN tunnel uses my home router as its DNS server.</p>\n<h2 id=\"what-id-do-differently\">What I'd Do Differently</h2>\n<p><strong>Don't forget the <code>clone</code> block in Terraform.</strong> I stared at an iPXE boot prompt for longer than I'd like to admit.</p>\n<p><strong>Read the pfSense NAT processing order docs first.</strong> Understanding that NAT happens before firewall rule evaluation would have saved me an hour of debugging. The auto-generated associated filter rule had the right destination all along. I \"fixed\" it by changing it to the wrong one.</p>\n<p><strong>Test from actual external networks.</strong> NAT port forwards don't work from inside the LAN (hairpin NAT). I wasted time testing from a machine on the same network before switching to my phone on cellular.</p>\n<h2 id=\"whats-next\">What's Next</h2>\n<p>Right now this runs on a local Proxmox VM, which means my VPN server and my home internet share the same upload pipe. That's fine for accessing services, but if I wanted to use it as a general purpose exit node (tunnel all my browsing through it), my home upload speed becomes the bottleneck.</p>\n<p>The fix: run the same Ansible playbook against a cheap Hetzner VPS in Ashburn. $4/month, 1Gbps symmetric, US IP. The automation is already proven locally. One <code>ansible-playbook</code> command pointed at a different inventory and it's live. That's the whole point of building it this way.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong></p>\n<p>A stylized isometric illustration of a glowing WireGuard tunnel connecting a smartphone in a coffee shop to a home server rack, with encrypted data packets flowing through. The tunnel passes through a city skyline, through clouds, and into a cozy house where camera feeds and documents are visible on tiny screens. Neon blue and purple color palette, dark background, clean geometric style, homelab aesthetic with a sense of security and privacy. No text.</p>\n","comment_id":"69cff6ed1340aa0001a50494","feature_image":"https://emir.fyi/content/images/2026/04/ChatGPT-Image-Apr-3--2026--01_26_25-PM.png","featured":false,"visibility":"public","created_at":"2026-04-03T13:20:45.000-04:00","updated_at":"2026-04-08T09:37:01.000-04:00","published_at":"2026-04-08T09:37:01.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/wireguard-vpn-to-my-homelab-automating-a-vpn-server-with-terraform-and-ansible/","excerpt":"The Itch\n\n\nI have a homelab full of services that only exist on my LAN. Frigate watches my cameras. Paperless holds every document I own. SiYuan has all my notes. Vaultwarden keeps my passwords. These all live behind my router on a private 192.168.1.x subnet, and that's where they should stay.\n\n\nBut sometimes I'm not home. I'm traveling, visiting family, or just out for the day. And I want to pull up my camera feeds, or grab a document from Paperless, or check on a service. I needed a VPN that I","reading_time":7,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69c828461340aa0001a5047e","uuid":"8b8affef-f334-496a-8b7c-94f355794eca","title":"Giving Claude Desktop and Claude Code My Google Ads Account (On Purpose)","slug":"giving-claude-desktop-and-claude-code-my-google-ads-account-on-purpose","html":"<h2 id=\"the-actual-problem\">The actual problem</h2>\n<p>I have a landing page (I am helping my wife generate leads for her business). It has a Google Ads campaign driving traffic to it. It also has PostHog tracking what visitors do once they arrive. Two systems, two dashboards, two browser tabs I keep open and alt-tab between when I'm trying to figure out if the campaign is actually working.</p>\n<p>The workflow looks like this: check Google Ads to see which keywords are spending money, switch to PostHog to see if those visitors are actually converting, try to hold both datasets in my head long enough to make a decision, fail, open a spreadsheet. It's not great.</p>\n<p>What I actually wanted was to sit in Claude Code while working on the landing page and ask things like \"which ad groups have the worst bounce rate?\" and get an answer that combines Google Ads spend data with PostHog behavior data. I already had PostHog wired up as an MCP server. Google Ads was the missing piece.</p>\n<p>And then there's the other side. When I'm not in the code, I want to use Claude Desktop as a marketing copilot. Pull up campaign reports, compare ad copy performance, get suggestions on budget allocation. Same data, different context.</p>\n<p>So I needed the Google Ads MCP server running in both Claude Code and Claude Desktop. Sounds simple. It was not simple.</p>\n<h2 id=\"what-mcp-actually-is-30-second-version\">What MCP actually is (30 second version)</h2>\n<p>MCP (Model Context Protocol) lets you give Claude access to external tools. Instead of copying data from Google Ads and pasting it into a chat, you connect an MCP server that speaks the Google Ads API, and Claude can query it directly. Think of it like giving Claude read access to your ad account.</p>\n<p>PostHog, Gmail, Google Calendar, they all have MCP servers now. Google Ads joined the party recently via an open source server from Google's marketing solutions team.</p>\n<h2 id=\"getting-the-credentials\">Getting the credentials</h2>\n<p>Before anything works, you need four things from Google:</p>\n<ol>\n<li><strong>OAuth Client ID</strong> from Google Cloud Console (the long <code>...apps.googleusercontent.com</code> string)</li>\n<li><strong>OAuth Client Secret</strong> (the <code>xxXAas...</code> value)</li>\n<li><strong>Refresh Token</strong> generated via the OAuth 2.0 Playground</li>\n<li><strong>Developer Token</strong> from the Google Ads API Center (under Tools &gt; Setup &gt; API Center in your Google Ads manager account)</li>\n</ol>\n<p>The refresh token is the annoying one. You generate it through Google's OAuth 2.0 Playground by authorizing the Google Ads API scope and exchanging the authorization code.</p>\n<p>These go into a YAML file at <code>~/.google-ads.yaml</code>:</p>\n<pre><code class=\"language-yaml\">client_id: \"your-client-id.apps.googleusercontent.com\"\nclient_secret: \"GOCSPX--your-secret-here\"\nrefresh_token: \"1//your-refresh-token-here\"\ndeveloper_token: \"your-developer-token\"\nlogin_customer_id: \"1234567890\"\nuse_proto_plus: True\n</code></pre>\n<p>That last line, <code>use_proto_plus: True</code>, is not in any of the setup guides I found. The server crashes without it with a <code>ValueError</code> saying the key is missing, and the error message itself links to Google's protobuf messages guide which explains the option. So the fix was right there in the traceback, just not in any setup instructions</p>\n<p>The <code>login_customer_id</code> is your Google Ads manager account ID (no dashes). If you only have a single account, use that account's ID.</p>\n<h2 id=\"setting-up-claude-code-the-http-path\">Setting up Claude Code (the HTTP path)</h2>\n<p>Here's where things got interesting. The Google Ads MCP server from <code>google-marketing-solutions/google_ads_mcp</code> is built with FastMCP and the transport is hardcoded to <code>streamable-http</code>. It starts an HTTP server on port 8000, not a stdio process.</p>\n<p>This matters because Claude Code and Claude Desktop handle MCP transports differently.</p>\n<p>For Claude Code, HTTP works great:</p>\n<pre><code class=\"language-bash\">claude mcp add -s user -t http google-ads-mcp \"http://127.0.0.1:8000/mcp\"\n</code></pre>\n<p>But you need the server actually running first. I installed it with pipx:</p>\n<pre><code class=\"language-bash\">pipx install \"git+https://github.com/google-marketing-solutions/google_ads_mcp.git\"\n</code></pre>\n<p>Then created a launchd service so it starts automatically and survives reboots:</p>\n<pre><code class=\"language-xml\">&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n  \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"&gt;\n&lt;plist version=\"1.0\"&gt;\n&lt;dict&gt;\n    &lt;key&gt;Label&lt;/key&gt;\n    &lt;string&gt;com.google-ads-mcp&lt;/string&gt;\n    &lt;key&gt;ProgramArguments&lt;/key&gt;\n    &lt;array&gt;\n        &lt;string&gt;/opt/homebrew/bin/pipx&lt;/string&gt;\n        &lt;string&gt;run&lt;/string&gt;\n        &lt;string&gt;--spec&lt;/string&gt;\n        &lt;string&gt;git+https://github.com/google-marketing-solutions/google_ads_mcp.git&lt;/string&gt;\n        &lt;string&gt;run-mcp-server&lt;/string&gt;\n    &lt;/array&gt;\n    &lt;key&gt;EnvironmentVariables&lt;/key&gt;\n    &lt;dict&gt;\n        &lt;key&gt;GOOGLE_ADS_CREDENTIALS&lt;/key&gt;\n        &lt;string&gt;/Users/yourusername/.google-ads.yaml&lt;/string&gt;\n        &lt;key&gt;PATH&lt;/key&gt;\n        &lt;string&gt;/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin&lt;/string&gt;\n    &lt;/dict&gt;\n    &lt;key&gt;RunAtLoad&lt;/key&gt;\n    &lt;true/&gt;\n    &lt;key&gt;KeepAlive&lt;/key&gt;\n    &lt;true/&gt;\n    &lt;key&gt;StandardOutPath&lt;/key&gt;\n    &lt;string&gt;/tmp/google-ads-mcp.log&lt;/string&gt;\n    &lt;key&gt;StandardErrorPath&lt;/key&gt;\n    &lt;string&gt;/tmp/google-ads-mcp.err&lt;/string&gt;\n&lt;/dict&gt;\n&lt;/plist&gt;\n</code></pre>\n<p>Save that to <code>~/Library/LaunchAgents/com.google-ads-mcp.plist</code> and load it:</p>\n<pre><code class=\"language-bash\">launchctl load ~/Library/LaunchAgents/com.google-ads-mcp.plist\n</code></pre>\n<p>The server starts on boot, restarts if it crashes, and logs to <code>/tmp/</code>. Verify it's running:</p>\n<pre><code class=\"language-bash\">claude mcp list\n# google-ads-mcp: http://127.0.0.1:8000/mcp (HTTP) - ✓ Connected\n</code></pre>\n<h2 id=\"setting-up-claude-desktop-the-stdio-detour\">Setting up Claude Desktop (the stdio detour)</h2>\n<p>Here's the plot twist. Claude Desktop doesn't support <code>streamable-http</code> transport. It only speaks <code>stdio</code> (and <code>sse</code> for some servers). When you try to point it at an HTTP URL, it shows you a polite error dialog and refuses to load the server.</p>\n<p>The Google Ads MCP server hardcodes <code>streamable-http</code> in its source:</p>\n<pre><code class=\"language-python\">mcp_server.run(\n    transport=\"streamable-http\",\n    show_banner=False,\n)\n</code></pre>\n<p>No CLI flag to change it. No environment variable. Just hardcoded.</p>\n<p>The fix: a tiny wrapper script that imports the same MCP server object but runs it in stdio mode. Since I already installed the package with pipx, I can use the venv's Python interpreter directly:</p>\n<pre><code class=\"language-python\">#!/Users/yourusername/.local/pipx/venvs/google-ads-mcp/bin/python3\n\"\"\"Run Google Ads MCP server in stdio mode for Claude Desktop.\"\"\"\nimport asyncio\nimport os\n\nos.environ.setdefault(\n    \"GOOGLE_ADS_CREDENTIALS\",\n    os.path.expanduser(\"~/.google-ads.yaml\")\n)\n\nfrom ads_mcp.coordinator import mcp_server\nfrom ads_mcp.scripts.generate_views import update_views_yaml\nfrom ads_mcp.tools import api\n\ndef main():\n    asyncio.run(update_views_yaml())\n    api.get_ads_client()\n    mcp_server.run(\n        transport=\"stdio\",\n        show_banner=False,\n    )\n\nif __name__ == \"__main__\":\n    main()\n</code></pre>\n<p>Save it to <code>~/.local/bin/google-ads-mcp-stdio</code>, make it executable:</p>\n<pre><code class=\"language-bash\">chmod +x ~/.local/bin/google-ads-mcp-stdio\n</code></pre>\n<p>Then in Claude Desktop's config (<code>~/Library/Application Support/Claude/claude_desktop_config.json</code>):</p>\n<pre><code class=\"language-json\">{\n  \"mcpServers\": {\n    \"google-ads-mcp\": {\n      \"command\": \"/Users/yourusername/.local/bin/google-ads-mcp-stdio\",\n      \"args\": []\n    }\n  }\n}\n</code></pre>\n<p>Relaunch Claude Desktop. No error dialog. The MCP tools show up in the chat input.</p>\n<h2 id=\"the-final-architecture\">The final architecture</h2>\n<pre><code>┌─────────────────────────────────────────────────────┐\n│                 ~/.google-ads.yaml                   │\n│              (shared credentials file)               │\n└──────────────────────┬──────────────────────────────┘\n                       │\n          ┌────────────┴────────────┐\n          │                         │\n   ┌──────▼──────┐          ┌──────▼──────┐\n   │  HTTP Server │          │ stdio wrapper│\n   │  port 8000   │          │   script     │\n   │  (launchd)   │          │ (on demand)  │\n   └──────┬──────┘          └──────┬──────┘\n          │                         │\n   ┌──────▼──────┐          ┌──────▼──────┐\n   │ Claude Code  │          │Claude Desktop│\n   │  (dev work)  │          │ (marketing)  │\n   └─────────────┘          └─────────────┘\n</code></pre>\n<p>Two clients, two transports, same underlying MCP code, same credentials. Claude Code connects over HTTP to a persistent background server. Claude Desktop spawns its own stdio process on launch.</p>\n<p>They don't share a server process, but they don't need to. There's no shared state. Both just authenticate against the Google Ads API independently.</p>\n<h2 id=\"why-this-setup-matters\">Why this setup matters</h2>\n<p>The point isn't just \"I connected a thing to another thing.\" It's about putting the right tools in the right context.</p>\n<p>When I'm in Claude Code working on the landing page, I can ask \"what search terms triggered clicks this week?\" and cross-reference that with PostHog funnel data without leaving my terminal. The code changes I make are informed by real campaign data, not a tab I glanced at an hour ago.</p>\n<p>When I'm in Claude Desktop doing marketing work, I can pull campaign reports, compare ad group performance, and brainstorm copy changes in a conversational flow. No spreadsheet intermediary.</p>\n<p>Same data source. Different workflows. That's the whole idea behind MCP: you connect your tools once and use them wherever the context calls for it.</p>\n<h2 id=\"the-gotchas\">The gotchas</h2>\n<p>A few things that will save you time:</p>\n<ol>\n<li>\n<p><strong><code>use_proto_plus: True</code> is required</strong> in your <code>google-ads.yaml</code>. The server crashes with a confusing <code>ValueError</code> without it. None of the setup guides mention this.</p>\n</li>\n<li>\n<p><strong>The transport is hardcoded.</strong> The Google Ads MCP server only runs in <code>streamable-http</code> mode. Claude Desktop only supports <code>stdio</code>. The wrapper script bridges this gap.</p>\n</li>\n<li>\n<p><strong>Developer token access levels matter.</strong> A test account token can only query test accounts. If you need real campaign data, you'll need to apply for Basic Access through Google's API Center. The approval can take a few days.</p>\n</li>\n<li>\n<p><strong>launchd vs. manual process.</strong> Don't just background the server with <code>&amp;</code>. It won't survive a reboot, and if it crashes at 2 AM, it stays crashed. The launchd plist with <code>KeepAlive: true</code> handles both.</p>\n</li>\n<li>\n<p><strong>pipx caching.</strong> If you use <code>pipx run</code> instead of <code>pipx install</code>, it creates cached venvs that can disappear. For a persistent service, install it properly.</p>\n</li>\n</ol>\n<h2 id=\"whats-next\">What's next</h2>\n<p>For now, the plumbing is done. Both clients are connected, the server starts on boot, and I can ask Claude about my Google Ads account from anywhere. The hard part, as always, was getting the credentials right and figuring out that two different Claude clients need two different transport protocols for the same MCP server. Nobody warns you about that one.</p>\n<p><strong>I used this prompt to generate featured image</strong>. A split-screen digital illustration showing two computer interfaces connected to a central glowing node. On the left, a dark terminal/CLI environment (Claude Code) with green text. On the right, a clean chat interface (Claude Desktop) with a modern UI. Both connect via glowing data streams to a central hub labeled with the Google Ads logo. The aesthetic is technical but approachable, with a warm color palette of blues, greens, and Google's signature colors. Isometric perspective, clean lines, subtle circuit board patterns in the background. Homelab/developer workspace vibe.</p>\n","comment_id":"69c828461340aa0001a5047e","feature_image":"https://emir.fyi/content/images/2026/03/f9f1049d-ee22-4549-8afa-3bb77726828a.png","featured":false,"visibility":"public","created_at":"2026-03-28T15:13:10.000-04:00","updated_at":"2026-04-01T10:00:40.000-04:00","published_at":"2026-04-01T10:00:40.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/giving-claude-desktop-and-claude-code-my-google-ads-account-on-purpose/","excerpt":"The actual problem\n\n\nI have a landing page (I am helping my wife generate leads for her business). It has a Google Ads campaign driving traffic to it. It also has PostHog tracking what visitors do once they arrive. Two systems, two dashboards, two browser tabs I keep open and alt-tab between when I'm trying to figure out if the campaign is actually working.\n\n\nThe workflow looks like this: check Google Ads to see which keywords are spending money, switch to PostHog to see if those visitors are ac","reading_time":6,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69b37b6ce62aae000136c0da","uuid":"77b08416-9ae4-4305-b330-252efcbdf7e2","title":"I Sat Down to Build an HA Kubernetes Cluster. I Did Not Build an HA Kubernetes Cluster.","slug":"i-sat-down-to-build-an-ha-kubernetes-cluster-i-did-not-build-an-ha-kubernetes-cluster","html":"<p>I sat down tonight to build an HA Kubernetes cluster. I had two BeeLink mini PCs collecting dust, freshly installed with Ubuntu 24.04, both connected to my network over WiFi. Simple plan: switch them to wired, assign static IPs, and start building.</p>\n<p><strong>TLDR</strong>: I did not build an HA Kubernetes cluster tonight.</p>\n<h2 id=\"the-setup\">The Setup</h2>\n<p>I've been running a single control plane K8s cluster with two worker nodes on Proxmox VMs for a while now. It works, but it's all on one physical host. If Proxmox goes down, everything goes down. Not exactly \"high availability.\" And I have real reasons to care about uptime:</p>\n<ul>\n<li><strong>This blog</strong> — emir.fyi runs on a self-hosted Ghost instance. When it's down, you can't read posts like this one. Ironic.</li>\n<li><strong>Vaultwarden</strong> - my password manager. When it's down, I can't log into anything. My wife can't log into anything. Nobody's happy.</li>\n<li><strong>Paperless</strong> - I've been paying a lot of bills (just like everyone else), and this is where I keep all the docs. I often need to go back and confirm I actually paid something because the mail keeps on coming.</li>\n<li><strong>Market Mind</strong> - a Rails app I built to help me trade options. It crunches market data, generates signals, and has helped me pull a 70% return over the last two years. When it dies mid-trading-day, I'm flying blind.</li>\n<li><strong>PostgreSQL</strong> - currently running as a standalone container on Portainer with zero failover. If that host goes down, both Market Mind and every other service backed by it go with it. I want this on the cluster so K8s can reschedule it to a healthy node if one goes down.</li>\n</ul>\n<p>What actually pushed me to do this <em>now</em> rather than \"eventually\" was hardware failure. One of the ECC RAM slots on my Proxmox server started acting up, the machine restarted on me a few times out of nowhere. Re-seating the DIMMs seems to have fixed it for now, and I've ordered a replacement stick, but \"seems to have fixed it\" isn't exactly confidence-inspiring. I want my services to survive the next time that machine decides to take an unscheduled nap.</p>\n<p>The BeeLink mini PCs were perfect for this. Small, quiet, low power, and already sitting in a drawer. I installed Ubuntu 24.04 on both using WiFi during setup, gave them hostnames n1 and n2 (node 1, node 2. Very creative, I know), mounted them in my server rack, and ran ethernet from the patch panel. The plan was straightforward:</p>\n<ol>\n<li>SSH in over WiFi</li>\n<li>Enable the wired interface</li>\n<li>Disable WiFi</li>\n<li>Move on with life</li>\n</ol>\n<p>I got through exactly one and a half of those steps before things went sideways.</p>\n<h2 id=\"the-quick-way-dont-do-this\">The Quick Way (Don't Do This)</h2>\n<p>I started with n2. SSHed in over WiFi, saw the wired interface was <code>DOWN</code> with <code>qdisc noop</code> (kernel-speak for \"I haven't even initialized this thing\"), and in my infinite wisdom decided to do it all in one shot. Replace the entire WiFi netplan config with wired-only and <code>netplan apply</code>.</p>\n<p>The SSH session hung. WiFi went down immediately. The wired interface? Also down. On these BeeLinks, the Realtek NIC exists and the driver loads, but it won't auto-negotiate a link until something explicitly brings it up. Netplan killed WiFi before the wired interface had a chance to come alive. No network, no SSH, no way back in.</p>\n<p>The machine was sitting headless in a closet. No monitor, no keyboard. Just a blinking power LED and regret.</p>\n<p>A normal person would hook up a monitor, keyboard, and mouse. A smart person would have a JetKVM or similar KVM over IP for exactly this scenario (JetKVM, if you're reading this, I'm still waiting on that sponsorship). I am neither of these people. I held the power button, waited, pressed it again, and hoped. It worked. pfSense showed a new DHCP lease on the wired MAC. Got lucky. If the config had been bad or the cable loose, I'd be dragging a monitor across the house like a normal person after all.</p>\n<h2 id=\"the-right-way\">The Right Way</h2>\n<p>With n1, I did it properly. The key insight: <strong>never remove your working network connection before verifying the replacement works.</strong> Obvious in retrospect.</p>\n<p>The approach: add wired alongside WiFi first, use <code>netplan try</code> (which auto-reverts if it breaks things), verify SSH works on the new wired IP, and <em>only then</em> remove WiFi in a second pass.</p>\n<p>The transitional netplan config looks like this. Both interfaces active, wired preferred via lower metric:</p>\n<pre><code class=\"language-yaml\">network:\n  version: 2\n  ethernets:\n    enp1s0:\n      addresses:\n        - 192.168.1.1.40/24\n      routes:\n        - to: default\n          via: 192.168.1.1\n          metric: 100\n      nameservers:\n        addresses:\n          - 192.168.1.1\n  wifis:\n    wlo1:\n      dhcp4: true\n      access-points:\n        \"MyWiFiNetwork-real-network-totally\":\n          auth:\n            key-management: \"psk\"\n            password: \"myrealpassword\"\n</code></pre>\n<p>The magic command is <code>netplan try --timeout 30</code> instead of <code>netplan apply</code>. It applies the config but <strong>automatically reverts after 30 seconds</strong> unless you confirm. If it breaks your connection, you don't have to go find a monitor. It rolls back on its own.</p>\n<p>After verifying SSH worked on the wired IP, I removed the <code>wifis</code> block, ran <code>netplan generate</code> to validate, then <code>netplan apply</code>. WiFi went down, wired stayed up. No drama.</p>\n<h2 id=\"final-state\">Final State</h2>\n<table>\n<thead>\n<tr>\n<th>Host</th>\n<th>IP</th>\n<th>Interface</th>\n<th>Status</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>n1.localdomain</td>\n<td>192.168.1.40</td>\n<td>enp1s0 (wired)</td>\n<td>Static, WiFi disabled</td>\n</tr>\n<tr>\n<td>n2.localdomain</td>\n<td>192.168.1.41</td>\n<td>enp1s0 (wired)</td>\n<td>Static, WiFi disabled</td>\n</tr>\n</tbody>\n</table>\n<p>Both machines are on static IPs, wired only, reachable via <code>.localdomain</code> hostnames (set via my DNS server). The networking took the entire evening.</p>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<p>The actual lesson here isn't about netplan syntax or Realtek drivers. It's about making changes to remote systems you can't physically access.</p>\n<p><strong>Never remove your working connection before verifying the replacement.</strong> Add the new thing alongside the old thing. Test it. Confirm it. Then remove the old thing. This applies to network interfaces, DNS servers, firewall rules. Any change where a mistake means you're locked out. The best part? I already knew all of this. But it's a homelab, it's a local network, and I was trying to move fast. The best part? I already knew all of this. But it's a homelab, it's a local network, and I was trying to move fast. The mental math was simple. If it works, I save five minutes and start building the cluster tonight. If it doesn't, I'm configuring HA Kubernetes some other night. Seemed like a worthwhile bet at the time. I would not take it again.</p>\n<p><code>netplan try</code> exists specifically for this. Use it. It's the difference between \"let me verify this works\" and \"let me go find an HDMI cable.\"</p>\n<p>The HA Kubernetes cluster will have to wait for another night. But at least now when I sit down to build it, both machines will be reachable over a wire, with static IPs like civilized infrastructure.</p>\n","comment_id":"69b37b6ce62aae000136c0da","feature_image":"https://emir.fyi/content/images/2026/03/ChatGPT-Image-Mar-12--2026--11_22_20-PM.png","featured":false,"visibility":"public","created_at":"2026-03-12T22:50:20.000-04:00","updated_at":"2026-03-25T09:47:02.000-04:00","published_at":"2026-03-25T09:47:02.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/i-sat-down-to-build-an-ha-kubernetes-cluster-i-did-not-build-an-ha-kubernetes-cluster/","excerpt":"I sat down tonight to build an HA Kubernetes cluster. I had two BeeLink mini PCs collecting dust, freshly installed with Ubuntu 24.04, both connected to my network over WiFi. Simple plan: switch them to wired, assign static IPs, and start building.\n\n\nTLDR: I did not build an HA Kubernetes cluster tonight.\n\n\n\nThe Setup\n\n\nI've been running a single control plane K8s cluster with two worker nodes on Proxmox VMs for a while now. It works, but it's all on one physical host. If Proxmox goes down, ever","reading_time":4,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69b96c111340aa0001a5045f","uuid":"42a01dc7-1b6b-4832-8419-e0abf756a7b2","title":"Adding Self-Hosted Comments to My Ghost Blog with Isso","slug":"adding-self-hosted-comments-to-my-ghost-blog-with-isso","html":"<h2 id=\"why-i-wanted-comments\">Why I wanted comments</h2><p>I write blog posts about my homelab projects. People read them, some of them find them useful, and I know this because occasionally someone messages me on Linkedin. But there's no way for readers to leave feedback right there on the post. No \"hey, I tried this and it worked\" or \"you missed a step here\" or \"this saved me three hours.\" That kind of feedback loop makes the posts better for everyone who comes after.</p><p>Ghost has built-in comments, but they require readers to create an account first. For a personal tech blog, that's a dealbreaker. Nobody is going to sign up for yet another account just to say \"thanks, this helped.\" I needed something where you type your name, type your comment, and hit submit.</p><p>And lastly, because I can hehe. This is what I do I host my own services.</p><h2 id=\"the-options-i-considered\">The options I considered</h2><p>I spent some time looking at what's out there for comment systems that don't require reader accounts. Here's what I found:</p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>System</th>\n<th>Free?</th>\n<th>Ads?</th>\n<th>Self-hosted?</th>\n<th>Guest comments?</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Disqus</td>\n<td>Free tier has ads</td>\n<td>Yes</td>\n<td>No</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td>giscus</td>\n<td>Yes</td>\n<td>No</td>\n<td>No (GitHub hosted)</td>\n<td>No (needs GitHub account)</td>\n</tr>\n<tr>\n<td>Cusdis</td>\n<td>Yes</td>\n<td>No</td>\n<td>Yes (Docker)</td>\n<td>Yes</td>\n</tr>\n<tr>\n<td>Isso</td>\n<td>Yes</td>\n<td>No</td>\n<td>Yes (Docker)</td>\n<td>Yes</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p><strong>Disqus</strong> was out immediately. The free tier injects ads into your comment section, and the paid plans start at $11/month. I didn't escape cloud camera subscriptions just to pay for cloud comment subscriptions.</p><p><strong>giscus</strong> is clever. It uses GitHub Discussions as the backend, so comments are stored in your repo's discussion threads. Clean UI, reactions, threading. But readers need a GitHub account to comment. Great for a developer tools blog, not great for a general audience.</p><p><strong>Cusdis</strong> is the minimalist option. Self-hostable, no account required, just nickname and comment. But it's almost too minimal. No markdown, no voting, no threading, no edit/delete. It felt like a textarea with a submit button.</p><p><strong>Isso</strong> hit the sweet spot. Self-hosted via Docker, anonymous comments with just a name, markdown support, upvote/downvote, threaded replies, and users can edit or delete their own comments within 15 minutes. It also has a built-in admin panel for moderation and uses SQLite, so there's no extra database to manage.</p><h2 id=\"setting-it-up\">Setting it up</h2><h3 id=\"the-architecture\">The architecture</h3><p>My blog runs on Ghost at <code>emir.fyi</code>, served through Cloudflare -&gt; cloudflared tunnel -&gt; Caddy (HA load balancer) -&gt; Ghost on Portainer (192.168.1.2). I needed to fit Isso into this without adding a new subdomain, new DNS records, new TLS certificates, or new Cloudflare tunnel configs.</p><p>The solution: serve Isso under a path on the same domain. Requests to <code>emir.fyi/isso/*</code> go to the Isso container, everything else still goes to Ghost. One Caddy rule, zero new infrastructure.</p><h3 id=\"docker-compose\">Docker Compose</h3><p>Following the same pattern as my other Portainer services:</p><pre><code class=\"language-yaml\">services:\n  isso:\n    image: ghcr.io/isso-comments/isso:release\n    container_name: isso\n    restart: unless-stopped\n    ports:\n      - \"8080:8080\"\n    volumes:\n      - /opt/isso/config:/config\n      - /opt/isso/db:/db\n    environment:\n      - TZ=America/New_York\n    healthcheck:\n      test: [\"CMD-SHELL\", \"wget -q -O /dev/null http://localhost:8080/info 2&gt;&amp;1 || exit 1\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n    logging:\n      driver: json-file\n      options:\n        max-size: \"10m\"\n        max-file: \"3\"\n</code></pre><p>Nothing fancy. SQLite database and config mounted as bind mounts to <code>/opt/isso/</code> on the host. The <code>release</code> tag pins to the latest stable version.</p><h3 id=\"isso-configuration\">Isso configuration</h3><pre><code class=\"language-ini\">[general]\ndbpath = /db/comments.db\nhost =\n    https://emir.fyi\nmax-age = 15m\ngravatar = true\n\n[server]\nlisten = http://0.0.0.0:8080\npublic-endpoint = https://emir.fyi/isso\n\n[moderation]\nenabled = false\npurge-after = 30d\n\n[guard]\nenabled = true\nratelimit = 2\ndirect-reply = 3\nreply-to-self = false\nrequire-author = true\nrequire-email = false\n\n[markup]\noptions = strikethrough, superscript, autolink\n\n[admin]\nenabled = true\npassword = your_strong_password_here\n</code></pre><p>A few things worth noting:</p><ul><li><strong><code>host</code></strong> must match your blog's URL exactly. Isso uses this for CORS, so if it doesn't match, comments will fail silently.</li><li><strong><code>public-endpoint</code></strong> tells Isso where it lives from the outside. This is the path that gets embedded in the comment widget.</li><li><strong><code>moderation = false</code></strong> because I want comments to post immediately. Life's too short to moderate a personal blog.</li><li><strong><code>guard</code></strong> is still on. Rate limiting (2 comments per minute per IP) and requiring an author name keeps the low-effort spam out.</li><li><strong><code>require-email = false</code></strong> because I don't want to create friction. Name and comment, that's it.</li></ul><h3 id=\"caddy-route\">Caddy route</h3><p>The only infrastructure change was adding a path handler to the existing <code>:80</code> block in the Caddyfile. This is the block that Cloudflare's tunnel hits for <code>emir.fyi</code>:</p><pre><code>:80 {\n  handle_path /isso/* {\n    reverse_proxy 192.168.1.2:8080\n  }\n\n  handle {\n    reverse_proxy 192.168.1.2:2368 {\n      header_up X-Forwarded-Proto https\n    }\n  }\n}\n</code></pre><p><code>handle_path</code> strips the <code>/isso/</code> prefix before forwarding to the Isso container, so Isso sees clean paths. Everything else falls through to Ghost. Deployed via the existing Ansible playbook across all three HA load balancer nodes.</p><h3 id=\"ghost-theme-integration\">Ghost theme integration</h3><p>The last piece is embedding the Isso widget in blog posts. I edited the Attila theme's <code>post.hbs</code> to include the Isso script:</p><pre><code class=\"language-html\">&lt;section class=\"post-comments\"&gt;\n    &lt;div id=\"isso-thread\"&gt;&lt;/div&gt;\n    &lt;script data-isso=\"https://emir.fyi/isso/\"\n            src=\"https://emir.fyi/isso/js/embed.min.js\"&gt;&lt;/script&gt;\n&lt;/section&gt;\n</code></pre><p>This goes right before the existing <code>{{#if comments}}</code> block. Ghost needs a restart to pick up the theme change.</p><h2 id=\"dark-mode\">Dark mode</h2><p>Isso's default styles assume a white background, which looks fine on light mode but terrible on dark mode. White input fields and invisible text on a dark page. Since my theme (Attila) toggles dark mode via a <code>theme-dark</code> class on the HTML element, I added CSS through Ghost's code injection (Settings -&gt; Code injection -&gt; Site Header):</p><pre><code class=\"language-css\">.theme-dark #isso-thread .isso-postbox .isso-textarea,\n.theme-dark #isso-thread .isso-postbox input[type=\"text\"] {\n  background-color: #2a2a2a;\n  color: #e0e0e0;\n  border-color: #555;\n}\n.theme-dark #isso-thread .isso-postbox input[type=\"submit\"],\n.theme-dark #isso-thread .isso-postbox input[name=\"preview\"] {\n  background-color: #444;\n  color: #e0e0e0;\n  border-color: #555;\n}\n.theme-dark #isso-thread .isso-comment .isso-text,\n.theme-dark #isso-thread .isso-comment .isso-comment-header {\n  color: #e0e0e0;\n}\n.theme-dark #isso-thread h4 {\n  color: #ccc;\n}\n.theme-dark #isso-thread a {\n  color: #6cb4ff;\n}\n</code></pre><p>That last rule is important. Isso renders Edit/Delete links in default blue, which is invisible on a dark background. Light blue (<code>#6cb4ff</code>) fixes that.</p><h2 id=\"the-website-field-validation-problem\">The website field validation problem</h2><p>Isso has an optional Website field where commenters can link their site. The problem is that Isso's server validates it strictly: if you type <code>example.com</code> without <code>https://</code>, it returns a 400 Bad Request with zero feedback in the UI. The comment just doesn't post and you have no idea why.</p><p>I couldn't change Isso's server-side validation, so I added client-side validation via Ghost's code injection (Site Footer) that highlights the field and updates the label when the URL format is wrong:</p><pre><code class=\"language-javascript\">document.addEventListener('DOMContentLoaded', function() {\n  var observer = new MutationObserver(function() {\n    var website = document.querySelector('#isso-postbox-website');\n    if (website &amp;&amp; !website.dataset.validated) {\n      website.dataset.validated = 'true';\n      var label = document.querySelector('label[for=\"isso-postbox-website\"]');\n      var originalText = label.textContent;\n\n      website.addEventListener('input', function() {\n        var val = website.value.trim();\n        if (val === '' || /^https?:\\/\\/.+/.test(val)) {\n          website.style.borderColor = '';\n          label.textContent = originalText;\n        } else {\n          website.style.borderColor = 'red';\n          label.textContent = originalText + ' - invalid';\n        }\n      });\n    }\n  });\n  observer.observe(document.body, {childList: true, subtree: true});\n});\n</code></pre><p>A MutationObserver because Isso's widget loads asynchronously into an iframe/div after the page renders. The validation fires on every keystroke: empty is fine (it's optional), anything starting with <code>http://</code> or <code>https://</code> is fine, anything else turns the border red and appends \" - invalid\" to the label. The form still submits either way, but at least the user can see something is wrong before they wonder why their comment disappeared.</p><h2 id=\"security-considerations\">Security considerations</h2><p>Since Isso is publicly accessible at <code>emir.fyi/isso/</code>, I thought about what's exposed:</p><ul><li><strong>Admin panel</strong> at <code>/isso/admin/</code> is password-protected with a strong password. No rate limiting on login attempts though, so the password matters.</li><li><strong>CORS</strong> restricts the comment API to requests from <code>emir.fyi</code> only. You can't post comments from a different origin.</li><li><strong>Rate limiting</strong> via the guard config prevents comment flooding (2 per minute per IP subnet).</li><li><strong>No reCAPTCHA or CAPTCHA support.</strong> Isso doesn't offer it. For a personal blog, Cloudflare's bot protection plus rate limiting should be enough. If spam becomes a problem, I'll revisit.</li></ul><p>The container itself is minimal. It runs as an unprivileged user, stores data in SQLite (no exposed database ports), and the only network exposure is port 8080 proxied through Caddy.</p><h2 id=\"what-it-looks-like\">What it looks like</h2><p>Comment form at the bottom of every post. Type your name, optionally your email (for Gravatar only, not displayed), optionally a website, write your comment in markdown, hit Submit. It posts immediately. You get 15 minutes to edit or delete your own comment. Upvote and downvote. Threaded replies.</p><p>No account creation. No OAuth flow. No \"sign in with Google.\" Just a comment box that works.</p><h2 id=\"what-id-do-differently\">What I'd do differently</h2><p>If I were starting fresh, I'd look harder at whether Isso is still actively maintained. The last release was 0.13.0 and the GitHub activity is sporadic. It works well today, but I'm mentally prepared to swap it out if it stops getting updates. The data is in a single SQLite file, so migration would be straightforward.</p><p>I'd also consider running Isso behind its own subdomain from the start if I had wildcard DNS and certs already set up. The path-based routing works fine, but a subdomain would be cleaner and wouldn't require the <code>handle_path</code> prefix stripping in Caddy.</p><p>But for a weekend project that adds real community interaction to a blog? Isso does exactly what I needed. No SaaS dependency, no ads, no reader accounts, and it runs on infrastructure I already had.</p>","comment_id":"69b96c111340aa0001a5045f","feature_image":"https://emir.fyi/content/images/2026/03/ChatGPT-Image-Mar-17--2026--11_10_24-AM.png","featured":false,"visibility":"public","created_at":"2026-03-17T10:58:25.000-04:00","updated_at":"2026-03-22T11:16:46.000-04:00","published_at":"2026-03-22T11:16:46.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/adding-self-hosted-comments-to-my-ghost-blog-with-isso/","excerpt":"Why I wanted comments\n\nI write blog posts about my homelab projects. People read them, some of them find them useful, and I know this because occasionally someone messages me on Linkedin. But there's no way for readers to leave feedback right there on the post. No \"hey, I tried this and it worked\" or \"you missed a step here\" or \"this saved me three hours.\" That kind of feedback loop makes the posts better for everyone who comes after.\n\nGhost has built-in comments, but they require readers to cre","reading_time":6,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69b95967cbda360001756cb1","uuid":"9751da81-9076-422c-a63a-a2ae7559ebae","title":"Frigate NVR 0.17 on Apple Silicon: Running AI Detection on the M1 Neural Engine","slug":"frigate-0-17-on-apple-silicon-running-ai-detection-on-the-m1-neural-engine","html":"<h2 id=\"background\">Background</h2><p>When I first set up <a href=\"https://emir.fyi/running-frigate-nvr-on-m1-mac-mini-with-truenas-nfs-storage/\">Frigate NVR on my M1 Mac Mini</a>, the AI detection worked, but it was running entirely on the CPU. Three Amcrest PoE cameras, each pushing a detect stream, with every frame getting processed through a CPU-based ONNX detector at ~29ms per inference. The M1 handled it, but the machine was running hot, skipping frames, and the recording pipeline kept backing up with warnings every five seconds about \"too many unprocessed recording segments.\" The M1 has this beautiful 16-core Neural Engine sitting right there, purpose-built for exactly this kind of workload, and I wasn't using it at all.</p><p>Frigate 0.17 changed that. The release introduced native Apple Silicon support through a ZMQ proxy architecture that lets the Neural Engine handle object detection from outside the Docker container. I had to try it.</p><h2 id=\"the-problem-with-docker-and-apple-silicon\">The problem with Docker and Apple Silicon</h2><p>Here's the catch that makes this interesting: Docker Desktop on macOS runs a Linux VM under the hood. Frigate lives inside that VM, and from inside a Linux container, you simply cannot access Apple's CoreML framework or the Neural Engine. It doesn't matter how much RAM you give Docker or how many CPU cores you allocate. The NPU is invisible to the container.</p><p>Before I figured this out, I actually thought the problem was resource allocation. I had Docker set to 12GB of RAM on a 16GB machine (leaving macOS gasping for air with 193MB free and 6.5GB in the compressor). I was hoping giving it more memory would somehow offset work to Apple's neural engine. Reducing Docker to 8GB helped the system breathe, but detection was still running on CPU.</p><h2 id=\"how-frigate-017-solves-it\">How Frigate 0.17 solves it</h2><p>The architecture is clever. Instead of trying to access the Neural Engine from inside the container, Frigate 0.17 supports a <a href=\"https://github.com/frigate-nvr/apple-silicon-detector?ref=emir.fyi\">ZMQ detector</a> that runs as a separate process on the Mac host. The flow looks like this:</p><ol><li>Frigate (inside Docker) sends video frames over TCP via ZMQ</li><li>The detector client (on the host) receives frames, runs ONNX inference through CoreML</li><li>CoreML routes the inference to the Neural Engine</li><li>Results come back to Frigate over the same ZMQ connection</li></ol><p>The latency added by this hop is negligible since both processes run on the same machine.</p><h2 id=\"setting-it-up\">Setting it up</h2><h3 id=\"what-you-need\">What you need</h3><ul><li>Mac with Apple Silicon (M1 or newer)</li><li>Frigate 0.17+ with the <code>standard-arm64</code> Docker image</li><li>Python 3.11 on the host (I used pyenv)</li><li>The <a href=\"https://github.com/frigate-nvr/apple-silicon-detector?ref=emir.fyi\">apple-silicon-detector</a> client</li><li>A YOLO model exported to ONNX format</li></ul><h3 id=\"step-1-install-python-311-via-pyenv\">Step 1: Install Python 3.11 via pyenv</h3><p>My M1 only had Python 3.9.6 from Xcode Command Line Tools. The detector needs 3.11+.</p><pre><code class=\"language-bash\">curl https://pyenv.run | bash\n</code></pre><p>Add to your <code>~/.zshrc</code>:</p><pre><code class=\"language-bash\">export PYENV_ROOT=\"$HOME/.pyenv\"\n[[ -d $PYENV_ROOT/bin ]] &amp;&amp; export PATH=\"$PYENV_ROOT/bin:$PATH\"\neval \"$(pyenv init -)\"\n</code></pre><p>Then install Python 3.11:</p><pre><code class=\"language-bash\">pyenv install 3.11\n</code></pre><h3 id=\"step-2-clone-and-install-the-detector\">Step 2: Clone and install the detector</h3><pre><code class=\"language-bash\">cd ~\ngit clone https://github.com/frigate-nvr/apple-silicon-detector.git\ncd apple-silicon-detector\npyenv local 3.11\nmake install\n</code></pre><h3 id=\"step-3-export-a-yolov9t-model\">Step 3: Export a YOLOv9t model</h3><p>The detector needs an ONNX model. Frigate doesn't ship one for Apple Silicon, so you need to export it yourself. I used <code>ultralytics</code> to grab YOLOv9t (the tiny variant, perfect for the M1) and export it at 320x320:</p><pre><code class=\"language-bash\"># Inside the apple-silicon-detector venv\nvenv/bin/pip3 install ultralytics onnx onnxslim\nvenv/bin/python3 -c \"\nfrom ultralytics import YOLO\nmodel = YOLO('yolov9t.pt')\nmodel.export(format='onnx', imgsz=320)\n\"\n</code></pre><p>Then copy it where Frigate can find it:</p><pre><code class=\"language-bash\">mkdir -p ~/frigate/config/model_cache\ncp yolov9t.onnx ~/frigate/config/model_cache/yolo.onnx\n</code></pre><p><strong>Note:</strong> If your pyenv Python was built without <code>lzma</code> support (common if you don't have <code>xz</code> installed), you'll hit a <code>ModuleNotFoundError: No module named '_lzma'</code> from torchvision. You can work around this by mocking the lzma module before importing ultralytics, or just install <code>xz</code> before building Python (<code>brew install xz &amp;&amp; pyenv install 3.11</code>).</p><h3 id=\"step-4-configure-frigate\">Step 4: Configure Frigate</h3><p>Update your <code>config.yml</code> to use the ZMQ detector and the YOLO model:</p><pre><code class=\"language-yaml\">detectors:\n  apple-silicon:\n    type: zmq\n    endpoint: tcp://host.docker.internal:5555\n\nmodel:\n  model_type: yolo-generic\n  width: 320\n  height: 320\n  input_tensor: nchw\n  input_dtype: float\n  path: /config/model_cache/yolo.onnx\n  labelmap_path: /labelmap/coco-80.txt\n</code></pre><p>The <code>host.docker.internal</code> hostname is how Docker containers reach the Mac host. Port 5555 is where the ZMQ detector listens.</p><h3 id=\"step-5-make-it-persistent-with-launchd\">Step 5: Make it persistent with launchd</h3><p>I ran the detector with <code>nohup</code> for testing, but it wouldn't survive a reboot. And I created a launchd service:</p><pre><code class=\"language-bash\">cat &gt; ~/Library/LaunchAgents/com.frigate.apple-silicon-detector.plist &lt;&lt; 'EOF'\n&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n  \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"&gt;\n&lt;plist version=\"1.0\"&gt;\n&lt;dict&gt;\n    &lt;key&gt;Label&lt;/key&gt;\n    &lt;string&gt;com.frigate.apple-silicon-detector&lt;/string&gt;\n    &lt;key&gt;ProgramArguments&lt;/key&gt;\n    &lt;array&gt;\n        &lt;string&gt;/path/to/apple-silicon-detector/venv/bin/python3&lt;/string&gt;\n        &lt;string&gt;/path/to/apple-silicon-detector/detector/zmq_onnx_client.py&lt;/string&gt;\n        &lt;string&gt;--endpoint&lt;/string&gt;\n        &lt;string&gt;tcp://*:5555&lt;/string&gt;\n        &lt;string&gt;--providers&lt;/string&gt;\n        &lt;string&gt;CoreMLExecutionProvider&lt;/string&gt;\n        &lt;string&gt;CPUExecutionProvider&lt;/string&gt;\n    &lt;/array&gt;\n    &lt;key&gt;WorkingDirectory&lt;/key&gt;\n    &lt;string&gt;/path/to/apple-silicon-detector&lt;/string&gt;\n    &lt;key&gt;RunAtLoad&lt;/key&gt;\n    &lt;true/&gt;\n    &lt;key&gt;KeepAlive&lt;/key&gt;\n    &lt;true/&gt;\n    &lt;key&gt;StandardOutPath&lt;/key&gt;\n    &lt;string&gt;/path/to/apple-silicon-detector/detector.log&lt;/string&gt;\n    &lt;key&gt;StandardErrorPath&lt;/key&gt;\n    &lt;string&gt;/path/to/apple-silicon-detector/detector.err&lt;/string&gt;\n&lt;/dict&gt;\n&lt;/plist&gt;\nEOF\n</code></pre><p>Load it:</p><pre><code class=\"language-bash\">launchctl load ~/Library/LaunchAgents/com.frigate.apple-silicon-detector.plist\n</code></pre><p>The <code>KeepAlive</code> key means launchd will restart the detector if it crashes. <code>RunAtLoad</code> means it starts automatically on login. Between this and Docker Desktop's own auto-start, the whole stack comes up on boot without touching anything.</p><h3 id=\"step-6-start-everything-up\">Step 6: Start everything up</h3><p>Start the detector first (or let launchd handle it), then start Frigate:</p><pre><code class=\"language-bash\">docker start frigate\n</code></pre><p>Check the detector log to confirm CoreML loaded:</p><pre><code>INFO - Loading ONNX model with providers: ['CoreMLExecutionProvider']\nINFO - Model input: images, shape: [1, 3, 320, 320], type: tensor(float)\nINFO - Model ready for inference\n</code></pre><h2 id=\"the-results\">The results</h2><p>I let it run overnight with three cameras. Here's what changed:</p>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Metric</th>\n<th>CPU Detector (before)</th>\n<th>Neural Engine (after)</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Inference speed</td>\n<td>28.8ms</td>\n<td>17.1ms</td>\n</tr>\n<tr>\n<td>Skipped frames</td>\n<td>91-109 fps skipped</td>\n<td>0</td>\n</tr>\n<tr>\n<td>Recording warnings</td>\n<td>Every 5 seconds</td>\n<td>None</td>\n</tr>\n<tr>\n<td>Detection CPU usage</td>\n<td>6% (detector process)</td>\n<td>0% (offloaded to NPU)</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<h3 id=\"after-12-hours-of-continuous-operation\">After 12 hours of continuous operation</h3>\n<!--kg-card-begin: html-->\n<table>\n<thead>\n<tr>\n<th>Camera</th>\n<th>Stream FPS</th>\n<th>Detection FPS</th>\n<th>Skipped</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Camera 1</td>\n<td>5.0</td>\n<td>15.4</td>\n<td>0</td>\n</tr>\n<tr>\n<td>Camera 2</td>\n<td>5.0</td>\n<td>28.5</td>\n<td>0</td>\n</tr>\n<tr>\n<td>Camera 3</td>\n<td>5.1</td>\n<td>8.0</td>\n<td>0</td>\n</tr>\n</tbody>\n</table>\n<!--kg-card-end: html-->\n<p>Total detection throughput: 51.9 FPS across all cameras. Zero frames dropped. The \"too many unprocessed recording segments\" warnings that plagued the CPU detector? Completely gone.</p><p>The overall system still runs warm (Frigate container uses ~94% of its allocated 4 CPU cores), but that's ffmpeg doing stream decoding and recording, not detection. The actual AI inference is invisible from a CPU perspective because it's happening on dedicated silicon.</p><h2 id=\"what-i-learned\">What I learned</h2><p>The M1's Neural Engine is genuinely underutilized in most homelab setups. Apple doesn't make it easy to access from non-native environments (hence the Docker workaround), but the Frigate team built an elegant solution with the ZMQ proxy pattern. The key insight is that you don't need the detection to happen inside the container. Shipping frames over a local TCP socket adds almost no latency, and you get to use hardware that would otherwise sit completely idle.</p><p>If you're running Frigate on Apple Silicon with the CPU detector, this upgrade is worth the 30 minutes it takes to set up. The M1 Mac Mini went from struggling with three cameras to handling them effortlessly, and I have headroom to add more without worrying about the detection pipeline falling behind.</p><p>The Mac Mini M1 continues to be one of the best value machines in my homelab. NVR with AI detection, monitoring hub, and it does it all silently with no fan noise and barely any power draw. Not bad for a machine Apple doesn't even sell anymore.</p>","comment_id":"69b95967cbda360001756cb1","feature_image":"https://emir.fyi/content/images/2026/03/ChatGPT-Image-Mar-17--2026--09_56_44-AM.png","featured":false,"visibility":"public","created_at":"2026-03-17T09:38:47.000-04:00","updated_at":"2026-03-18T10:08:34.000-04:00","published_at":"2026-03-18T10:08:34.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/frigate-0-17-on-apple-silicon-running-ai-detection-on-the-m1-neural-engine/","excerpt":"Background\n\nWhen I first set up Frigate NVR on my M1 Mac Mini, the AI detection worked, but it was running entirely on the CPU. Three Amcrest PoE cameras, each pushing a detect stream, with every frame getting processed through a CPU-based ONNX detector at ~29ms per inference. The M1 handled it, but the machine was running hot, skipping frames, and the recording pipeline kept backing up with warnings every five seconds about \"too many unprocessed recording segments.\" The M1 has this beautiful 16","reading_time":5,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69b546d0cbda360001756c6b","uuid":"f803b566-c80b-42c7-826f-8de5ead30955","title":"My Blog Told Me It Was Vulnerable. It Was Right.","slug":"my-blog-told-me-it-was-vulnerable-it-was-right","html":"<p>I self host my blog. The whole thing, Ghost CMS, MySQL database, all of it, runs on a Docker host in my house. A Cloudflare tunnel punches it out to the internet so you can read posts at emir.fyi without my home IP being exposed. It sits alongside a dozen other self hosted services on a machine I manage through Portainer: a note taking app, a document management system, a container registry, monitoring agents. The blog is the only one that faces the public internet.</p>\n<p>Why self host a blog in 2026? Honestly, because I can. I already have the infrastructure for my homelab, and running Ghost on hardware I control means no monthly bills, no platform risk, no \"we're pivoting to AI and shutting down the blogging product\" emails. Plus, I write about self hosting. It would be a little embarrassing to do that from a managed WordPress instance.</p>\n<p>The setup had been running fine for some time now. Ghost 6.14.0 on port 2368, MySQL 8.4 backing it, both as Docker containers on a shared network. Traffic flows in through Cloudflare, hits a Caddy reverse proxy on my HA load balancer, and lands on Ghost. Simple, stable, boring in the best way.</p>\n<p>Then I opened my Ghost admin panel and saw the kind of message that makes you put down your coffee:</p>\n<blockquote>\n<p>\"Update Ghost now: your Ghost site is vulnerable to an attack that lets unauthenticated attackers read arbitrary data from the database.\"</p>\n</blockquote>\n<p>Not \"hey, there's a minor update available.\" Not \"consider upgrading when convenient.\" <strong>Unauthenticated attackers can read your entire database.</strong> Cool. Love that for me.</p>\n<p>This is the part of self hosting nobody puts in the brochure. When you run your own stuff, <em>you're</em> the one who gets paged. There's no managed hosting provider absorbing the hit and pushing a patch while you sleep. The CVE lands, the banner appears, and it's on you.</p>\n<h2 id=\"the-vulnerability\">The Vulnerability</h2>\n<p>CVE-2026-26980 is a SQL injection in Ghost's Content API. The Content API is the public facing, no auth required API that serves your blog posts to the world. Somebody figured out you could inject SQL through it and read whatever you want from the database. User credentials, session tokens, email addresses, draft posts, everything.</p>\n<p>Affected versions: Ghost 3.24.0 through 6.19.0. I was running <strong>6.14.0</strong>. My blog is publicly accessible via Cloudflare tunnel, so this wasn't a theoretical risk. Anyone on the internet could have been reading my database like a book.</p>\n<p>CVSS score: 9.4. That's \"stop what you're doing and fix this now\" territory.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<p>Simple enough:</p>\n<ul>\n<li>[x] Update Ghost from 6.14.0 to 6.21.2 (latest)</li>\n<li>[x] Rotate all database credentials (if they could read the DB, they could read the passwords)</li>\n<li>[x] Actually put Ghost in a docker-compose file like a civilized person</li>\n<li>[x] Document the standard so this never happens again</li>\n</ul>\n<p>That third one is what turned a 10 minute patch into an evening project.</p>\n<h2 id=\"the-dirty-secret\">The Dirty Secret</h2>\n<p>When I went to update Ghost, I discovered something embarrassing. Every other service on my Portainer host (SiYuan, Paperless, Redis, Errbit etc.) had proper <code>docker-compose.yml</code> files. Source of truth in the homelab repo, deployed copies on the host, secrets in <code>.env</code> files. Clean. Reproducible. Professional, even.</p>\n<p>Ghost? The blog that's <em>publicly accessible on the internet</em>? The one service that actually has a CVE against it? Manually created through the Portainer UI. No compose file. No IaC. Just vibes and a container that's been running since whenever I clicked \"deploy\" long time ago.</p>\n<p>I had to <code>docker inspect</code> the container just to figure out what environment variables it was using. That's how you know your infrastructure management has gaps.</p>\n<h2 id=\"the-update\">The Update</h2>\n<p>First priority: get the vulnerable version off the internet. The actual patching process was straightforward since the data lives in Docker volumes that persist independently of the container.</p>\n<p>Here's the approach:</p>\n<ol>\n<li>Pull the new image</li>\n<li>Rotate the database credentials while MySQL is still running</li>\n<li>Stop and remove the old containers</li>\n<li>Deploy new ones with the updated image and new credentials</li>\n</ol>\n<p>The key thing to understand is that Ghost stores everything in two places. A <code>ghost-content</code> Docker volume holds themes, images, and settings. A MySQL database holds everything else. Both are external to the container. Swapping the container image is like changing the engine in a car while keeping all the cargo. Nothing gets lost.</p>\n<h3 id=\"rotating-credentials\">Rotating Credentials</h3>\n<p>Since the SQLi vulnerability could read arbitrary database data, I had to assume the MySQL passwords were compromised. That means rotating both the <code>ghost</code> database user and the <code>root</code> MySQL password.</p>\n<p>You change passwords in a running MySQL container like this:</p>\n<pre><code class=\"language-sql\">ALTER USER 'ghost'@'%' IDENTIFIED BY 'new-ghost-password-here';\nALTER USER 'root'@'localhost' IDENTIFIED BY 'new-root-password-here';\nFLUSH PRIVILEGES;\n</code></pre>\n<p>One gotcha: the <code>MYSQL_ROOT_PASSWORD</code> and <code>MYSQL_PASSWORD</code> environment variables in a MySQL container are only used on <strong>first initialization</strong>. Changing them in your compose file doesn't change the actual passwords in the database. You have to change them in MySQL first, <em>then</em> update the compose config to match. If you do it the other way around, your new container will try to connect with the new password and MySQL will say no.</p>\n<h3 id=\"the-docker-compose-file\">The Docker Compose File</h3>\n<p>Here's what the Ghost stack looks like properly codified:</p>\n<pre><code class=\"language-yaml\">services:\n  ghost:\n    image: ghost:6.21.2\n    container_name: emir.fyi-blog\n    restart: always\n    ports:\n      - \"2368:2368\"\n    volumes:\n      - ghost-content:/var/lib/ghost/content\n    env_file: .env\n    environment:\n      url: https://yourdomain.com\n      database__client: mysql\n      database__connection__host: mysql-db\n      database__connection__user: ghost\n      database__connection__database: ghost\n    depends_on:\n      mysql:\n        condition: service_healthy\n    healthcheck:\n      test: [\"CMD-SHELL\", \"node -e \\\"const h=require('http');h.get('http://localhost:2368/ghost/api/admin/site/',r=&gt;{process.exit(r.statusCode&lt;500?0:1)}).on('error',()=&gt;process.exit(1))\\\"\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n\n  mysql:\n    image: mysql:8.4\n    container_name: mysql-db\n    restart: always\n    ports:\n      - \"3306:3306\"\n    volumes:\n      - mysql-data:/var/lib/mysql\n    env_file: .env\n    environment:\n      MYSQL_DATABASE: ghost\n      MYSQL_USER: ghost\n    healthcheck:\n      test: [\"CMD-SHELL\", \"mysqladmin ping -u root -p\\\"$$MYSQL_ROOT_PASSWORD\\\"\"]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n\nvolumes:\n  ghost-content:\n    external: true\n  mysql-data:\n    external: true\n</code></pre>\n<p>The <code>.env</code> file (which never gets committed to the repo) holds the secrets:</p>\n<pre><code>database__connection__password=your-ghost-db-password\nMYSQL_PASSWORD=your-ghost-db-password\nMYSQL_ROOT_PASSWORD=your-mysql-root-password\n</code></pre>\n<p>Notice the volumes are marked <code>external: true</code>. That means Docker won't try to create new ones. It'll use the existing volumes with all your data intact. This is what makes the upgrade safe. Your posts, your themes, your images, they're all in those volumes. The container is just the runtime.</p>\n<p>The <code>depends_on</code> with <code>condition: service_healthy</code> is important too. Ghost will crash on startup if MySQL isn't ready yet. Without the health dependency, Docker starts both containers simultaneously and Ghost tries to connect to a database that's still initializing. Ask me how I know.</p>\n<h2 id=\"establishing-a-standard\">Establishing a Standard</h2>\n<p>While fixing this, I realized I should document how container management works on Portainer so I don't end up with another rogue manually created container. Here's the standard I landed on:</p>\n<p><strong>Source of truth:</strong> <code>docker/&lt;service&gt;/docker-compose.yml</code> in the homelab Git repo. This is what gets version-controlled, reviewed, and tracked.</p>\n<p><strong>Deployed to:</strong> <code>/opt/&lt;service&gt;/compose/</code> on the Portainer host. This is where the running config lives.</p>\n<p><strong>Secrets:</strong> <code>.env</code> file in the compose directory on the host. A <code>.env.example</code> with placeholder values gets committed to the repo so someone (future me) knows what variables are needed.</p>\n<p><strong>Deploy/update:</strong> <code>scp</code> the compose file to the host, then <code>cd /opt/&lt;service&gt;/compose &amp;&amp; docker compose up -d</code>.</p>\n<p><strong>Healthchecks required:</strong> Every container gets a healthcheck. No exceptions. Beszel monitors container health, and a container without a healthcheck is invisible to monitoring.</p>\n<p>It's simple, it's boring, and it means I'll never have to <code>docker inspect</code> a container to figure out how it was configured again. (I sure hope I don't regret saying never, again. Not gonna happen take 33 I guess)</p>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<p>The technical fix here took maybe 15 minutes. Pull image, rotate credentials, deploy. The part that took longer, and mattered more, was realizing that my most publicly exposed service was the least well managed one.</p>\n<p>There's a pattern in homelabs (and honestly, in production environments too) where the first thing you set up gets the least love. You deploy Ghost early on, it works, you move on to shinier projects. Six months later every other service has proper IaC and the blog is held together by a manually created container and muscle memory.</p>\n<p>CVE-2026-26980 was the kick I needed to fix that. If the vulnerability hadn't forced my hand, Ghost would probably still be running on vibes. Sometimes it takes a 9.4 CVSS score to make you do the housekeeping you've been putting off.</p>\n<p>The blog is now running Ghost 6.21.2 with fresh credentials, a proper compose file, and a documented standard for how every container on Portainer should be managed. Not bad for a Saturday morning. Done with all this before my kids woke up.</p>\n","comment_id":"69b546d0cbda360001756c6b","feature_image":"https://emir.fyi/content/images/2026/03/ChatGPT-Image-Mar-14--2026--07_56_14-AM.png","featured":false,"visibility":"public","created_at":"2026-03-14T07:30:24.000-04:00","updated_at":"2026-03-14T07:56:37.000-04:00","published_at":"2026-03-14T07:52:15.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/my-blog-told-me-it-was-vulnerable-it-was-right/","excerpt":"I self host my blog. The whole thing, Ghost CMS, MySQL database, all of it, runs on a Docker host in my house. A Cloudflare tunnel punches it out to the internet so you can read posts at emir.fyi without my home IP being exposed. It sits alongside a dozen other self hosted services on a machine I manage through Portainer: a note taking app, a document management system, a container registry, monitoring agents. The blog is the only one that faces the public internet.\n\n\nWhy self host a blog in 202","reading_time":6,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69b2c54ae62aae000136c0a3","uuid":"9ce0cf93-f541-4395-b75b-554cb20947a4","title":"Running Frigate NVR on M1 Mac Mini with TrueNAS NFS Storage","slug":"running-frigate-nvr-on-m1-mac-mini-with-truenas-nfs-storage","html":"<h2 id=\"why-did-i-do-this\">Why did I do this</h2>\n<p>I got tired of changing Blink camera batteries and of the thought that someone in the cloud might have recordings of my family's every move.</p>\n<p>That's it. That's the origin story. Every now and then I'd get the notification, trudge outside, swap batteries, and wonder why I was paying a cloud subscription for the privilege of watching grainy clips of my own front yard on someone else's server. So I decided to fix it. Overkill style.</p>\n<p>I'm a homelabber. I build things to learn things. Half the reason any project gets a green light in my house is because there's something new I get to figure out along the way. This project had a lot of those moments. It was my first time using a PoE switch. It had been a while since I'd fished cable through an attic or soffits, and even longer since I'd worked with PVC conduit outside, cementing it and pulling cable through. Good excuse to dust off those skills. And then Frigate itself was completely new territory for me.</p>\n<p>The end goal is straightforward: three wired cameras to start, seven days of continuous recordings, and AI detection for people, cars, and animals. Eventually, when the whole property is covered, I want to figure out who keeps leaving dog poop on my lawn. I haven't worked out what happens after I identify them. Confrontation? A politely worded letter? A wall of shame? That's a problem for future me.</p>\n<p>The surveillance software is <a href=\"https://frigate.video/?ref=emir.fyi\">Frigate</a>, an open source NVR with built-in AI object detection. I'd never run it before, but it looked like one of those cool open source projects that punches way above its weight. It is.</p>\n<h2 id=\"the-inspiration\">The Inspiration</h2>\n<p>I watched <a href=\"https://www.youtube.com/watch?v=example&ref=emir.fyi\">NetworkChuck's video</a> where he set up Frigate with Reolink cameras and a Google Coral TPU on a Raspberry Pi, and later scaled it to a gaming PC. Great video. But I had different hardware laying around and different priorities, so my setup diverged pretty quickly:</p>\n<table>\n<thead>\n<tr>\n<th>NetworkChuck's Setup</th>\n<th>My Setup</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Raspberry Pi / Gaming PC</td>\n<td>M1 Mac Mini (collecting dust)</td>\n</tr>\n<tr>\n<td>Google Coral TPU ($100)</td>\n<td>M1 Neural Engine via <a href=\"https://github.com/frigate-nvr/apple-silicon-detector?ref=emir.fyi\">Apple Silicon detector</a> (built-in, free)</td>\n</tr>\n<tr>\n<td>Reolink cameras (wireless)</td>\n<td>Amcrest cameras (wired PoE)</td>\n</tr>\n<tr>\n<td>Local storage / external drive</td>\n<td>TrueNAS NAS via NFS (22TB)</td>\n</tr>\n</tbody>\n</table>\n<p>Going wired was deliberate. Chuck's biggest headache was 10 wireless cameras destroying his WiFi network with RTSP retransmits. His TX retry rate hit 29%. He had to add a third access point, stagger camera reboots, and switch to constant bit rate just to keep things from falling apart. None of that is a problem with Ethernet.</p>\n<p>The M1 Mac Mini was just sitting in a drawer. It has a Neural Engine that can handle AI inference without buying a separate accelerator. And the TrueNAS NAS was already running with 22TB of storage, so pointing Frigate at it seemed obvious. It was not obvious. More on that shortly.</p>\n<blockquote>\n<p><strong>Googling one of these?</strong> You're in the right place.</p>\n<p><code>docker desktop macOS NFS volume mount NAS \"permission denied\"</code> / <code>docker desktop mac \"host_mnt\" \"operation not permitted\"</code> / <code>frigate NVR macOS Docker Desktop NAS storage</code> / <code>frigate docker compose NFS volume TrueNAS</code> / <code>TrueNAS SCALE NFS \"allow non-root mount\" Docker</code> / <code>frigate NVR Mac mini Apple Silicon M1 setup</code> / <code>frigate amcrest camera RTSP macOS docker</code> / <code>frigate config KeyError ffmpeg disabled camera</code></p>\n</blockquote>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Get Frigate running on Docker Desktop macOS</s> (done)</li>\n<li><s>Configure NAS storage via NFS</s> (done, eventually)</li>\n<li><s>Add Amcrest cameras one at a time with RTSP</s> (done, 2 cameras)</li>\n<li><s>Enable AI object detection (person, car, dog, cat)</s> (done)</li>\n<li><s>Enable recording, continuous + event-based</s> (done)</li>\n<li><s>Set up go2rtc for efficient stream handling</s> (done)</li>\n<li>Configure facial recognition and semantic search</li>\n<li>Connect to Home Assistant (optional)</li>\n<li>Identify the lawn pooper (pending)</li>\n</ol>\n<h2 id=\"the-nas-storage-saga\">The NAS Storage Saga</h2>\n<p>This was supposed to be the easy part. I have a TrueNAS box with 22TB of storage sitting on my network. Frigate needs somewhere to store recordings. Just point one at the other, right?</p>\n<p>I spent more time getting NAS storage working than any other part of this project. If you're running Frigate on Linux, you mount your NFS share and move on with your life. On Docker Desktop for macOS, nothing works the way you expect. What follows is every wall I hit and how I got past it.</p>\n<h3 id=\"why-nfs-over-smb\">Why NFS Over SMB?</h3>\n<p>Both NFS and SMB can share files over a network, but NFS is the better choice for Frigate:</p>\n<ul>\n<li><strong>Performance:</strong> NFS has lower protocol overhead. Frigate is I/O heavy, cameras can generate 3GB/hr per stream. SMB's chattier protocol adds latency on every file operation, which compounds across multiple cameras writing continuously.</li>\n<li><strong>Permissions:</strong> Both the NAS (Linux-based TrueNAS) and the Docker container (Linux) speak POSIX natively. NFS passes POSIX permissions cleanly. SMB has to translate between Windows-style ACLs and POSIX, which causes permission headaches. Especially when Frigate needs to create directories, write recordings, and manage retention.</li>\n<li><strong>Reliability for continuous writes:</strong> NFS handles the constant stream of small file writes (recording segments) more gracefully. SMB connections are more prone to stalling or dropping under sustained write loads, which can cause Frigate to error out or fall behind on recordings.</li>\n<li><strong>Simplicity:</strong> No username/password authentication to configure. NFS uses host-based access control, which is simpler for a trusted LAN environment.</li>\n</ul>\n<h3 id=\"three-approaches-two-of-them-dont-work\">Three Approaches (Two of Them Don't Work)</h3>\n<p>When trying to connect Frigate on Docker Desktop (macOS) to NAS storage, there are three approaches. I tried all of them.</p>\n<h3 id=\"option-a-mount-nfs-on-macos-bind-mount-into-docker\">Option A: Mount NFS on macOS, bind-mount into Docker</h3>\n<p>Mount the NFS share on macOS first (<code>sudo mount -t nfs ...</code>), then reference that path in docker-compose volumes. This is the standard approach on Linux.</p>\n<p><strong>Does NOT work on Docker Desktop for macOS.</strong> Docker Desktop's Linux VM translates host paths to <code>/host_mnt/...</code> and cannot traverse NFS mount points on the host. You'll get <code>operation not permitted</code> errors.</p>\n<h3 id=\"option-b-docker-nfs-volume-driver-what-worked\">Option B: Docker NFS volume driver (what worked)</h3>\n<p>Let Docker's Linux VM mount NFS directly from the NAS, bypassing macOS entirely. Define an NFS volume in docker-compose with <code>driver: local</code> and <code>driver_opts</code>. The VM talks directly to the NAS.</p>\n<p><strong>This is what works.</strong> Requires enabling \"Allow non-root mount\" on TrueNAS so Docker's VM can connect from non-privileged ports. <strong>THIS IS THE MOST IMPORTANT SETTING.</strong></p>\n<h3 id=\"option-c-smbcifs-volume\">Option C: SMB/CIFS volume</h3>\n<p>Similar to Option B but using SMB instead of NFS. Docker supports CIFS volumes with <code>type: cifs</code> in driver_opts. Requires username/password configuration and has the performance drawbacks mentioned above. A viable fallback if NFS isn't an option.</p>\n<h3 id=\"known-issues-and-gotchas\">Known Issues and Gotchas</h3>\n<ul>\n<li><strong><code>/host_mnt</code> path translation bug:</strong> This is a <a href=\"https://github.com/docker/for-mac/issues/5390?ref=emir.fyi\">known Docker Desktop for Mac issue</a>. Docker Desktop's VM prefixes host paths with <code>/host_mnt/</code> and cannot handle NFS/network mount points on the host. Switching the file sharing backend from VirtioFS to osxfs has been reported as a workaround but is unreliable.</li>\n<li><strong>NAS offline at boot:</strong> If the NFS share isn't available when Frigate starts, Frigate will create a local <code>/media/frigate</code> directory inside the container and fill up local storage. Using <code>soft</code> in NFS mount options helps Frigate fail gracefully rather than hang. The <code>restart: unless-stopped</code> policy will keep retrying.</li>\n<li><strong>No USB Coral TPU passthrough on macOS:</strong> Docker Desktop on macOS does not support USB device passthrough, so you <a href=\"https://github.com/blakeblackshear/frigate/discussions/1247?ref=emir.fyi\">cannot use a Coral TPU</a>. But here's the thing: you don't need one. M1/M2 CPUs achieve 7-11ms inference speeds on their own, which is comparable to a Coral. And as of Frigate 0.17, there's an official <a href=\"https://github.com/frigate-nvr/apple-silicon-detector?ref=emir.fyi\">Apple Silicon detector</a> that runs natively on macOS using CoreML and the Neural Engine. It runs as a separate app alongside Docker and connects via ZMQ. Users report ~11ms inference with multiple 4K cameras at just 10W power consumption. This is on the roadmap for this project.</li>\n<li><strong>Storage consumption:</strong> Frigate is storage-hungry. A single 4K camera stream can use ~3GB/hr. Plan your NAS storage accordingly and configure Frigate's retention settings in <code>config.yml</code> to auto-delete old recordings.</li>\n</ul>\n<h2 id=\"the-challenges-aka-the-interesting-part\">The Challenges (a.k.a. The Interesting Part)</h2>\n<p>What follows is every problem I ran into, in order. If you're here from Google, you probably hit one of these exact errors. Welcome. I feel your pain.</p>\n<h3 id=\"challenge-1-docker-desktop-cannot-bind-mount-nfs-paths-from-macos\">Challenge 1: Docker Desktop Cannot Bind-Mount NFS Paths from macOS</h3>\n<p>My first attempt was the obvious one. Mount the NFS share on macOS, then tell Docker to use that path. Simple. Except:</p>\n<pre><code>Error response from daemon: error while creating mount source path '/host_mnt/Volumes/frigate':\nmkdir /host_mnt/Volumes/frigate: operation not permitted\n</code></pre>\n<p><strong>Why:</strong> Docker Desktop on macOS runs inside a Linux VM. Host paths get translated to <code>/host_mnt/...</code> inside the VM. NFS mount points on macOS don't pass through this translation layer and the VM can't see or create them.</p>\n<p><strong>Solution:</strong> Don't mount NFS on macOS at all. Let Docker's Linux VM mount NFS directly from the NAS using Docker's built-in NFS volume driver:</p>\n<pre><code class=\"language-yaml\">volumes:\n  frigate-media:\n    driver: local\n    driver_opts:\n      type: nfs\n      o: addr=192.168.1.7,rw,nfsvers=3,nolock,soft\n      device: \":/mnt/Storage/frigate\"\n</code></pre>\n<h3 id=\"challenge-2-docker-nfs-volume-mount-permission-denied\">Challenge 2: Docker NFS Volume Mount, Permission Denied</h3>\n<p>Great, so Docker needs to mount NFS directly. I set that up, ran <code>docker compose up</code>, and:</p>\n<pre><code>failed to mount local volume: mount :/mnt/Storage/frigate:...: permission denied\n</code></pre>\n<p><strong>Why:</strong> Docker Desktop's Linux VM connects to NFS from a non-privileged port (above 1024). By default, NFS servers reject connections from non-privileged ports, a security holdover from the Unix days where only root could use ports below 1024.</p>\n<p><strong>Solution:</strong> On TrueNAS (tested on v25.04.2.4), enable <strong>\"Allow non-root mount\"</strong>:</p>\n<ol>\n<li>Go to <strong>System &gt; Services &gt; NFS</strong></li>\n<li>Click the edit/config icon</li>\n<li>Check <strong>\"Allow non-root mount\"</strong></li>\n<li>Save and restart the NFS service</li>\n</ol>\n<h3 id=\"challenge-3-nfs-share-write-permissions\">Challenge 3: NFS Share Write Permissions</h3>\n<p>At this point I was getting good at reading the words \"Permission denied.\" This time the mount succeeded, but writing to it didn't.</p>\n<p><strong>Why:</strong> The NFS share's default permissions don't map the connecting user to a user with write access on the NAS filesystem.</p>\n<p><strong>Solution:</strong> On the TrueNAS NFS share settings for the Frigate dataset:</p>\n<ol>\n<li>Set <strong>Mapall User</strong> to <code>root</code></li>\n<li>Set <strong>Mapall Group</strong> to <code>wheel</code></li>\n</ol>\n<p>This maps all connecting users (including Docker) to root on the NAS, granting full read/write. Acceptable for a dedicated Frigate media folder.</p>\n<h3 id=\"challenge-4-frigate-config-disabled-camera-crash\">Challenge 4: Frigate Config, Disabled Camera Crash</h3>\n<p>NAS finally working. Time to actually run Frigate. It crashed immediately.</p>\n<pre><code>KeyError: 'ffmpeg'\n</code></pre>\n<p><strong>Why:</strong> Even disabled cameras in <code>config.yml</code> require an <code>ffmpeg.inputs</code> section. Frigate validates the config structure before checking if the camera is enabled.</p>\n<p><strong>Solution:</strong> Always include the <code>ffmpeg</code> block, even for placeholder/disabled cameras:</p>\n<pre><code class=\"language-yaml\">cameras:\n  dummy_camera:\n    enabled: false\n    ffmpeg:\n      inputs:\n        - path: rtsp://127.0.0.1:8554/dummy\n          roles:\n            - detect\n</code></pre>\n<h3 id=\"challenge-5-rtsp-authentication-fails-because-of-a-question-mark\">Challenge 5: RTSP Authentication Fails Because of a Question Mark</h3>\n<p>This one was maddening. I could see the camera feed in the Amcrest web UI. I could pull frames with ffmpeg manually. But Frigate kept saying <code>401 Unauthorized</code> or <code>wrong user/pass</code>. The password was correct. I triple-checked.</p>\n<p><strong>Why:</strong> RTSP URLs follow standard URL format (<code>rtsp://user:pass@host/path</code>). Characters like <code>?</code>, <code>@</code>, <code>:</code>, <code>#</code>, <code>&amp;</code> in the password break URL parsing. Both ffmpeg and go2rtc interpret <code>?</code> as the start of a query string, not part of the password. URL-encoding (<code>%3F</code>) gets double-encoded depending on whether the value is quoted in YAML.</p>\n<p><strong>Solution:</strong> Change your camera password to avoid special URL characters. Stick to letters and numbers. This is easier than fighting encoding issues across multiple layers (YAML → go2rtc → ffmpeg → RTSP).</p>\n<p>Avoid in camera passwords: <code>?</code> <code>@</code> <code>:</code> <code>#</code> <code>&amp;</code> <code>%</code> <code>/</code></p>\n<h3 id=\"challenge-6-using-go2rtc-as-a-stream-proxy\">Challenge 6: Using go2rtc as a Stream Proxy</h3>\n<p>This one isn't really a problem. It's more of a \"you should do this and here's why.\"</p>\n<p>Without go2rtc, every viewer of your camera (Frigate detection, Frigate recording, Home Assistant, your phone) opens a separate RTSP connection directly to the camera. That's four connections to one camera. Multiply by ten cameras and your network is doing unnecessary work. Some cameras don't even handle multiple concurrent RTSP connections well.</p>\n<p>Frigate already includes go2rtc, a lightweight stream proxy. It connects to the camera once and re-serves the stream locally. All consumers read from <code>rtsp://127.0.0.1:8554/stream_name</code> instead of hitting the camera directly. This is exactly what NetworkChuck did in his video to fix his WiFi issues, and it's even more important if you plan to add Home Assistant later.</p>\n<p><strong>Config:</strong></p>\n<pre><code class=\"language-yaml\">go2rtc:\n  streams:\n    front_door_sub:\n      - rtsp://admin:yourpassword@192.168.1.30:554/cam/realmonitor?channel=1&amp;subtype=1\n\ncameras:\n  front_door:\n    ffmpeg:\n      inputs:\n        - path: rtsp://127.0.0.1:8554/front_door_sub\n          roles:\n            - detect\n</code></pre>\n<p>This is especially important if you plan to add Home Assistant or view streams from multiple devices.</p>\n<h2 id=\"adding-amcrest-cameras\">Adding Amcrest Cameras</h2>\n<p>With NAS storage sorted and Frigate running, this is where it actually starts feeling like a surveillance system. I unboxed one camera at a time, got it fully working end-to-end before moving to the next. Resist the urge to plug them all in at once. Trust me.</p>\n<h3 id=\"initial-camera-setup\">Initial Camera Setup</h3>\n<ol>\n<li>Plug camera into PoE switch and it powers on and gets a DHCP address</li>\n<li>Find the camera's IP and check your router's DHCP lease list, or broadcast ping and check ARP:<pre><code class=\"language-bash\">ping -c 1 192.168.1.255 &amp;&amp; arp -a\n</code></pre>\n</li>\n<li>Verify it's the camera by checking Amcrest's default ports:<pre><code class=\"language-bash\">nc -z -w 3 192.168.1.X 80 &amp;&amp; echo \"HTTP open\"   # Web UI\nnc -z -w 3 192.168.1.X 554 &amp;&amp; echo \"RTSP open\"   # Video stream\nnc -z -w 3 192.168.1.X 37777 &amp;&amp; echo \"API open\"   # Amcrest API\n</code></pre>\n</li>\n<li>Open the web UI at <code>http://CAMERA_IP</code> and set a password (avoid special URL characters like <code>?@:#&amp;</code>)</li>\n<li>Assign a static IP on your router (Optional)</li>\n<li>Disable all on-camera smart features (Setup &gt; Event):\n<ul>\n<li>Motion Detection → uncheck Enable</li>\n<li>Video Tamper → uncheck Enable, Record, Snapshot</li>\n<li>Audio Detection → uncheck all</li>\n<li>Any AI/IVS/Smart Plan → disable</li>\n</ul>\n</li>\n</ol>\n<p>The camera should be a dumb video pipe, Frigate handles all the AI.</p>\n<h3 id=\"amcrest-rtsp-url-format\">Amcrest RTSP URL Format</h3>\n<pre><code>Main stream (high-res, for recording):\nrtsp://admin:PASSWORD@CAMERA_IP:554/cam/realmonitor?channel=1&amp;subtype=0\n\nSubstream (low-res, for AI detection):\nrtsp://admin:PASSWORD@CAMERA_IP:554/cam/realmonitor?channel=1&amp;subtype=1\n</code></pre>\n<h3 id=\"dual-stream-architecture\">Dual Stream Architecture</h3>\n<p>Each camera sends two RTSP streams to Frigate via go2rtc:</p>\n<ul>\n<li><strong>Main stream</strong> (<code>subtype=0</code>), full resolution (e.g. 2960x1668 or 3840x2160), used for <strong>recording</strong>. This is the high-quality footage you review.</li>\n<li><strong>Substream</strong> (<code>subtype=1</code>), lower resolution (e.g. 704x480), used for <strong>AI detection</strong>. The AI doesn't need 4K to spot a person, lower res means less CPU/GPU work.</li>\n</ul>\n<p>go2rtc proxies both streams locally so Frigate (and any other viewer) connects to <code>127.0.0.1:8554</code> instead of hitting the camera directly.</p>\n<h2 id=\"recording-strategy\">Recording Strategy</h2>\n<p>Here's where the 22TB NAS earns its keep. I wanted both: continuous recording as a safety net (in case AI misses something), plus longer retention for events that actually matter. Frigate 0.17 supports exactly this with layered retention:</p>\n<pre><code class=\"language-yaml\">record:\n  enabled: true\n  continuous:\n    days: 7         # Keep ALL footage for 7 days, then auto-delete\n  alerts:\n    retain:\n      days: 30      # Person/car detections kept 30 days\n  detections:\n    retain:\n      days: 14      # Dog/cat detections kept 14 days\n</code></pre>\n<ul>\n<li><strong>Continuous</strong> = everything, 24/7. Your safety net even if AI misses something, you have 7 days to go back and find it.</li>\n<li><strong>Alerts</strong> = high-priority objects (person, car by default). Kept longer because these matter most.</li>\n<li><strong>Detections</strong> = other tracked objects (dog, cat). Kept shorter but still longer than continuous.</li>\n</ul>\n<h3 id=\"storage-estimates\">Storage Estimates</h3>\n<p><em>Updated March 18, 2026 with real usage data after running three cameras for over a week.</em></p>\n<p>These are real numbers from my setup, not theoretical calculations. After running three cameras for over a week, here's what the NAS is eating per stream:</p>\n<table>\n<thead>\n<tr>\n<th>Stream</th>\n<th>Resolution</th>\n<th>Bitrate</th>\n<th>Per Hour</th>\n<th>Per Day</th>\n<th>7 Days</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>4K main</td>\n<td>3840x2160</td>\n<td>~15 Mbps</td>\n<td>~6.7 GB</td>\n<td>~64 GB</td>\n<td>~450 GB</td>\n</tr>\n<tr>\n<td>5MP main</td>\n<td>2960x1668</td>\n<td>~8 Mbps</td>\n<td>~3.6 GB</td>\n<td>~33 GB</td>\n<td>~230 GB</td>\n</tr>\n</tbody>\n</table>\n<p>With all three cameras recording continuously, actual total usage is <strong>~120 GB/day</strong> (~40 GB/day per camera). After a full week of 7-day retention, storage settled at <strong>~650 GB</strong> on the 22TB NAS. With 22TB available, one could comfortably run 15+ cameras at 7-day continuous retention before storage becomes a concern. This is why I wanted NAS storage instead of a USB drive strapped to the Mac Mini.</p>\n<h2 id=\"final-working-setup\">Final Working Setup</h2>\n<h3 id=\"docker-composeyml\">docker-compose.yml</h3>\n<pre><code class=\"language-yaml\">services:\n  frigate:\n    container_name: frigate\n    image: ghcr.io/blakeblackshear/frigate:stable-standard-arm64\n    restart: unless-stopped\n    stop_grace_period: 30s\n    shm_size: \"512mb\"\n\n    ports:\n      - \"8971:8971\"\n      - \"8554:8554\"\n      - \"8555:8555/tcp\"\n      - \"8555:8555/udp\"\n\n    volumes:\n      - ./config:/config\n      - frigate-media:/media/frigate\n      - type: tmpfs\n        target: /tmp/cache\n        tmpfs:\n          size: 1000000000\n\n    privileged: true\n\nvolumes:\n  frigate-media:\n    driver: local\n    driver_opts:\n      type: nfs\n      o: addr=192.168.1.7,rw,nfsvers=3,nolock,soft\n      device: \":/mnt/Storage/frigate\"\n</code></pre>\n<h3 id=\"configyml\">config.yml</h3>\n<pre><code class=\"language-yaml\">mqtt:\n  enabled: false\n\nobjects:\n  track:\n    - person\n    - dog\n    - cat\n    - car\n\nrecord:\n  enabled: true\n  continuous:\n    days: 7\n  alerts:\n    retain:\n      days: 30\n  detections:\n    retain:\n      days: 14\n\ngo2rtc:\n  streams:\n    tree_camera_1_main:\n      - rtsp://admin:PASSWORD@192.168.1.30:554/cam/realmonitor?channel=1&amp;subtype=0\n    tree_camera_1_sub:\n      - rtsp://admin:PASSWORD@192.168.1.30:554/cam/realmonitor?channel=1&amp;subtype=1\n    garage_camera_main:\n      - rtsp://admin:PASSWORD@192.168.1.31:554/cam/realmonitor?channel=1&amp;subtype=0\n    garage_camera_sub:\n      - rtsp://admin:PASSWORD@192.168.1.31:554/cam/realmonitor?channel=1&amp;subtype=1\n\ncameras:\n  garage_camera:\n    enabled: true\n    ffmpeg:\n      inputs:\n        - path: rtsp://127.0.0.1:8554/garage_camera_main\n          roles:\n            - record\n        - path: rtsp://127.0.0.1:8554/garage_camera_sub\n          roles:\n            - detect\n    detect:\n      enabled: true\n\n  tree_camera_1:\n    enabled: true\n    ffmpeg:\n      inputs:\n        - path: rtsp://127.0.0.1:8554/tree_camera_1_main\n          roles:\n            - record\n        - path: rtsp://127.0.0.1:8554/tree_camera_1_sub\n          roles:\n            - detect\n    detect:\n      enabled: true\n\nversion: 0.17-0\n</code></pre>\n<h3 id=\"truenas-nfs-configuration-summary\">TrueNAS NFS Configuration Summary</h3>\n<ul>\n<li><strong>NFS Share path:</strong> <code>/mnt/Storage/frigate</code></li>\n<li><strong>NFS Service:</strong> \"Allow non-root mount\" enabled</li>\n<li><strong>Share Mapall User:</strong> <code>root</code></li>\n<li><strong>Share Mapall Group:</strong> <code>wheel</code></li>\n<li><strong>TrueNAS version:</strong> 25.04.2.4</li>\n</ul>\n<h3 id=\"verification\">Verification</h3>\n<pre><code class=\"language-bash\"># Start Frigate\ndocker compose up -d\n\n# Verify NFS mount inside container (should show NAS storage)\ndocker exec frigate df -h /media/frigate\n# :/mnt/Storage/frigate   22T     0   22T   0% /media/frigate\n\n# Check logs\ndocker logs frigate --tail 20\n\n# Check storage usage\ndocker exec frigate du -sh /media/frigate/recordings/\n</code></pre>\n<p>Frigate web UI available at <strong><a href=\"http://localhost:8971/?ref=emir.fyi\">http://localhost:8971</a></strong>.</p>\n<h2 id=\"whats-next\">What's Next</h2>\n<p>The system is running. Two cameras are up, recording continuously to the NAS, detecting people and cars with AI. It works. But there's more to do:</p>\n<ul>\n<li><strong>Facial recognition</strong> so I can tell the difference between \"person I know\" and \"person I don't know\"</li>\n<li><s><strong>Third camera</strong> to cover the front yard (the poop zone)</s> ✅ Done</li>\n<li><strong>Home Assistant integration</strong> so my wife can check cameras without logging into Frigate directly</li>\n<li><s><strong>M1 Neural Engine detector</strong> to replace CPU detection, which works but is slower than it needs to be.</s> ✅ Done - <a href=\"https://emir.fyi/frigate-0-17-on-apple-silicon-running-ai-detection-on-the-m1-neural-engine/\">read about it here</a></li>\n</ul>\n<p>Was this overkill for replacing Blink cameras? Absolutely. But I got to brush up on fishing cable through an attic and cementing PVC conduit, while picking up NFS volume configuration in Docker, RTSP authentication debugging across three layers of URL encoding, and setting up an AI surveillance system that doesn't phone home to anyone.</p>\n<p>The Blink cameras are in a drawer. Good riddance.</p>\n<h2 id=\"what-it-actually-looks-like\">What It Actually Looks Like</h2><p>After all the YAML wrestling and permission-denied debugging, it's nice to have something physical to point at. Here's the real install.</p><h3 id=\"the-cameras\">The Cameras</h3><p>First camera went up on a tree in the front yard, high enough that nobody can mess with it but low enough to actually see things. Cable runs down the trunk inside conduit so it doesn't look like a science fair project from the street.</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/04/tree-camera-1.webp\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1200\" height=\"1600\" srcset=\"https://emir.fyi/content/images/size/w600/2026/04/tree-camera-1.webp 600w, https://emir.fyi/content/images/size/w1000/2026/04/tree-camera-1.webp 1000w, https://emir.fyi/content/images/2026/04/tree-camera-1.webp 1200w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Tree cameras. The little gray cylinder below it is the conduit elbow where the cable enters the trunk run.</span></figcaption></figure><p>The garage camera tucks into the soffit corner, watching the side yard and driveway. White housing on white trim, you forget it's there until a detection alert lights up your phone.</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/04/garage-camera-1.webp\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1200\" height=\"1600\" srcset=\"https://emir.fyi/content/images/size/w600/2026/04/garage-camera-1.webp 600w, https://emir.fyi/content/images/size/w1000/2026/04/garage-camera-1.webp 1000w, https://emir.fyi/content/images/2026/04/garage-camera-1.webp 1200w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Garage camera, blending in nicely with the trim.</span></figcaption></figure><h3 id=\"the-conduit\">The Conduit</h3><p>The least softwary part of the whole project. PVC conduit, a tube of adhesive, some fishing tape, and a willingness to climb a ladder. Honestly the most satisfying few hours of the build.</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/04/conduit-soffit-run-1.webp\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1200\" height=\"1600\" srcset=\"https://emir.fyi/content/images/size/w600/2026/04/conduit-soffit-run-1.webp 600w, https://emir.fyi/content/images/size/w1000/2026/04/conduit-soffit-run-1.webp 1000w, https://emir.fyi/content/images/2026/04/conduit-soffit-run-1.webp 1200w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Conduit run from the soffit down to where the cable exits at grade. Not pretty up close, basically invisible from the street.</span></figcaption></figure><h3 id=\"the-poe-switch\">The PoE Switch</h3><p>First time using a PoE switch and I'm a convert. One cable to each camera, no wall warts, no power injectors strapped to the wall. The TP-Link slots into the rack with the rest of the gear and quietly does its job.</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/04/poe-switch-rack.webp\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1200\" height=\"1600\" srcset=\"https://emir.fyi/content/images/size/w600/2026/04/poe-switch-rack.webp 600w, https://emir.fyi/content/images/size/w1000/2026/04/poe-switch-rack.webp 1000w, https://emir.fyi/content/images/2026/04/poe-switch-rack.webp 1200w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">The TP-Link doing the unglamorous work of pushing power and packets to the cameras over a single cable each</span></figcaption></figure><h3 id=\"the-m1-mini-mac-and-its-new-roommate-m4-max-pro\">The M1 mini mac (and Its New Roommate m4 max pro)</h3><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/04/m4-on-m1-base.webp\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1600\" height=\"1200\" srcset=\"https://emir.fyi/content/images/size/w600/2026/04/m4-on-m1-base.webp 600w, https://emir.fyi/content/images/size/w1000/2026/04/m4-on-m1-base.webp 1000w, https://emir.fyi/content/images/2026/04/m4-on-m1-base.webp 1600w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">M4 on top, M1 on the bottom. The M1 has been doing camera duty 24/7 since this all started, and it barely warms up.</span></figcaption></figure><p>The Frigate workhorse is the bottom Mac Mini in this photo. The silver one stacked on top is an M4 that joined the rack later for unrelated reasons. The M1 didn't get demoted when the M4 showed up, it got promoted to \"permanent appliance.\" It just sits there, sips watts, runs Frigate.</p>","comment_id":"69b2c54ae62aae000136c0a3","feature_image":"https://emir.fyi/content/images/2026/03/ChatGPT-Image-Mar-12--2026--10_50_51-AM.png","featured":false,"visibility":"public","created_at":"2026-03-12T09:53:14.000-04:00","updated_at":"2026-04-15T20:00:20.000-04:00","published_at":"2026-03-12T10:44:23.000-04:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/running-frigate-nvr-on-m1-mac-mini-with-truenas-nfs-storage/","excerpt":"Why did I do this\n\n\nI got tired of changing Blink camera batteries and of the thought that someone in the cloud might have recordings of my family's every move.\n\n\nThat's it. That's the origin story. Every now and then I'd get the notification, trudge outside, swap batteries, and wonder why I was paying a cloud subscription for the privilege of watching grainy clips of my own front yard on someone else's server. So I decided to fix it. Overkill style.\n\n\nI'm a homelabber. I build things to learn t","reading_time":14,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"69b57464cbda360001756c8b","uuid":"0a71c4a1-c29c-4e40-bc68-17c1890c7dd4","title":"Entire Homelab Dies Because One Machine Reboots: Building an HA Load Balancer with Keepalived and Caddy","slug":"entire-homelab-dies-because-one-machine-reboots-building-an-ha-load-balancer-with-keepalived-and-caddy","html":"<h2 id=\"why-i-did-this\">Why I Did This</h2>\n<p>My Proxmox host rebooted, and everything went dark.</p>\n<p>Vaultwarden, ArgoCD, my monitoring dashboard, the blog, Paperless. All of it. Every service in my homelab routed through a Kubernetes cluster running on Proxmox VMs, and the load balancer for that cluster was MetalLB, which also ran inside Kubernetes. So when Proxmox went down for a firmware update, the load balancer died with it, which meant the ingress died, which meant DNS entries pointing at the ingress went nowhere. A planned five-minute reboot turned into a \"why is everything broken\" moment for anyone in the house trying to use anything I host.</p>\n<p>The irony was thick. I had a K8s cluster with multiple worker nodes, redundant pod replicas, health checks, the works. But none of that matters when your load balancer is a pod inside the same cluster it's supposed to be balancing. It's like putting the pilot's ejection seat inside the engine.</p>\n<h2 id=\"what-metallb-actually-does-and-why-it-wasnt-enough\">What MetalLB Actually Does (and Why It Wasn't Enough)</h2>\n<p>MetalLB is a bare-metal Kubernetes load balancer. It gives your services a real IP address on your LAN using ARP announcements, so you can hit <code>192.168.1.201</code> and reach your Traefik ingress controller. For a lot of homelabs, this is perfectly fine.</p>\n<p>Here's the problem: MetalLB runs as pods inside K8s. If K8s goes down, MetalLB goes down, and that IP stops responding. There's no failover outside the cluster. Your DNS entries still point at <code>192.168.1.201</code>, but nobody's home.</p>\n<p>I also had services running outside K8s entirely. SiYuan (notes), Paperless (document management), and my Ghost blog all run as Docker containers on a separate Portainer host. They were routed through K8s using an external-services pattern (headless Service + Endpoints pointing at the Docker host IP). This worked, but it meant that even non-K8s services depended on K8s being alive. A notes app shouldn't go offline because Kubernetes is having a bad day.</p>\n<p>And then there's Beszel (my monitoring dashboard) and Frigate (security cameras), both running natively on the Mac Mini M1 that has nothing to do with Proxmox or K8s. These could survive a Proxmox outage entirely, but I couldn't reach them because the routing went through the dead cluster.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Remove MetalLB from K8s</s> (done)</li>\n<li><s>Expose Traefik via NodePort instead of LoadBalancer</s> (done)</li>\n<li><s>Set up 3 LB nodes across different physical machines</s> (done)</li>\n<li><s>Install keepalived for floating VIP (VRRP)</s> (done)</li>\n<li><s>Install Caddy as the reverse proxy on each node</s> (done)</li>\n<li><s>Ansible playbook for repeatable deployment</s> (done)</li>\n<li><s>Update DNS entries to point at VIP</s> (done)</li>\n<li><s>Update cloudflared to route through VIP</s> (done)</li>\n<li><s>Auto-start Mac-hosted VMs on boot</s> (done)</li>\n</ol>\n<h2 id=\"choosing-the-stack\">Choosing the Stack</h2>\n<h3 id=\"why-keepalived\">Why Keepalived?</h3>\n<p>VRRP (Virtual Router Redundancy Protocol) is the simplest answer to \"what if the machine holding the IP goes down?\" Multiple nodes run keepalived, each with a priority. The highest priority healthy node becomes the master and owns the virtual IP (VIP). If the master dies, the next highest takes over. Usually within a second.</p>\n<p>No coordination service. No consensus algorithm. No etcd. Just VRRP advertisements over multicast, every second. The nodes don't even need to know about each other's existence. They just broadcast \"I'm alive and my priority is X\" and the protocol handles the rest.</p>\n<h3 id=\"why-caddy\">Why Caddy?</h3>\n<p>I needed a reverse proxy on each LB node that could:</p>\n<ul>\n<li>Terminate TLS with my private CA's wildcard certificate</li>\n<li>Route by hostname to different backends</li>\n<li>Health-check K8s workers and round-robin across them</li>\n<li>Run with almost zero configuration</li>\n</ul>\n<p>Caddy does all of this out of the box. The Caddyfile is readable by humans, TLS just works if you hand it a cert and key, and it has built-in health checking for upstreams. I already use Traefik inside K8s, but Caddy felt like the right tool for a standalone reverse proxy that should be as boring and reliable as possible.</p>\n<p>I looked at HAProxy and Nginx too. HAProxy would work but the config syntax is verbose for what I need. Nginx would also work but I'd have to manually configure health checks and TLS in a way that Caddy just handles automatically. For three nodes running identical configs, simplicity wins.</p>\n<h3 id=\"why-three-nodes\">Why Three Nodes?</h3>\n<p>Two nodes give you failover. Three give you failover without anxiety. If one of the Mac Minis is off for maintenance and then Proxmox reboots, you still have a working load balancer. The cost is minimal: each LB node runs Ubuntu with 512MB RAM and 1 CPU core. Three lightweight VMs spread across three physical machines.</p>\n<table>\n<thead>\n<tr>\n<th>Node</th>\n<th>IP</th>\n<th>Physical Host</th>\n<th>Priority</th>\n<th>Role</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>lb-m4</td>\n<td>192.168.1.22</td>\n<td>Mac Mini M4 Pro (UTM VM)</td>\n<td>100</td>\n<td>Default master</td>\n</tr>\n<tr>\n<td>lb-proxmox</td>\n<td>192.168.1.20</td>\n<td>Proxmox (QEMU VM)</td>\n<td>90</td>\n<td>Backup</td>\n</tr>\n<tr>\n<td>lb-m1</td>\n<td>192.168.1.23</td>\n<td>Mac Mini M1 (UTM VM)</td>\n<td>80</td>\n<td>Backup</td>\n</tr>\n</tbody>\n</table>\n<p><strong>VIP:</strong> <code>192.168.1.221/24</code> (outside the DHCP range)</p>\n<p>The Mac Mini M4 Pro gets priority 100 because it's the beefy daily driver that's always on. The Proxmox VM gets 90 because it's the most likely to go down during maintenance. The Mac Mini M1 gets 80 as the last resort.</p>\n<h2 id=\"the-architecture\">The Architecture</h2>\n<p>Here's what the traffic flow looks like now:</p>\n<pre><code>                                    LAN devices\n                                  (laptops, phones,\n                                   other machines)\n                                        |\nInternet --&gt; Cloudflare                 |\n              --&gt; cloudflared           |\n                    |                   |\n                    |    *.localdomain   |\n                    |    DNS resolves    |\n                    |    to VIP         |\n                    v                   v\n              HA Load Balancer (VIP 192.168.1.221)\n              keepalived + Caddy, 3 nodes\n              |\n              +-- K8s services (vault, argocd, market-mind)\n              |     Caddy --&gt; Traefik NodePort 30443\n              |     Round-robin across K8s workers\n              |\n              +-- Portainer services (siyuan, paperless, errbit)\n              |     Caddy --&gt; direct to 192.168.1.2\n              |\n              +-- Mac Mini M1 services (metrics, frigate)\n              |     Caddy --&gt; direct to 192.168.1.5\n              |     Fully HA: survives Proxmox outage\n              |\n              +-- Ghost blog (:80)\n                    cloudflared --&gt; Caddy --&gt; 192.168.1.2:2368\n</code></pre>\n<p>Most traffic isn't coming from the internet. It's coming from inside the house. Laptops opening Vaultwarden to grab a password. Phones checking Frigate camera feeds. My dev machine hitting ArgoCD or the monitoring dashboard. Other services calling each other over <code>*.localdomain</code> hostnames. The router resolves all <code>*.localdomain</code> DNS entries to the VIP (<code>192.168.1.221</code>), so every device on the network hits Caddy the same way an external request would, just without the Cloudflare hop. Public traffic (the blog, MarketMind) comes in through Cloudflare and cloudflared, but that's a small fraction of the total load.</p>\n<p>The key insight: Caddy doesn't just proxy to K8s. It routes everything by hostname. K8s services go through Traefik's NodePort, Docker services go directly to the Portainer host, and Mac Mini M1 services go directly to that machine. Each class of service has a different failure domain. If Proxmox goes down, you lose K8s and Portainer services, but metrics and Frigate stay up because they live on the Mac Mini M1, a completely separate physical machine.</p>\n<h2 id=\"setting-up-the-nodes\">Setting Up the Nodes</h2>\n<h3 id=\"proxmox-vm-the-easy-one\">Proxmox VM (the easy one)</h3>\n<p>This was the straightforward node. Clone an Ubuntu template, assign a static IP via cloud-init, SSH in, done. I use Terraform to provision VMs on Proxmox, so lb-proxmox is defined as infrastructure-as-code alongside the K8s nodes:</p>\n<pre><code class=\"language-hcl\">resource \"proxmox_vm_qemu\" \"lb_node\" {\n  name        = \"lb-node\"\n  target_node = \"proxmox\"\n  vmid        = 105\n  clone       = \"ubuntu-template\"\n  full_clone  = true\n  agent       = 1\n  onboot      = true\n  cores       = 1\n  memory      = 512\n\n  disk {\n    type    = \"disk\"\n    slot    = \"scsi0\"\n    size    = \"8G\"\n    storage = \"local-lvm\"\n  }\n\n  network {\n    id     = 0\n    model  = \"virtio\"\n    bridge = \"vmbr0\"\n  }\n\n  ipconfig0  = \"ip=192.168.1.20/24,gw=192.168.1.1\"\n  nameserver = \"192.168.1.1\"\n  ciuser     = \"ubuntu\"\n  sshkeys    = file(\"~/.ssh/my_key.pub\")\n}\n</code></pre>\n<h3 id=\"mac-utm-vms-the-adventure\">Mac UTM VMs (the adventure)</h3>\n<p>UTM is a QEMU-based hypervisor for Apple Silicon. It runs ARM64 Linux VMs natively, which is exactly what I needed for the Mac Mini M4 Pro and Mac Mini M1 nodes.</p>\n<p>The catch: UTM doesn't have cloud-init, Terraform, or any automation story. You install Ubuntu from an ISO, manually configure networking, and SSH in (scp your public key into authorized_keys). For the Mac Mini M1, which runs headless in a closet, I had to do the whole install over screen sharing, then set up a LaunchAgent to auto-start the VM on boot.</p>\n<p><strong>Bridged networking</strong> is critical. The VM needs to be on the same L2 network as the other LB nodes for VRRP to work. In UTM, this means selecting the wired Ethernet adapter (not WiFi) as the bridged interface. On Mac, <code>en0</code> is wired and <code>en1</code> is WiFi. Getting this backwards means your VM gets an IP but VRRP advertisements never reach the other nodes.</p>\n<p><strong>Auto-start on boot</strong> uses a LaunchAgent plist that waits 15 seconds (for UTM to finish launching) then runs <code>utmctl start</code>:</p>\n<pre><code class=\"language-xml\">&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n  \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"&gt;\n&lt;plist version=\"1.0\"&gt;\n&lt;dict&gt;\n  &lt;key&gt;Label&lt;/key&gt;\n  &lt;string&gt;com.utm.autostart-m4-lb&lt;/string&gt;\n  &lt;key&gt;ProgramArguments&lt;/key&gt;\n  &lt;array&gt;\n    &lt;string&gt;/bin/bash&lt;/string&gt;\n    &lt;string&gt;-c&lt;/string&gt;\n    &lt;string&gt;sleep 15 &amp;amp;&amp;amp; /Applications/UTM.app/Contents/MacOS/utmctl start m4-lb&lt;/string&gt;\n  &lt;/array&gt;\n  &lt;key&gt;RunAtLoad&lt;/key&gt;\n  &lt;true/&gt;\n&lt;/dict&gt;\n&lt;/plist&gt;\n</code></pre>\n<p>Drop this in <code>~/Library/LaunchAgents/</code>, enable auto-login on the Mac, and disable FileVault. The VM comes up about 30 seconds after boot. Not instant, but keepalived handles the gap. The other nodes own the VIP until this one joins.</p>\n<h2 id=\"the-configs\">The Configs</h2>\n<h3 id=\"keepalived-vrrp\">Keepalived (VRRP)</h3>\n<p>Each node runs the same keepalived config, templated by Ansible with per-node priority and interface:</p>\n<pre><code>vrrp_instance VI_1 {\n  state BACKUP\n  interface {{ lb_interface }}\n  virtual_router_id 51\n  priority {{ lb_priority }}\n  advert_int 1\n\n  authentication {\n    auth_type PASS\n    auth_pass {{ lb_vrrp_pass }}\n  }\n\n  virtual_ipaddress {\n    192.168.1.221/24 dev {{ lb_interface }}\n  }\n}\n</code></pre>\n<p>Every node starts as <code>BACKUP</code>. The one with the highest priority wins the election and becomes master. <code>advert_int 1</code> means nodes advertise every second. If the master misses three advertisements (3 seconds), failover happens. In practice, I see sub-second failover in my testing.</p>\n<p>All nodes use the same <code>virtual_router_id</code> (51) and authentication password so they recognize each other as part of the same VRRP group. The interface varies: <code>eth0</code> on the Proxmox VM, <code>enp0s1</code> on the UTM VMs.</p>\n<h3 id=\"caddy-reverse-proxy\">Caddy (Reverse Proxy)</h3>\n<p>This is the fun part. The Caddyfile is identical on all three nodes, templated once and deployed everywhere:</p>\n<pre><code># Reusable snippet for K8s services via Traefik NodePort\n(k8s_backend) {\n  reverse_proxy 192.168.1.11:30443 192.168.1.12:30443 {\n    transport http {\n      tls\n      tls_insecure_skip_verify\n    }\n    lb_policy round_robin\n    health_interval 10s\n    health_timeout 5s\n  }\n}\n\n# K8s services\nvault.localdomain {\n  tls /etc/caddy/certs/wildcard.crt /etc/caddy/certs/wildcard.key\n  import k8s_backend\n}\n\nargocd.localdomain {\n  tls /etc/caddy/certs/wildcard.crt /etc/caddy/certs/wildcard.key\n  import k8s_backend\n}\n\nmarket-mind.localdomain {\n  tls /etc/caddy/certs/wildcard.crt /etc/caddy/certs/wildcard.key\n  import k8s_backend\n}\n\n# Docker/Portainer services (direct, no K8s involved)\nsiyuan.localdomain {\n  tls /etc/caddy/certs/wildcard.crt /etc/caddy/certs/wildcard.key\n  reverse_proxy 192.168.1.2:6806\n}\n\npaperless.localdomain {\n  tls /etc/caddy/certs/wildcard.crt /etc/caddy/certs/wildcard.key\n  reverse_proxy 192.168.1.2:28981\n}\n\n# Fully HA services (Mac Mini M1, survives Proxmox outage)\nmetrics.localdomain {\n  tls /etc/caddy/certs/wildcard.crt /etc/caddy/certs/wildcard.key\n  reverse_proxy 192.168.1.5:8090\n}\n\n# Public HTTP (Ghost blog via cloudflared)\n:80 {\n  reverse_proxy 192.168.1.2:2368 {\n    header_up X-Forwarded-Proto https\n  }\n}\n</code></pre>\n<p>A few things worth noting:</p>\n<p><strong>The <code>k8s_backend</code> snippet</strong> is a Caddy named snippet. Every K8s service imports it, so adding a new one is a single block with <code>import k8s_backend</code>. The snippet round-robins across both K8s worker nodes on port 30443 (Traefik's NodePort) with health checks every 10 seconds. If a worker goes down, Caddy stops routing to it automatically.</p>\n<p><strong><code>tls_insecure_skip_verify</code></strong> is needed because Traefik presents a self-signed certificate on the NodePort. Caddy is talking to Traefik over the LAN, so verifying Traefik's cert isn't necessary. The wildcard cert from step-ca is what clients (browsers) see.</p>\n<p><strong>The <code>X-Forwarded-Proto: https</code> header</strong> on port 80 prevents Ghost from going into an infinite redirect loop. Ghost sees HTTP from Caddy, tries to redirect to HTTPS, Cloudflare sends it back as HTTP, Ghost redirects again. Setting the header tells Ghost \"the client is already on HTTPS, stop redirecting.\"</p>\n<p><strong><code>metrics.localdomain</code></strong> points directly at the Mac Mini M1. This service has zero dependency on Proxmox, K8s, or Docker. If Proxmox burns to the ground, my monitoring dashboard stays up. That felt important.</p>\n<h2 id=\"traefik-loadbalancer-to-nodeport\">Traefik: LoadBalancer to NodePort</h2>\n<p>With Caddy handling ingress, Traefik no longer needs a MetalLB LoadBalancer IP. Instead, it exposes a NodePort service that Caddy routes to:</p>\n<pre><code class=\"language-yaml\">apiVersion: v1\nkind: Service\nmetadata:\n  name: traefik-nodeport\n  namespace: traefik\nspec:\n  type: NodePort\n  selector:\n    app.kubernetes.io/name: traefik\n  ports:\n    - name: web\n      port: 80\n      targetPort: web\n      nodePort: 30080\n    - name: websecure\n      port: 443\n      targetPort: websecure\n      nodePort: 30443\n</code></pre>\n<p>Traefik still does all the K8s-internal routing (IngressRoutes, middleware, TLS termination for K8s services). It just doesn't own a LAN IP anymore. Caddy owns the LAN IP (via the VIP) and forwards to Traefik on the NodePort.</p>\n<p>I left the old LoadBalancer service in place. It shows <code>&lt;pending&gt;</code> now that MetalLB is gone, but it's harmless and removing it would require updating the Helm values.</p>\n<h2 id=\"ansible-making-it-repeatable\">Ansible: Making It Repeatable</h2>\n<p>Three nodes running identical configs is the perfect Ansible use case. The entire deployment is a single playbook:</p>\n<pre><code class=\"language-yaml\">- name: Deploy HA load balancer (keepalived + Caddy)\n  hosts: lb_nodes\n  become: true\n  tasks:\n    - name: Install keepalived and caddy\n      ansible.builtin.apt:\n        name: [keepalived, caddy]\n        state: present\n        update_cache: true\n\n    - name: Create cert directory\n      ansible.builtin.file:\n        path: /etc/caddy/certs\n        state: directory\n        owner: caddy\n        group: caddy\n        mode: \"0750\"\n\n    - name: Copy wildcard TLS certificate\n      ansible.builtin.copy:\n        src: \"{{ lb_cert_src }}\"\n        dest: /etc/caddy/certs/wildcard.crt\n        owner: caddy\n        group: caddy\n        mode: \"0640\"\n      notify: Reload caddy\n\n    - name: Copy wildcard TLS key\n      ansible.builtin.copy:\n        src: \"{{ lb_key_src }}\"\n        dest: /etc/caddy/certs/wildcard.key\n        owner: caddy\n        group: caddy\n        mode: \"0640\"\n      notify: Reload caddy\n\n    - name: Template Caddyfile\n      ansible.builtin.template:\n        src: templates/Caddyfile.j2\n        dest: /etc/caddy/Caddyfile\n      notify: Reload caddy\n\n    - name: Template keepalived config\n      ansible.builtin.template:\n        src: templates/keepalived.conf.j2\n        dest: /etc/keepalived/keepalived.conf\n      notify: Restart keepalived\n\n    - name: Enable and start services\n      ansible.builtin.systemd:\n        name: \"{{ item }}\"\n        enabled: true\n        state: started\n      loop: [caddy, keepalived]\n\n  handlers:\n    - name: Reload caddy\n      ansible.builtin.systemd:\n        name: caddy\n        state: reloaded\n\n    - name: Restart keepalived\n      ansible.builtin.systemd:\n        name: keepalived\n        state: restarted\n</code></pre>\n<p>The inventory defines per-node variables:</p>\n<pre><code class=\"language-yaml\">lb_nodes:\n  hosts:\n    192.168.1.20:\n      ansible_user: ubuntu\n      lb_priority: 90     # Proxmox VM\n      lb_interface: eth0\n    192.168.1.23:\n      ansible_user: ubuntu\n      lb_priority: 80     # Mac Mini M1 UTM VM\n      lb_interface: enp0s1\n    192.168.1.22:\n      ansible_user: ubuntu\n      lb_priority: 100    # Mac Mini M4 Pro UTM VM\n      lb_interface: enp0s1\n</code></pre>\n<p>One command deploys or updates all three nodes:</p>\n<pre><code class=\"language-bash\">ansible-playbook -i inventory.yml playbook-ha-lb.yml --extra-vars '@lb-keys.yml'\n</code></pre>\n<p>The <code>lb-keys.yml</code> file (gitignored) contains the VRRP shared secret and paths to the TLS cert/key from step-ca. The cert gets copied from my Mac (which acts as an SCP relay between step-ca and the LB nodes).</p>\n<h2 id=\"what-changed-the-migration\">What Changed (The Migration)</h2>\n<p>The actual cutover from MetalLB to the HA LB was surprisingly smooth. Here's what happened:</p>\n<ol>\n<li><strong>Applied the NodePort service</strong> to K8s so Traefik is reachable on port 30443 on every worker</li>\n<li><strong>Ran the Ansible playbook</strong> to deploy keepalived + Caddy on all three nodes</li>\n<li><strong>Updated router DNS</strong> to point all <code>*.localdomain</code> hostnames at <code>192.168.1.221</code> (the VIP) instead of <code>192.168.1.201</code> (old MetalLB IP)</li>\n<li><strong>Updated cloudflared</strong> config to route through the VIP instead of directly to Traefik</li>\n<li><strong>Removed MetalLB</strong> from the K8s cluster (optional, but why keep it around)</li>\n<li><strong>Removed external-services K8s manifests</strong> for Portainer services (Caddy routes to them directly now, no need for the headless Service + Endpoints hack)</li>\n</ol>\n<p>The only downtime was the DNS change, which propagates instantly on my router since it's the authoritative DNS for <code>.localdomain</code>. Total switchover: about 30 seconds.</p>\n<h2 id=\"failure-scenarios\">Failure Scenarios</h2>\n<p>This is the part I actually tested. Repeatedly. By pulling network cables and watching what happened.</p>\n<p><strong>Proxmox reboots (the original problem):</strong><br>\nK8s services go down (expected, VMs are on Proxmox), lb-proxmox goes down. But lb-m4 or lb-m1 keeps the VIP alive. When Proxmox comes back, lb-proxmox rejoins automatically and starts receiving traffic again. Metrics and Frigate never went down because they live on the Mac Mini M1.</p>\n<p><strong>Mac Mini M4 Pro goes offline (highest priority node):</strong><br>\nlb-proxmox (priority 90) takes over the VIP within ~1 second. All services continue working. When lb-m4 comes back, it reclaims the VIP (preemption is enabled by default in keepalived).</p>\n<p><strong>Two nodes down simultaneously:</strong><br>\nThe remaining node owns the VIP alone. Services routed to the surviving backends still work. This is why three nodes across three physical machines matters. The odds of all three dying at once are vanishingly small.</p>\n<p><strong>Complete power outage:</strong><br>\nEverything dies. But when power returns, the Mac Minis auto-login, UTM launches, the LaunchAgents start the VMs, keepalived elects a master, and the VIP comes up. No manual intervention. I tested this by flipping the breaker. It took about 90 seconds from power-on to VIP-active.</p>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<p>The biggest lesson (That I already knew and applied only at the job): your load balancer can't live inside the thing it's balancing. This seems obvious in retrospect, but MetalLB makes it so easy to set up that you don't think about it until it bites you. For production Kubernetes clusters backed by cloud providers, this isn't an issue because the cloud LB is external. For homelabs on bare metal, you have to build that external layer yourself.</p>\n<p>Keepalived is ridiculously simple for what it does. The entire config is 16 lines. VRRP is a solved protocol from the 1990s and it just works. No service discovery, no leader election complexity, no distributed state. Three daemons yelling their priority over the network every second. The loudest healthy one wins. Beautiful.</p>\n<p>Caddy replaced an entire Kubernetes routing layer (MetalLB + external-services pattern) with a single config file. I went from \"headless Service with manually maintained Endpoints pointing at a LAN IP, routed through an IngressRoute on Traefik that I need MetalLB to expose\" to \"reverse_proxy 192.168.1.2:6806\". Seven lines of YAML became one line of Caddyfile.</p>\n<p>Spreading nodes across physically separate machines is the real HA move. If all three VMs were on Proxmox, this entire exercise would have been pointless. The Mac Minis are the insurance policy. They're low-power, always-on machines that don't share a failure domain with the hypervisor. One of them even hosts services that are completely Proxmox-independent. That's the setup I should have had from day one.</p>\n<p>And UTM on Apple Silicon is surprisingly capable for running lightweight Linux VMs. It's not Proxmox, it's not VMware, and it has zero automation story. But for a 512MB Ubuntu VM running keepalived and Caddy, it's perfectly adequate. Just budget extra time for the initial setup if the Mac is headless.</p>\n","comment_id":"69b57464cbda360001756c8b","feature_image":"https://emir.fyi/content/images/2026/03/ChatGPT-Image-Mar-14--2026--11_05_33-AM.png","featured":false,"visibility":"public","created_at":"2026-03-14T10:44:52.000-04:00","updated_at":"2026-03-14T11:05:47.000-04:00","published_at":"2026-02-18T11:02:00.000-05:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/entire-homelab-dies-because-one-machine-reboots-building-an-ha-load-balancer-with-keepalived-and-caddy/","excerpt":"Why I Did This\n\n\nMy Proxmox host rebooted, and everything went dark.\n\n\nVaultwarden, ArgoCD, my monitoring dashboard, the blog, Paperless. All of it. Every service in my homelab routed through a Kubernetes cluster running on Proxmox VMs, and the load balancer for that cluster was MetalLB, which also ran inside Kubernetes. So when Proxmox went down for a firmware update, the load balancer died with it, which meant the ingress died, which meant DNS entries pointing at the ingress went nowhere. A pl","reading_time":12,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"697d77cfb97a8100019a9bda","uuid":"7f34baab-4a23-48a0-a743-13e78f7429f2","title":"Upgrading PostgreSQL from version 14 to 17 - Part 2","slug":"upgrading-postgresql-from-version-14-to-17-part-1-2","html":"<p>Coming soon... maybe</p>","comment_id":"697d32c550c39300018cc931","feature_image":"https://emir.fyi/content/images/2026/01/ChatGPT-Image-Jan-30--2026--02_05_40-PM-1.png","featured":false,"visibility":"public","created_at":"2026-01-30T17:37:57.000-05:00","updated_at":"2026-01-30T17:38:59.000-05:00","published_at":"2026-01-30T17:38:21.000-05:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/upgrading-postgresql-from-version-14-to-17-part-1-2/","excerpt":"Coming soon... maybe","reading_time":0,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null},{"id":"697d77cfb97a8100019a9bd9","uuid":"1e93e8bc-64c6-46f8-9a6d-851a8c42cbbc","title":"Upgrading PostgreSQL from version 14 to 17 - Part 1","slug":"upgrading-postgresql-from-version-14-to-17-part-1","html":"<p>To start I should probably mention that I work for a healthcare company, and our PostgreSQL databases are hosted on Aptible. </p><p>Also this is <strong>NOT a tutorial</strong>. This is me writing about an experience I went through, which involved a fair amount of technical work, along with the usual emotional and everyday realities that come with it. Now that I have that out of the way, let's begin.</p><p>Our database is very write heavy. Incoming data is arriving from batch import jobs that run on demand and over-night. The outgoing data is typically consumed via the web app (some via API too), concentrated during normal PST work hours, with very little activity outside business hours or on weekends.</p><p><strong>Database hardware:</strong></p><ul><li>Disk: 6200 GB (Disk IOPS 16000)</li><li>Memory: 60 GB</li><li>CPU share: 7.5 (memory optimized container)</li></ul><p>There are no failovers or read replicas configured. This is a single, chunky database.</p><p>Now for the reason we upgraded. It wasn't performance, new features, or anything particularly exciting (though we'll happily take the benefits of a newer version). The real reason was reducing cost. Once you factor in database backups required for disaster recovery and compliance, that unused space gets expensive very quickly.</p><p>Upgrading PostgreSQL on Aptible is usually straightforward. They offer several upgrade paths, and the easiest and best is an in-place upgrade. Unfortunately, that option wasn't available to us. Some underlying library binaries changed between these versions, which ruled it out. <strong>Major bummer</strong>.</p><p>That left two options: logical replication or dump and restore. At first glance, logical replication was the better choice. Downtime would be minimal compared to dump and restore. With the latter, we would need to stop the database entirely, or at least stop writes.</p><p>Next, we needed to do a dry run of the migration because when have things ever worked on the first try, especially at this scale. Data consistency and reliability are very important to us, arguably more important than availability. That said, like any business, we have customers and contracts to sign SLAs to meet, and all the usual constraints that come with that.</p><p>Going back to the actual work, I started by putting together a document to capture the steps and a few observations along the way. I won't go into too much detail here, but it looked roughly like this:</p><ul><li>Define terminology (source DB, target DB, etc.)</li><li>Schemas and sizes (we have 200+ schemas, some north of 200 GB)</li><li>Hot tables — a handful of tables per schema that hold most of the data, some with 280M+ rows</li><li>Source and target configuration</li><li>Grafana alerts (disk size would drop significantly and immediately trigger alerts)</li><li>Environment variables</li><li>Tables without primary keys (logical replication won't work without them)</li><li>Creating the replica (Aptible command + environment variables)</li><li>Speeding up the initial sync</li><li>Monitoring replication progress</li><li>Reindexing the data</li><li>Autovacuum on the target</li><li>Fixing sequences</li><li>Aligning target DB settings with the source</li><li>Cutover strategy (how we coordinate pointing the app to the upgraded database)</li><li>Rollback strategy</li></ul><p>I was genuinely excited to work on this. It was the end of the year, and if everything went well, we'd start the new year paying nearly 50% less for the database while also running a newer and faster version of PostgreSQL.</p><p>With the plan written down and reviewed by a peer (minor feedback, green light), I moved on to actually running the thing. Fast forward to kicking off  <code>aptible db:replicate</code> to create the replica and start replication and… <strong>khaboom</strong>.</p><p>This is the error I immediately ran into:</p><pre><code>INFO -- : ║ 2025-11-25 18:16:25 UTC HINT:  You might need to increase \"max_locks_per_transaction\".\nINFO -- : ║ pg_restore: error: could not execute query: ERROR:  out of shared memory\nINFO -- : ║ HINT:  You might need to increase \"max_locks_per_transaction\".\nINFO -- : ║ Command was: CREATE SEQUENCE schema1.activities_id_seq\nINFO -- : ║     START WITH 1\nINFO -- : ║     INCREMENT BY 1\nINFO -- : ║     NO MINVALUE\nINFO -- : ║     NO MAXVALUE\nINFO -- : ║     CACHE 1;</code></pre><p>Given how many schemas and database objects we have, this wasn't entirely surprising. With the default settings, the replica simply couldn't be provisioned within a single transaction. We were blowing past the default limits.</p><p>To be clear about what I mean by object count, this is what I was looking at:</p><pre><code>SELECT count(*) \nFROM pg_class \nWHERE relkind IN ('r','p','i','S','t','m','v','I');</code></pre><p>The obvious fix here is to increase <code>max_locks_per_transaction</code> (and <code>max_pred_locks_per_transaction</code>), so that's what I did. I bumped them up significantly from the default value of 64. I even took a screenshot so I'd remember exactly how far I pushed them.</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/01/db-settings.png\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1116\" height=\"232\" srcset=\"https://emir.fyi/content/images/size/w600/2026/01/db-settings.png 600w, https://emir.fyi/content/images/size/w1000/2026/01/db-settings.png 1000w, https://emir.fyi/content/images/2026/01/db-settings.png 1116w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Postgresql db settings after modification</span></figcaption></figure><p>And yet the replica still failed to provision. At that point, I reached out to Aptible support. </p><p>They were friendly and responsive, but the back-and-forth dragged on. \"Try increasing it more.\" Then more. Try this, try that. Then bump up even more. This went on for about a week. I'm compressing the exchange here, but troubleshooting something like this over email is hard enough, doing it live in production is even harder. Not trying to talk down on Aptible support here and henceforth.</p><p>Eventually, we jumped on a call (I should have done that sooner). That's when things finally clicked.</p><p>I was able to demonstrate that configuration changes I was applying to the <strong>source</strong> database were not being applied to the <strong>replica</strong> database at all. Every attempt, every tweak, every retry had been happening against a replica that never picked up the new settings.</p><p>Once they realized that, everything suddenly made sense. And honestly, it was great news.</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/01/Screenshot-2026-01-30-at-4.30.27---PM.png\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1492\" height=\"506\" srcset=\"https://emir.fyi/content/images/size/w600/2026/01/Screenshot-2026-01-30-at-4.30.27---PM.png 600w, https://emir.fyi/content/images/size/w1000/2026/01/Screenshot-2026-01-30-at-4.30.27---PM.png 1000w, https://emir.fyi/content/images/2026/01/Screenshot-2026-01-30-at-4.30.27---PM.png 1492w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Email from aptible</span></figcaption></figure><p>After a couple more status updates over email, and one Thanksgiving holiday later, I got the notice that the new release had shipped:</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/01/Screenshot-2026-01-30-at-4.59.55---PM.png\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1524\" height=\"152\" srcset=\"https://emir.fyi/content/images/size/w600/2026/01/Screenshot-2026-01-30-at-4.59.55---PM.png 600w, https://emir.fyi/content/images/size/w1000/2026/01/Screenshot-2026-01-30-at-4.59.55---PM.png 1000w, https://emir.fyi/content/images/2026/01/Screenshot-2026-01-30-at-4.59.55---PM.png 1524w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Email from aptible</span></figcaption></figure><p>That was my cue to start the replication test immediately. I followed the steps from the document I had put together earlier, and for the first day or so everything looked fine. Initialization was progressing as expected. I was keeping an eye on it using a <a href=\"https://github.com/c0mrade/upgrade_from_pg14_to_17/blob/main/monitor_pglogical_sync.sh?ref=emir.fyi\" rel=\"noreferrer\">small script</a> for tracking progress, in case you're curious. Then, the very next day an unwelcome surprise.</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/01/Screenshot-2026-01-30-at-5.11.19---PM.png\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1480\" height=\"208\" srcset=\"https://emir.fyi/content/images/size/w600/2026/01/Screenshot-2026-01-30-at-5.11.19---PM.png 600w, https://emir.fyi/content/images/size/w1000/2026/01/Screenshot-2026-01-30-at-5.11.19---PM.png 1000w, https://emir.fyi/content/images/2026/01/Screenshot-2026-01-30-at-5.11.19---PM.png 1480w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Email from aptible</span></figcaption></figure><p>Reading that update was frustrating. The issue only affected newly created replicas, not the one I was actively testing, but it still clearly needed to be fixed. This wasn't something we could just ignore. A day or two later, while replication was still ongoing, another email landed.:</p><figure class=\"kg-card kg-image-card kg-card-hascaption\"><img src=\"https://emir.fyi/content/images/2026/01/Screenshot-2026-01-30-at-5.14.00---PM.png\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"1526\" height=\"240\" srcset=\"https://emir.fyi/content/images/size/w600/2026/01/Screenshot-2026-01-30-at-5.14.00---PM.png 600w, https://emir.fyi/content/images/size/w1000/2026/01/Screenshot-2026-01-30-at-5.14.00---PM.png 1000w, https://emir.fyi/content/images/2026/01/Screenshot-2026-01-30-at-5.14.00---PM.png 1526w\" sizes=\"(min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Email from aptible</span></figcaption></figure><p>At that point, I wasn't confident this was going to get resolved quickly enough for us to complete the upgrade over the holidays. Problems like this are hard, and as a customer you only ever see your own use case. There are always hidden complexities, especially the ones you don't have visibility into. Because of that, I empathize with the Aptible support and engineering teams.</p><p>I let my manager know that I was starting work on an alternative plan for the upgrade. This was supposed to be a side quest. It was clearly turning into the main one.</p><p>That's it for part one. If this was interesting and you're curious about what happened next, part two will cover that (maybe).</p><blockquote>I used ChatGPT to help clean up grammar, since I'm not a native speaker. The ideas, opinions, and descriptions are mine. The header image was generated using this prompt:</blockquote><p><em>I want you to generate image with postgresql logo in it and somehow we paint over 14 number 17, background needs to be dark because my web styling is white.</em></p>","comment_id":"697cfd5b50c39300018cc822","feature_image":"https://emir.fyi/content/images/2026/01/ChatGPT-Image-Jan-30--2026--02_05_40-PM.png","featured":false,"visibility":"public","created_at":"2026-01-30T13:50:03.000-05:00","updated_at":"2026-04-15T19:23:46.000-04:00","published_at":"2026-01-30T17:28:38.000-05:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/upgrading-postgresql-from-version-14-to-17-part-1/","excerpt":"To start I should probably mention that I work for a healthcare company, and our PostgreSQL databases are hosted on Aptible.\n\nAlso this is NOT a tutorial. This is me writing about an experience I went through, which involved a fair amount of technical work, along with the usual emotional and everyday realities that come with it. Now that I have that out of the way, let's begin.\n\nOur database is very write heavy. Incoming data is arriving from batch import jobs that run on demand and over-night. ","reading_time":6,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":"<span style=\"white-space: pre-wrap;\">It was a piece of cake in my mind</span>"},{"id":"69e239d43cad350001301c89","uuid":"69e4252c-c10e-44a1-9af8-d08065787e95","title":"Turning Paperless-ngx Into a Smart Filing Cabinet With Local AI and NAS Storage","slug":"turning-paperless-ngx-into-a-smart-filing-cabinet-with-local-ai-and-nas-storage","html":"<h2 id=\"the-itch\">The Itch</h2>\n<p>I had Paperless-ngx running. Technically. It was deployed on Portainer, had a DNS entry, a TLS cert, the whole nine yards. It also had zero documents in it. It was a ghost town with a login page. Every time I'd get a medical bill, a receipt, or a tax document, I'd do what every responsible adult does: take a photo on my phone and forget which album it ended up in.</p>\n<p>The straw that broke it was tax season. Every year, my wife and I spend hours across multiple days going through all of her business bank account statements, manually categorizing every expense. Is this one deductible? Was that a business lunch or a personal one? What about that $47 charge from November that neither of us remembers? It's tedious, error prone, and we both dread it. The whole time I'm thinking: I have a rack of computers in the other room. There has to be a better way. That's when I decided to actually use Paperless. Not just run it. Use it. The goal is simple: upload receipts and invoices throughout the year, let AI tag them as tax-deductible or not, and when tax season comes around, just filter by the tag instead of excavating bank statements.</p>\n<p>But basic Paperless with its built-in OCR was not going to cut it. I'd seen what happens when you feed it a camera photo of a receipt. Tesseract (the OCR engine) does its best, but \"its best\" for a photo of crumpled paper under fluorescent lighting is a string of characters that looks like someone fell asleep on a keyboard. I wanted the documents to be searchable, auto categorized, and ideally smart enough to tag a medical bill as \"medical\" and a hosting invoice as \"tax-deductible\" without me lifting a finger.</p>\n<h2 id=\"the-inspiration\">The Inspiration</h2>\n<p>I watched <a href=\"https://www.youtube.com/watch?v=NMAwHjleqHg&ref=emir.fyi\">Techno Tim's video</a> where he set up Paperless-ngx with Ollama and a couple of AI add-ons that dramatically improved both the metadata and the OCR quality. Great video. But his setup runs everything in one big Docker Compose stack on a single Linux box with an NVIDIA GPU. My homelab is a bit more distributed, so the architecture had to adapt.</p>\n<table>\n<thead>\n<tr>\n<th>Techno Tim's Setup</th>\n<th>My Setup</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Single Linux server with NVIDIA GPU</td>\n<td>LXC container (Portainer) + Mac Mini M4 Max</td>\n</tr>\n<tr>\n<td>Everything in one Docker Compose</td>\n<td>Services split across hosts</td>\n</tr>\n<tr>\n<td>Ollama in Docker (NVIDIA GPU passthrough)</td>\n<td>Ollama native on macOS (Metal GPU, no Docker)</td>\n</tr>\n<tr>\n<td>Local storage</td>\n<td>TrueNAS NAS via NFS</td>\n</tr>\n</tbody>\n</table>\n<p>The big deviation: Docker on Apple Silicon cannot pass through the GPU to containers. So Ollama has to run natively on macOS to use the M4 Max's Metal acceleration. Everything else stays containerized.</p>\n<h2 id=\"the-plan\">The Plan</h2>\n<ol>\n<li><s>Add Gotenberg and Tika to Paperless (better document conversion and metadata extraction)</s> (done)</li>\n<li><s>Install Ollama natively on Mac Mini M4 Max via Ansible</s> (done)</li>\n<li><s>Mount TrueNAS NFS share for document storage</s> (done, eventually)</li>\n<li><s>Deploy paperless-ai for auto-tagging, titles, and document classification</s> (done)</li>\n<li><s>Deploy paperless-gpt for vision model OCR</s> (done)</li>\n<li><s>Tune the AI prompt for tax-deductible and medical document detection</s> (done)</li>\n</ol>\n<h2 id=\"the-tech-stack\">The Tech Stack</h2>\n<p>Think of it like this: Paperless-ngx is the filing cabinet, Ollama is the brain running on a separate machine, and two AI add-ons (paperless-ai and paperless-gpt) sit in between making everything smarter.</p>\n<p><strong>Paperless-ngx</strong> stores, indexes, and searches documents. It runs built-in OCR via Tesseract and handles the web UI where you interact with everything.</p>\n<p><strong>Gotenberg</strong> converts non-PDF documents (Word, Excel, HTML, emails) into PDFs so Paperless can process them. Without it, you're limited to PDFs and images.</p>\n<p><strong>Tika</strong> (Apache Tika) extracts metadata and text from various file formats. It reads the guts of Office docs, emails, and other formats so Paperless can index them.</p>\n<p><strong>Ollama</strong> runs the actual LLM models. It sits on the M4 Max, listening on port 11434, serving two models: <code>qwen3:8b</code> for text processing and <code>minicpm-v:8b</code> for vision (understanding images).</p>\n<p><strong>paperless-ai</strong> reads the text content that Paperless already extracted and feeds it to the LLM. The LLM assigns tags, generates titles, detects correspondents (who sent it), and classifies the document type. It's the smart filing clerk.</p>\n<p><strong>paperless-gpt</strong> replaces the OCR itself. Instead of Tesseract guessing at pixels, it feeds the document image to a vision model (minicpm-v) that actually understands what it's looking at. Tables become tables, serial numbers stay intact, and blurry text gets read correctly.</p>\n<p>Here's how it all connects:</p>\n<pre><code>Document uploaded\n    → Paperless-ngx ingests + basic OCR (Tesseract)\n    → Gotenberg converts non-PDFs\n    → Tika extracts metadata\n    → paperless-ai reads text → sends to Ollama (qwen3:8b) → writes back tags/title/type\n    → paperless-gpt reads image → sends to Ollama (minicpm-v:8b) → replaces OCR content\n</code></pre>\n<h2 id=\"why-ollama-cant-run-in-docker-on-apple-silicon\">Why Ollama Can't Run in Docker on Apple Silicon</h2>\n<p>This tripped me up. Techno Tim runs Ollama in Docker with <code>--gpus all</code> because he has an NVIDIA GPU where Docker GPU passthrough just works. On Apple Silicon, Docker Desktop runs a Linux VM under the hood, and that VM has no access to the Metal GPU. Running Ollama in Docker on a Mac means CPU-only inference, which is painfully slow for any useful model.</p>\n<p>The solution: install Ollama natively on macOS. It automatically uses Metal for GPU acceleration, and the M4 Max's unified memory architecture means models load fast and run fast. I got 48 tokens/second on minicpm-v:8b, which is plenty for document processing.</p>\n<p>I wrote an Ansible playbook to automate the install, configure it to listen on all interfaces (so containers on other hosts can reach it), and pull the models:</p>\n<pre><code class=\"language-yaml\"># ansible/playbook-ollama.yml (abbreviated)\n- name: Install and configure Ollama on macOS (Apple Silicon)\n  hosts: mac_ai\n  tasks:\n    - name: Install Ollama via Homebrew\n      community.general.homebrew:\n        name: ollama\n        state: present\n\n    - name: Create Ollama LaunchAgent plist\n      ansible.builtin.template:\n        src: templates/com.ollama.serve.plist.j2\n        dest: \"{{ ansible_facts.env.HOME }}/Library/LaunchAgents/com.ollama.serve.plist\"\n\n    - name: Pull qwen3:8b model\n      ansible.builtin.command: /opt/homebrew/bin/ollama pull qwen3:8b\n\n    - name: Pull minicpm-v:8b vision model\n      ansible.builtin.command: /opt/homebrew/bin/ollama pull minicpm-v:8b\n</code></pre>\n<p>The LaunchAgent plist configures <code>OLLAMA_HOST=0.0.0.0:11434</code> so other machines on the LAN can reach it, and <code>KeepAlive=true</code> so it auto-restarts if it crashes:</p>\n<pre><code class=\"language-xml\">&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;plist version=\"1.0\"&gt;\n&lt;dict&gt;\n  &lt;key&gt;Label&lt;/key&gt;\n  &lt;string&gt;com.ollama.serve&lt;/string&gt;\n  &lt;key&gt;ProgramArguments&lt;/key&gt;\n  &lt;array&gt;\n    &lt;string&gt;/opt/homebrew/bin/ollama&lt;/string&gt;\n    &lt;string&gt;serve&lt;/string&gt;\n  &lt;/array&gt;\n  &lt;key&gt;EnvironmentVariables&lt;/key&gt;\n  &lt;dict&gt;\n    &lt;key&gt;OLLAMA_HOST&lt;/key&gt;\n    &lt;string&gt;0.0.0.0:11434&lt;/string&gt;\n    &lt;key&gt;OLLAMA_KEEP_ALIVE&lt;/key&gt;\n    &lt;string&gt;15m&lt;/string&gt;\n  &lt;/dict&gt;\n  &lt;key&gt;RunAtLoad&lt;/key&gt;\n  &lt;true/&gt;\n  &lt;key&gt;KeepAlive&lt;/key&gt;\n  &lt;true/&gt;\n&lt;/dict&gt;\n&lt;/plist&gt;\n</code></pre>\n<h2 id=\"nfs-storage-the-lxc-plot-twist\">NFS Storage: The LXC Plot Twist</h2>\n<p>I wanted documents stored on my TrueNAS NAS, not on the Portainer host's local disk. NAS gives me ZFS snapshots, redundancy, and one place to back up. I'd already worked with NFS in the past, so I figured this would be quick. <strong>It was not quick.</strong></p>\n<p>The Paperless container runs on Portainer, which is an <strong>unprivileged LXC container</strong> on Proxmox. And unprivileged LXC containers cannot mount filesystems. The <code>mount()</code> syscall is blocked at the kernel level, even as root inside the container. I spent a while tweaking NFS export settings on TrueNAS before realizing the request was never leaving the container.</p>\n<h3 id=\"the-fix-mount-on-proxmox-bind-mount-into-lxc\">The Fix: Mount on Proxmox, Bind-Mount Into LXC</h3>\n<p>The standard pattern for LXC + NFS:</p>\n<ol>\n<li>Mount the NFS share on the <strong>Proxmox host</strong> (not inside the LXC)</li>\n<li>Add a bind-mount in the LXC config that exposes it inside the container. <code>mujo-nas.localdomain</code> is DNS for my NAS.</li>\n</ol>\n<p>On Proxmox:</p>\n<pre><code class=\"language-bash\"># /etc/fstab on Proxmox host\nmujo-nas.localdomain:/mnt/Storage/paperless /mnt/nas/paperless nfs vers=3,nolock,soft,rw,_netdev 0 0\n</code></pre>\n<p>In the LXC config (<code>/etc/pve/lxc/100.conf</code>):</p>\n<pre><code>mp0: /mnt/nas/paperless,mp=/mnt/nas/paperless\n</code></pre>\n<p>One catch with unprivileged LXCs: UID namespacing. Container UID 1000 maps to host UID 101000, so files created by the container appear as a different user on the host. The fix is to <code>chmod 777</code> the NFS subdirectories (media, export, consume). This is fine because NFS access is already controlled at the network level by the share configuration.</p>\n<p>The Paperless compose just points at the bind-mounted paths:</p>\n<pre><code class=\"language-yaml\">volumes:\n  # data stays local (search index, classifier cache)\n  - /opt/paperless/data:/usr/src/paperless/data\n  # media/export/consume on NFS (TrueNAS → Proxmox → LXC bind mount)\n  - /mnt/nas/paperless/media:/usr/src/paperless/media\n  - /mnt/nas/paperless/export:/usr/src/paperless/export\n  - /mnt/nas/paperless/consume:/usr/src/paperless/consume\n</code></pre>\n<p>Data stays local for performance (search index, classifier cache). The actual documents live on the NAS.</p>\n<h2 id=\"the-compose-files\">The Compose Files</h2>\n<p>Everything follows my standard homelab convention: each service gets its own directory under <code>docker/</code>, its own <code>docker-compose.yml</code>, a <code>.env.example</code> with placeholders, and real secrets in <code>.env</code> on the host (never committed).</p>\n<h3 id=\"paperless-ngx-gotenberg-tika\">Paperless-ngx + Gotenberg + Tika</h3>\n<pre><code class=\"language-yaml\">services:\n  paperless:\n    image: ghcr.io/paperless-ngx/paperless-ngx:latest\n    container_name: paperless\n    restart: unless-stopped\n    network_mode: host\n    depends_on:\n      - gotenberg\n      - tika\n    volumes:\n      - /opt/paperless/data:/usr/src/paperless/data\n      - /mnt/nas/paperless/media:/usr/src/paperless/media\n      - /mnt/nas/paperless/export:/usr/src/paperless/export\n      - /mnt/nas/paperless/consume:/usr/src/paperless/consume\n    env_file: .env\n    environment:\n      PAPERLESS_REDIS: redis://localhost:6379\n      PAPERLESS_DBHOST: localhost\n      PAPERLESS_DBNAME: paperless\n      PAPERLESS_DBUSER: paperless\n      PAPERLESS_TIME_ZONE: America/New_York\n      PAPERLESS_OCR_LANGUAGE: eng\n      PAPERLESS_URL: https://paperless.localdomain\n      PAPERLESS_PORT: 28981\n      PAPERLESS_TIKA_ENABLED: 1\n      PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://localhost:3100\n      PAPERLESS_TIKA_ENDPOINT: http://localhost:9998\n      PAPERLESS_TASK_WORKERS: 5\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-fs\", \"-S\", \"-L\", \"--max-time\", \"2\", \"http://localhost:28981\"]\n      interval: 30s\n      timeout: 10s\n      retries: 5\n\n  gotenberg:\n    image: docker.io/gotenberg/gotenberg:8.27\n    container_name: gotenberg\n    restart: unless-stopped\n    network_mode: host\n    command:\n      - \"gotenberg\"\n      - \"--chromium-disable-javascript=true\"\n      - \"--chromium-allow-list=file:///tmp/.*\"\n      - \"--api-port=3100\"\n    environment:\n      TZ: America/New_York\n\n  tika:\n    image: docker.io/apache/tika:latest\n    container_name: tika\n    restart: unless-stopped\n    network_mode: host\n    environment:\n      TZ: America/New_York\n</code></pre>\n<p>Gotenberg runs on port 3100 (not the default 3000) to avoid a conflict with another service.</p>\n<h3 id=\"paperless-ai\">paperless-ai</h3>\n<pre><code class=\"language-yaml\">services:\n  paperless-ai:\n    image: clusterzx/paperless-ai:latest\n    container_name: paperless-ai\n    restart: unless-stopped\n    ports:\n      - \"3003:3000\"\n    cap_drop:\n      - ALL\n    security_opt:\n      - no-new-privileges:true\n    env_file: .env\n    environment:\n      TZ: America/New_York\n      PAPERLESS_AI_PORT: 3000\n      PAPERLESS_API_URL: http://192.168.1.2:28981/api\n      PAPERLESS_URL: http://192.168.1.2:28981\n      AI_PROVIDER: ollama\n      OLLAMA_API_URL: http://192.168.1.53:11434\n      OLLAMA_MODEL: qwen3:8b\n      RAG_SERVICE_URL: http://192.168.1.2:28981\n      RAG_SERVICE_ENABLED: \"true\"\n      SCAN_INTERVAL: \"*/2 * * * *\"\n    volumes:\n      - /opt/paperless-ai/data:/app/data\n</code></pre>\n<p>The scan interval is 2 minutes. Every 2 minutes, paperless-ai checks for new documents and runs them through the LLM. Initially I had it at 30 minutes (the default) and kept wondering why nothing was happening after uploads.</p>\n<h3 id=\"paperless-gpt\">paperless-gpt</h3>\n<pre><code class=\"language-yaml\">services:\n  paperless-gpt:\n    image: icereed/paperless-gpt:latest\n    container_name: paperless-gpt\n    restart: unless-stopped\n    ports:\n      - \"3002:8080\"\n    env_file: .env\n    environment:\n      TZ: America/New_York\n      PAPERLESS_BASE_URL: http://192.168.1.2:28981\n      LLM_PROVIDER: ollama\n      LLM_MODEL: llama3.2:3b\n      OLLAMA_HOST: http://192.168.1.53:11434\n      OLLAMA_CONTEXT_LENGTH: \"8192\"\n      TOKEN_LIMIT: \"1000\"\n      LLM_LANGUAGE: English\n      OCR_PROVIDER: llm\n      VISION_LLM_PROVIDER: ollama\n      VISION_LLM_MODEL: minicpm-v:8b\n      AUTO_OCR_TAG: paperless-gpt-ocr-auto\n      AUTO_TAG: paperless-gpt-auto\n      MANUAL_TAG: paperless-gpt-manual\n      PDF_OCR_TAGGING: \"true\"\n      PDF_OCR_COMPLETE_TAG: paperless-gpt-ocr-complete\n      PDF_UPLOAD: \"false\"\n      LOG_LEVEL: DEBUG\n    volumes:\n      - /opt/paperless-gpt/prompts:/app/prompts\n</code></pre>\n<p>Both paperless-ai and paperless-gpt use bridge networking with port mapping instead of host networking. This was necessary because their default ports (3000 and 8080) were already taken by other services on the Portainer host. Neither port is configurable via environment variable, which I discovered after watching them crash-loop with <code>EADDRINUSE</code> errors.</p>\n<h2 id=\"the-prompt-engineering-rabbit-hole\">The Prompt Engineering Rabbit Hole</h2>\n<p>This is where I spent more time than I expected. The default paperless-ai prompt does a decent job at generating titles and detecting correspondents, but it was ignoring my specific requirements: tagging documents as \"tax-deductible\" or \"medical.\"</p>\n<p>The first attempt was a long, detailed prompt with all the rules. The small model (llama3.2:3b) would fill all 4 tag slots with descriptive tags like \"Web Hosting\" and \"Invoice\" and leave no room for \"tax-deductible.\" Upgrading to qwen3:8b helped, but the real fix was restructuring the prompt.</p>\n<p>What worked: making the classification tags <strong>mandatory</strong> and putting them before the descriptive tags. Instead of \"include the tag tax-deductible if applicable\" buried in a paragraph, I made it:</p>\n<pre><code>Tag rules (CRITICAL — follow exactly):\n- Start with mandatory tags, then add up to 3 descriptive tags.\n- MANDATORY: If the expense is potentially tax-deductible (...), you MUST include the tag \"tax-deductible\".\n- MANDATORY: If the document is healthcare-related (...), you MUST include the tag \"medical\".\n- MANDATORY: If the document is a personal identity or life document, you MUST include the tag \"personal documents\".\n- Then add up to 3 descriptive tags for the document topic.\n</code></pre>\n<p>The word \"MANDATORY\" and \"MUST\" in caps, plus front-loading the rules before the descriptive tags, made the model consistently apply them. Small models respond better to structure and emphasis than to nuance.</p>\n<p>I also found that giving the model explicit examples of what qualifies as tax-deductible (business expenses, software subscriptions, tolls, web hosting, domain renewals) produced much better results than just saying \"business expenses.\" The model doesn't have your tax accountant's intuition. Spell it out.</p>\n<h2 id=\"performance-on-apple-silicon\">Performance on Apple Silicon</h2>\n<p>The M4 Max handles this workload comfortably:</p>\n<table>\n<thead>\n<tr>\n<th>Model</th>\n<th>Speed</th>\n<th>Use Case</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>qwen3:8b</td>\n<td>~41 tokens/s generation</td>\n<td>paperless-ai (text classification)</td>\n</tr>\n<tr>\n<td>minicpm-v:8b</td>\n<td>~48 tokens/s generation</td>\n<td>paperless-gpt (vision OCR)</td>\n</tr>\n</tbody>\n</table>\n<p>First inference after model load takes ~20 seconds (cold load into GPU memory), but subsequent requests are near-instant because <code>OLLAMA_KEEP_ALIVE=15m</code> keeps the model warm. For document processing (not real-time chat), this is more than fast enough.</p>\n<p>On the Paperless side, setting <code>PAPERLESS_TASK_WORKERS=5</code> lets it OCR multiple documents in parallel. HEIC photos from an iPhone take a couple of minutes each through Tesseract, so parallelism helps when you batch-upload a stack of receipts.</p>\n<h2 id=\"what-i-learned\">What I Learned</h2>\n<p>This project changed how I interact with paper documents. I take a photo, drag it into Paperless, and within a couple of minutes it's filed, tagged, searchable, and backed up on the NAS. Tax season next year should be a filter query instead of a camera roll excavation.</p>\n<p>The biggest lesson: <strong>Ollama on Apple Silicon is genuinely good.</strong> Something that technically works but isn't practical, claude, chatgpt and other models are far more superior. The M4 Max runs these models fast enough that document processing feels near-instant. The unified memory architecture means even the 8B parameter models load without drama. If you have a Mac collecting dust, this is a great use for it.</p>\n<p>The second lesson: <strong>prompt engineering matters more than model size.</strong> Switching from a 3B to an 8B model helped, but restructuring the prompt to front-load mandatory rules was the bigger improvement. Small local models are capable, but you have to be explicit. They don't infer what you want from context the way larger cloud models do.</p>\n<p>The third lesson: <strong>LXC containers and NFS don't mix directly.</strong> If you're running Docker inside an unprivileged LXC on Proxmox and you need NAS storage, mount on the Proxmox host and bind-mount into the LXC. Don't spend an hour tweaking NFS export settings on TrueNAS like I did. The <code>mount()</code> syscall is blocked inside the container. No amount of NFS flags will fix a kernel-level restriction.</p>\n<h2 id=\"whats-next-automated-tax-categorization\">What's Next: Automated Tax Categorization</h2>\n<p>Right now, the AI tags documents as \"tax-deductible\" or not. That's already a huge win. But \"tax-deductible\" is a broad bucket. When my wife's CPA asks for expenses broken down by category, we still have to sort through the pile manually.</p>\n<p>If you're wondering why doesn't she just use quickbooks or whatever. She started last year but there is still figuring out on her, what works best for her expense-wise. Sometimes she ends up paying business expense with personal card or cash, and it's important to categorize those correctly. I would say having quickbooks makes this 80% easier than previous few years. Even tho remaining 20% isn't as big it's definitely the most annoying.</p>\n<p>The v2 vision is a second step in the pipeline that takes everything tagged \"tax-deductible\" and classifies it into one of the actual IRS expense categories we use:</p>\n<table>\n<thead>\n<tr>\n<th>Category</th>\n<th>Category</th>\n<th>Category</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Advertising</td>\n<td>Mileage</td>\n<td>Sunpass</td>\n</tr>\n<tr>\n<td>Parking &amp; Tolls</td>\n<td>Bank Charges</td>\n<td>Computer &amp; Internet</td>\n</tr>\n<tr>\n<td>Client Reimbursement</td>\n<td>Dues &amp; Subs</td>\n<td>Education/Coach</td>\n</tr>\n<tr>\n<td>Gifts</td>\n<td>Insurance (Health)</td>\n<td>Insurance (Other)</td>\n</tr>\n<tr>\n<td>Interest</td>\n<td>Legal &amp; Prof.</td>\n<td>Meals &amp; Ent.</td>\n</tr>\n<tr>\n<td>Office Exp.</td>\n<td>Postage</td>\n<td>Assistant</td>\n</tr>\n<tr>\n<td>Repairs &amp; Maint.</td>\n<td>Supplies</td>\n<td>Taxes &amp; Licenses</td>\n</tr>\n<tr>\n<td>Telephone</td>\n<td>Travel</td>\n<td>Staging</td>\n</tr>\n<tr>\n<td>Utilities</td>\n<td>Pension Exp (SEP/SIMPLE)</td>\n<td>Photography</td>\n</tr>\n<tr>\n<td>Officer's Salary</td>\n<td>Other Wages</td>\n<td>Payroll Taxes</td>\n</tr>\n</tbody>\n</table>\n<p>These are the exact column headers from the spreadsheet we use today. The dream is to upload a receipt, have the AI tag it as tax-deductible, then automatically sub-categorize it into \"Computer &amp; Internet\" or \"Meals &amp; Ent.\" or \"Travel.\" At year end, export a filtered view grouped by category and hand it straight to the CPA. No more multi-day spreadsheet sessions.</p>\n<p>This is probably a custom field or a second tag layer in paperless-ai, combined with a more specific prompt. Or maybe a small script that runs after paperless-ai processing and does a second pass with a more focused prompt. Either way, the infrastructure is all in place now. The hard part is done. The categorization logic is just prompt engineering.</p>\n<h2 id=\"featured-image-prompt\">Featured Image Prompt</h2>\n<p><strong>I used this prompt to generate the featured image.</strong> A cozy home office desk with a stack of messy paper documents on one side being fed into a glowing portal, emerging on the other side as neatly organized digital files floating in a holographic display. A small robot assistant with a magnifying glass is examining each document. The desk has a Mac Mini and a NAS box with blinking lights in the background. Warm lighting, isometric perspective, clean vector illustration style with a slight retro-tech aesthetic.</p>\n","comment_id":"69e239d43cad350001301c89","feature_image":"https://emir.fyi/content/images/2026/04/ChatGPT-Image-Apr-17--2026--01_37_11-PM.png","featured":false,"visibility":"public","created_at":"2026-04-17T09:47:00.000-04:00","updated_at":"2026-04-17T13:42:11.000-04:00","published_at":"2026-01-28T15:38:00.000-05:00","custom_excerpt":null,"codeinjection_head":null,"codeinjection_foot":null,"custom_template":null,"canonical_url":null,"url":"https://emir.fyi/turning-paperless-ngx-into-a-smart-filing-cabinet-with-local-ai-and-nas-storage/","excerpt":"The Itch\n\n\nI had Paperless-ngx running. Technically. It was deployed on Portainer, had a DNS entry, a TLS cert, the whole nine yards. It also had zero documents in it. It was a ghost town with a login page. Every time I'd get a medical bill, a receipt, or a tax document, I'd do what every responsible adult does: take a photo on my phone and forget which album it ended up in.\n\n\nThe straw that broke it was tax season. Every year, my wife and I spend hours across multiple days going through all of ","reading_time":11,"access":true,"comments":false,"og_image":null,"og_title":null,"og_description":null,"twitter_image":null,"twitter_title":null,"twitter_description":null,"meta_title":null,"meta_description":null,"email_subject":null,"frontmatter":null,"feature_image_alt":null,"feature_image_caption":null}],"meta":{"pagination":{"page":1,"limit":100,"pages":1,"total":22,"next":null,"prev":null}}}