<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: sanskar arora</title>
    <description>The latest articles on DEV Community by sanskar arora (@snskarora).</description>
    <link>https://dev.to/snskarora</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2766323%2Fa4119da7-4968-4940-b789-a83923f36650.png</url>
      <title>DEV Community: sanskar arora</title>
      <link>https://dev.to/snskarora</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/snskarora"/>
    <language>en</language>
    <item>
      <title>🐈 HomeLab Chronicles: Episode 11 - schrodingers cluster</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Mon, 02 Nov 2026 22:28:27 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-11-schrodingers-cluster-4cf4</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-11-schrodingers-cluster-4cf4</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Last episode was an RCA with seven root causes and a port number. This one is an RCA with one root cause, five wrong theories, and a cluster that reported itself healthy for thirty-eight hours while doing absolutely nothing.&lt;/p&gt;

&lt;p&gt;State of the lab going in: two nodes, Flux in charge, Airflow on a public domain through a Cloudflare tunnel, nightly backups, DHCP reservations finally ticked. Episode 10 ended with "the router remains the weakest member of the team." I would like to amend that statement. The router has competition.&lt;/p&gt;

&lt;p&gt;Here's what happened, in the order I found out, which is not the order it happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚨 Symptom: The Tunnel Is Down
&lt;/h2&gt;

&lt;p&gt;Sunday night, 10 PM. Cloudflare's Zero Trust dashboard, which has shown two happy connectors since Episode 9, says the tunnel is down. No connectors. Nothing.&lt;/p&gt;

&lt;p&gt;First look at the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get nodes
NAME   STATUS     ROLES    AGE   VERSION
dell   NotReady   &amp;lt;none&amp;gt;   64d   v1.35.6
msi    Ready      &amp;lt;none&amp;gt;   64d   v1.35.6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dell is down. Has been for two and a half days, it turns out. And the pods tell the failover story exactly the way the textbook says it should go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n cloudflared get pods -o wide
NAME                           READY   STATUS        RESTARTS   AGE     NODE
cloudflared-6cf54796d6-42rg9   1/1     Running       0          2d16h   msi
cloudflared-6cf54796d6-7g425   1/1     Terminating   0          5d9h    dell
cloudflared-6cf54796d6-jkt44   1/1     Running       0          2d16h   msi
cloudflared-6cf54796d6-t567p   1/1     Terminating   0          5d9h    dell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dell died, the old pods went &lt;code&gt;Terminating&lt;/code&gt;, two fresh replicas came up on msi 2d16h ago. &lt;code&gt;Running&lt;/code&gt;. &lt;code&gt;1/1&lt;/code&gt;. Zero restarts. Kubernetes did its job.&lt;/p&gt;

&lt;p&gt;Small aside before we continue. Episode 9 proudly said: &lt;em&gt;two replicas with pod anti-affinity so the tunnel survives a node dying&lt;/em&gt;. Look at the ages. Both old replicas were on dell. Anti-affinity on a two-node cluster is a &lt;em&gt;preference&lt;/em&gt;, and the scheduler preferred dell twice. The tunnel survived the node dying anyway, because failover worked. It just didn't survive what came next.&lt;/p&gt;

&lt;p&gt;So the question was simple. Two healthy connectors on msi, dashboard says zero. Why?&lt;/p&gt;




&lt;h2&gt;
  
  
  🔬 Hypothesis 1: The Failover Path Was Never Tested
&lt;/h2&gt;

&lt;p&gt;Both replicas had always lived on dell. This was the first time &lt;code&gt;cloudflared&lt;/code&gt; had ever run from msi. Maybe msi's egress was different: UDP 7844 blocked, QUIC failing, DNS for &lt;code&gt;argotunnel.com&lt;/code&gt; not resolving from msi's pods.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; read the pod's log. &lt;strong&gt;The fix if true:&lt;/strong&gt; &lt;code&gt;TUNNEL_TRANSPORT_PROTOCOL=http2&lt;/code&gt; to force TCP, or fix whatever was eating UDP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened instead:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n cloudflared logs cloudflared-6cf54796d6-42rg9
Error from server: Get "https://192.168.0.201:10250/containerLogs/...":
tls: failed to verify certificate: x509: certificate is valid for 192.168.68.210, not 192.168.0.201
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Episode 10, Sidequest 2, verbatim, except on the other node. When I moved subnets, I fixed dell's kubelet certificate because that's where the pod I wanted to read was. msi's kubelet had the same stale certificate the entire time, and nobody noticed because nobody asked msi for logs. &lt;code&gt;kubectl get&lt;/code&gt; doesn't touch the kubelet. &lt;code&gt;kubectl logs&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;Fine. The host has the logs on disk. Over ssh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 80 /var/log/pods/cloudflared_cloudflared-6cf54796d6-42rg9_&lt;span class="k"&gt;*&lt;/span&gt;/cloudflared/0.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the log was a complete short story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-10-31T01:11:58Z INF Registered tunnel connection connIndex=1 ... location=blr03 protocol=quic
2026-10-31T01:11:58Z INF Registered tunnel connection connIndex=2 ...
2026-10-31T01:11:59Z INF Registered tunnel connection connIndex=3 ...
2026-10-31T01:12:03Z INF |  SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol.  |
...
2026-11-01T07:24:54Z WRN Serve tunnel error error="... timeout: no recent network activity" connIndex=0
2026-11-01T07:24:54Z WRN Serve tunnel error error="... timeout: no recent network activity" connIndex=3
2026-11-01T07:24:54Z WRN Serve tunnel error error="... timeout: no recent network activity" connIndex=2
2026-11-01T07:24:54Z ERR Serve tunnel error error="... timeout: no recent network activity" connIndex=1
...
2026-11-01T07:25:19Z INF Initiating graceful shutdown due to signal terminated ...
2026-11-01T07:25:22Z ERR no more connections active and exiting
2026-11-01T07:25:22Z INF Tunnel server stopped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hypothesis 1, dead. Failover worked perfectly. The msi replica registered four connections on Friday morning and every pre-check passed. Then on Saturday at 12:54 PM (the logs are UTC; I'm not), all four connections timed out in the same second, a SIGTERM arrived twenty-five seconds later, and the process exited cleanly.&lt;/p&gt;

&lt;p&gt;And then nothing. No new container. The log file ends there. For thirty-eight hours, the API said &lt;code&gt;Running, 0 restarts&lt;/code&gt; about a process that had logged its own obituary.&lt;/p&gt;

&lt;p&gt;That changed the question. It was no longer "why is the tunnel down." It was &lt;strong&gt;"why did the kubelet not restart a dead container, and why is it lying about it?"&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  📡 Interlude: Who Killed It
&lt;/h2&gt;

&lt;p&gt;Four connections dying in the same second is not Cloudflare. That's the floor falling out from under the network. msi's journal for that minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Nov 01 07:24:49 msi wpa_supplicant[760438]: wlo1: CTRL-EVENT-BEACON-LOSS
Nov 01 07:24:50 msi wpa_supplicant[760438]: wlo1: CTRL-EVENT-DISCONNECTED bssid=... reason=4 locally_generated=1
Nov 01 07:25:32 msi microk8s.daemon-kubelite[1122435]: ...   &amp;lt;- kubelite starting
Nov 01 07:25:43 msi microk8s.daemon-kubelite[1122802]: ...   &amp;lt;- and again
Nov 01 07:25:53 msi microk8s.daemon-kubelite[1123187]: ...   &amp;lt;- and again
Nov 01 07:25:54 msi wpa_supplicant[760438]: wlo1: CTRL-EVENT-CONNECTED
Nov 01 07:26:55 msi microk8s.daemon-kubelite[1123923]: ...   &amp;lt;- fourth time's the charm
Nov 01 07:28:19 msi kernel: wlo1: deauthenticated from ... (Reason: 7=CLASS3_FRAME_FROM_NONASSOC_STA)
Nov 01 07:28:19 msi kernel: iwlwifi 0000:00:14.3: 0x004D9C74 | branchlink2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wi-Fi power save. The laptop's radio napped through too many beacons, decided the router was gone, and disconnected itself (&lt;code&gt;reason=4 locally_generated=1&lt;/code&gt;, which is the card saying "this was my idea"). When the address vanished, so did the thing dqlite is bound to. Episode 5 explained this exact mechanism: a single-node MicroK8s keeps its datastore on loopback and is immune to this, and the day the Dell joined, dqlite moved to the LAN address and became a hostage of the Wi-Fi card. kubelite crash-looped four times in two minutes. Then the router kicked msi off twice more (&lt;code&gt;reason=7&lt;/code&gt;, the router's idea this time), with an Intel firmware crash dump for garnish.&lt;/p&gt;

&lt;p&gt;Somewhere in that mess, &lt;code&gt;cloudflared&lt;/code&gt; got its SIGTERM. I never pinned down who sent it; my best guess is a liveness probe doing its job during the chaos. It doesn't matter much. What matters is that the kubelet came back after the fourth restart and then &lt;strong&gt;did nothing&lt;/strong&gt;, for a day and a half.&lt;/p&gt;

&lt;p&gt;The Episode 5 fix for the symptom, applied on the spot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iw dev wlo1 &lt;span class="nb"&gt;set &lt;/span&gt;power_save off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual fix is a cable. We'll get there.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪪 Blocker: The Certificate, Again
&lt;/h2&gt;

&lt;p&gt;Before any more theories I needed &lt;code&gt;kubectl logs&lt;/code&gt; and &lt;code&gt;exec&lt;/code&gt; working on msi, because a debugging session without them is archaeology. Episode 10's trick was &lt;code&gt;--rotate-server-certificates=true&lt;/code&gt;: let the kubelet request a fresh cert, approve the CSR, done. On msi it produced a different error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error from server: Get "https://192.168.0.201:10250/containerLogs/...": remote error: tls: internal error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get csr
No resources found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tls: internal error&lt;/code&gt; is the kubelet saying "rotation is on, I have nothing to serve yet, and I'm not going to serve the old file either." And it never filed a CSR. No error about why. Just a kubelet politely waiting for a certificate it never asked for. Hold that thought, because in hindsight it's the same bug wearing a different hat.&lt;/p&gt;

&lt;p&gt;I stopped debugging rotation and signed the certificate by hand with the cluster CA. Same private key, new SAN list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/snap/microk8s/current/certs
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; kubelet.crt kubelet.crt.bak

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'basicConstraints=CA:FALSE'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'keyUsage=critical,digitalSignature,keyEncipherment'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'extendedKeyUsage=serverAuth'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'subjectAltName=DNS:msi,IP:192.168.0.201,IP:127.0.0.1'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/kubelet-ext.cnf

&lt;span class="nb"&gt;sudo &lt;/span&gt;openssl req &lt;span class="nt"&gt;-new&lt;/span&gt; &lt;span class="nt"&gt;-key&lt;/span&gt; kubelet.key &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=msi"&lt;/span&gt; &lt;span class="nt"&gt;-out&lt;/span&gt; /tmp/kubelet.csr
&lt;span class="nb"&gt;sudo &lt;/span&gt;openssl x509 &lt;span class="nt"&gt;-req&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; /tmp/kubelet.csr &lt;span class="nt"&gt;-CA&lt;/span&gt; ca.crt &lt;span class="nt"&gt;-CAkey&lt;/span&gt; ca.key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-CAcreateserial&lt;/span&gt; &lt;span class="nt"&gt;-CAserial&lt;/span&gt; /tmp/kubelet-ca.srl &lt;span class="nt"&gt;-days&lt;/span&gt; 3650 &lt;span class="nt"&gt;-sha256&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-extfile&lt;/span&gt; /tmp/kubelet-ext.cnf &lt;span class="nt"&gt;-out&lt;/span&gt; /tmp/kubelet.crt

&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 660 &lt;span class="nt"&gt;-o&lt;/span&gt; root &lt;span class="nt"&gt;-g&lt;/span&gt; microk8s /tmp/kubelet.crt kubelet.crt
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/^--rotate-server-certificates=true$/d'&lt;/span&gt; /var/snap/microk8s/current/args/kubelet
&lt;span class="nb"&gt;sudo &lt;/span&gt;snap restart microk8s.daemon-kubelite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ echo | openssl s_client -connect 192.168.0.201:10250 2&amp;gt;/dev/null | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:msi, IP Address:192.168.0.201, IP Address:127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Certificate fixed. &lt;code&gt;kubectl logs&lt;/code&gt; again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n kube-system logs deploy/coredns --tail=3
Error from server (NotFound): the server could not find the requested resource ( pods/log coredns-78894c95f4-l8n8j)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;404&lt;/strong&gt;. From the kubelet. The TLS handshake succeeded, the API server asked msi's kubelet for coredns's logs, and the kubelet said "never heard of it." About a pod the API says has been running on msi for 64 days.&lt;/p&gt;

&lt;p&gt;That's not a certificate problem. That's the kubelet not knowing what it's running.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔬 Hypothesis 2: The Kubelet Has Lost Its Pods
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; ask the kubelet directly for its pod list, through the API server's node proxy, and compare with what the API thinks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt; spec.nodeName&lt;span class="o"&gt;=&lt;/span&gt;msi &lt;span class="nt"&gt;-o&lt;/span&gt; name | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# 34&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt; /api/v1/nodes/msi/proxy/pods
&lt;span class="c"&gt;# {"kind":"PodList", ..., "items": null}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty-four pods in the API. &lt;strong&gt;Zero&lt;/strong&gt; in the kubelet. Not "some missing." Null. And the kubelet's journal, every ten seconds, since a very specific moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Nov 01 07:26:57 msi kubelite: E1101 07:26:57 kubelet_node_status.go:392]
  "Error getting the current node from lister" err="node \"msi\" not found"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;07:26:57. Two seconds after the fourth kubelite restart on Saturday. The kubelet can't find its own node, and it's been saying so 15,000 times while &lt;code&gt;kubectl get nodes&lt;/code&gt; says &lt;code&gt;msi Ready&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Confirmed, then: the kubelet's view of the cluster is empty. Everything downstream follows. It won't restart a container belonging to a pod it doesn't know. It won't write status for it, so the API keeps showing the last status it ever wrote (the frozen &lt;code&gt;Running, 0 restarts&lt;/code&gt;). It answers log requests with 404. And meanwhile containerd, which doesn't care about any of this, keeps running whatever was already running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo microk8s ctr tasks ls | awk 'NR&amp;gt;1{print $3}' | sort | uniq -c
     59 RUNNING
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Airflow was fine. Postgres was fine. Fifty-nine containers happily running with no supervisor. The only things missing were the ones that had died, which nobody was around to restart.&lt;/p&gt;

&lt;p&gt;But "the kubelet's view is empty" is a symptom. The question is &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔬 Hypothesis 3: The API Server's Watch Cache Is Empty
&lt;/h2&gt;

&lt;p&gt;Quick mechanism, because the rest of the post depends on it. The API server has two ways to answer a read. A plain &lt;code&gt;GET&lt;/code&gt; goes to the datastore. But every component that &lt;em&gt;watches&lt;/em&gt; the cluster (the kubelet, the controller-manager, the scheduler, Flux) reads through the &lt;strong&gt;watch cache&lt;/strong&gt;: an in-memory copy of the datastore that the API server keeps current by watching the datastore itself. Thousands of watchers, one datastore watch. That's what lets Kubernetes scale. You can also read from it on purpose, by asking for &lt;code&gt;resourceVersion=0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If that cache were empty, you'd get exactly this: &lt;code&gt;kubectl&lt;/code&gt; sees a healthy cluster (datastore), every component sees nothing (cache).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; the same question, both ways. &lt;strong&gt;The fix if true:&lt;/strong&gt; restart kubelite, since the cache is rebuilt on every API server start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo microk8s kubectl get --raw '/api/v1/pods?fieldSelector=spec.nodeName%3Dmsi' | ...
34
$ sudo microk8s kubectl get --raw '/api/v1/pods?fieldSelector=spec.nodeName%3Dmsi&amp;amp;resourceVersion=0' | ...
34
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dead. The cache had all 34 pods. (I had also restarted kubelite twice by then, so an empty cache would have refilled anyway. Keep that in mind; it comes back.)&lt;/p&gt;




&lt;h2&gt;
  
  
  🔬 Hypothesis 4: The Kubelet's Credentials Are Broken
&lt;/h2&gt;

&lt;p&gt;The API server answers &lt;em&gt;me&lt;/em&gt; fine. Maybe it doesn't answer the kubelet. Episode 7 was an entire episode about the control plane being forbidden from reading its own objects, so this had form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; replay the kubelet's exact startup queries using the kubelet's own kubeconfig. &lt;strong&gt;The fix if true:&lt;/strong&gt; whatever the error says, probably certificates or RBAC.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ K="sudo /snap/microk8s/current/kubectl --kubeconfig /var/snap/microk8s/current/credentials/kubelet.config"
$ $K auth whoami
Username   system:node:msi
Groups     [system:nodes system:authenticated]
$ $K get --raw '/api/v1/nodes?fieldSelector=metadata.name%3Dmsi&amp;amp;resourceVersion=0' | ...
1 ['msi']
$ $K get --raw '/api/v1/pods?fieldSelector=spec.nodeName%3Dmsi&amp;amp;resourceVersion=0' | ...
34
$ $K get --raw '/api/v1/pods?fieldSelector=spec.nodeName%3Dmsi&amp;amp;watch=1&amp;amp;resourceVersion=0' | head -c 200
{"type":"ADDED","object":{"kind":"Pod", ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dead. The kubelet's identity gets the node, gets all 34 pods, and can open a watch. Every request the kubelet &lt;em&gt;could&lt;/em&gt; make works when I make it.&lt;/p&gt;

&lt;p&gt;So the kubelet isn't making them.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔬 Hypothesis 5: The Kubelet's Informers Are Failing
&lt;/h2&gt;

&lt;p&gt;Each component keeps its own local cache of the objects it cares about (an &lt;em&gt;informer&lt;/em&gt;): one LIST to fill it, then a WATCH to keep it current. The kubelet won't touch pods until its informers have loaded once. If the LIST or WATCH were failing, the kubelet would log it and retry. Loudly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; grep for reflector errors. &lt;strong&gt;The fix if true:&lt;/strong&gt; depends on the error, but at least there'd be one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo journalctl -u snap.microk8s.daemon-kubelite --since "90 min ago" \
    | grep -E 'reflector|failed to list|Failed to watch|forbidden|deadline exceeded'
(nothing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dead, in a way that was more informative than an error would have been. Nothing was &lt;em&gt;failing&lt;/em&gt;. And the controller-manager, which runs in the same kubelite process, told me what "nothing" looked like from the inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo journalctl -u snap.microk8s.daemon-kubelite --since "90 min ago" | grep -E 'Waiting for caches to sync|Caches are synced'
Nov 02 20:25:02 msi kubelite: I1102 20:25:02 shared_informer.go:370] "Waiting for caches to sync"
Nov 02 20:25:02 msi kubelite: I1102 20:25:02 shared_informer.go:370] "Waiting for caches to sync"
Nov 02 20:25:02 msi kubelite: I1102 20:25:02 shared_informer.go:370] "Waiting for caches to sync"
... forty of these, all from the same second ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty minutes after startup. Forty controllers waiting for caches to sync. Zero &lt;code&gt;Caches are synced&lt;/code&gt;. Every informer in the entire control plane was stuck in the waiting room, not erroring, just waiting. Which, incidentally, is why &lt;code&gt;kubectl rollout restart deployment/cloudflared&lt;/code&gt; an hour earlier had done precisely nothing: the deployment controller was in the same waiting room.&lt;/p&gt;

&lt;p&gt;This also explains the certificate rotation that never filed a CSR. The kubelet's cert manager needs the node informer to have synced before it can build the request. Same wait, different symptom.&lt;/p&gt;

&lt;p&gt;Four hypotheses down. Every test says the API server is fine, the data is fine, the credentials are fine, nothing is failing, and nothing is happening.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧊 Hypothesis 6: The Cache Isn't Empty. It's Frozen.
&lt;/h2&gt;

&lt;p&gt;Here's the hole in Hypothesis 3. When an informer starts, it doesn't ask for "whatever the cache has." It asks for the current state, and the API server, before answering, &lt;strong&gt;waits for its watch cache to catch up to the datastore's latest revision&lt;/strong&gt;. A fresh snapshot, guaranteed. My &lt;code&gt;resourceVersion=0&lt;/code&gt; test never asked for that guarantee. It asked for the stale copy and got it.&lt;/p&gt;

&lt;p&gt;A frozen cache, one that took a snapshot and then stopped receiving updates, would pass my test and hang every informer forever. And my test couldn't tell frozen from live because &lt;strong&gt;nothing in the cluster was changing&lt;/strong&gt;, because everything that changes things was stuck waiting on the cache. Circular. Beautiful. Infuriating.&lt;/p&gt;

&lt;p&gt;To catch it I needed an object that was still being written. The kubelet renews its node lease every ten seconds, straight to the datastore, informers or no informers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The test:&lt;/strong&gt; read the lease from the datastore, then from the cache.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ date -u
Mon Nov  2 21:19:24 UTC 2026
$ ... leases/msi               -&amp;gt; rv 3815025  renewTime 2026-11-02T21:19:19Z
$ ... leases/msi?resourceVersion=0 -&amp;gt; rv 3810400  renewTime 2026-11-02T20:23:53Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed. The datastore's copy was five seconds old. The cache's copy was &lt;strong&gt;fifty-five minutes old&lt;/strong&gt;, from 20:23:53, which is three seconds before I had restarted kubelite for the certificate. The cache took one snapshot at startup and then received &lt;strong&gt;nothing&lt;/strong&gt;. Four thousand six hundred revisions behind and counting.&lt;/p&gt;

&lt;p&gt;Every informer in kubelite was waiting for a cache to catch up to a datastore it had stopped listening to.&lt;/p&gt;

&lt;p&gt;And why did restarting kubelite not fix it, twice? Because the cache is fed by a watch stream from &lt;strong&gt;k8s-dqlite&lt;/strong&gt;, which is its own snap service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ systemctl show snap.microk8s.daemon-k8s-dqlite -p ActiveEnterTimestamp
ActiveEnterTimestamp=Wed 2026-10-28 07:07:22 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running since the Episode 10 fix. The one component in the whole chain that had never restarted. And its log from Saturday, during the Wi-Fi hole:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Nov 01 07:32:33 k8s-dqlite: [go-dqlite] attempt 10: server 192.168.0.201:19001: dial: connect: network is unreachable
Nov 01 07:32:33 k8s-dqlite: error in txn: update transaction failed for key /registry/longhorn.io/nodes/longhorn-system/msi: context canceled
Nov 01 07:32:42 k8s-dqlite: error while range on /registry/serviceaccounts/: query (try: 0): context canceled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The datastore, unable to reach its own address, cancelling its own queries. Episode 10's &lt;code&gt;dial ... i/o timeout&lt;/code&gt; to your own old IP, except this time the IP was correct and simply didn't exist for ninety seconds.&lt;/p&gt;

&lt;p&gt;k8s-dqlite is really &lt;em&gt;kine&lt;/em&gt;, a shim that makes dqlite speak etcd's API. kine finds new writes by polling its table and pushes them to anyone watching, including the API server's cache. Somewhere in that &lt;code&gt;context canceled&lt;/code&gt; storm, the push stopped, while reads and writes carried on working. Every kubelite restart after that got a fresh cache with a perfect snapshot and a dead feed. I never found the exact line of kine that wedged. I found the fix, which was good enough at 3 AM.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔧 The Fix
&lt;/h2&gt;

&lt;p&gt;Restart the datastore, with the API server stopped so it isn't holding connections mid-stream, then bring the API server back to build a cache on a live feed. Containers keep running throughout; containerd doesn't care about any of this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s dbctl backup /var/backups/microk8s/pre-restart-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# paranoia is a feature&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;snap stop microk8s.daemon-kubelite
&lt;span class="nb"&gt;sudo &lt;/span&gt;snap restart microk8s.daemon-k8s-dqlite
&lt;span class="nb"&gt;sleep &lt;/span&gt;15
&lt;span class="nb"&gt;sudo &lt;/span&gt;snap start microk8s.daemon-kubelite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninety seconds later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ... leases/msi               -&amp;gt; rv 3817920  renewTime 2026-11-02T21:50:45Z
$ ... leases/msi?resourceVersion=0 -&amp;gt; rv 3817920  renewTime 2026-11-02T21:50:45Z
$ journalctl ... | grep -c 'Caches are synced'
58
$ journalctl ... | grep 'Watching apiserver'
Nov 02 21:49:13 msi kubelite: I1102 21:49:13 apiserver.go:52] "Watching apiserver"
$ ... /api/v1/nodes/msi/proxy/pods -&amp;gt; 34
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same revision from both read paths. Fifty-eight controllers synced. The kubelet watching the API server again. Thirty-four pods known. And:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n cloudflared get pods
NAME                           READY   STATUS    RESTARTS      AGE
cloudflared-6cf54796d6-42rg9   1/1     Running   1 (38h ago)   2d20h
cloudflared-6cf54796d6-jkt44   1/1     Running   1 (38h ago)   2d20h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RESTARTS 1 (38h ago)&lt;/code&gt;. The kubelet woke up, found two dead containers, restarted them, and for the first time in a day and a half wrote a status that was true. The "38h ago" is when the previous container died: Saturday, 12:55 PM, exactly where the log file ended.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n cloudflared logs cloudflared-6cf54796d6-42rg9 --tail=5
INF Registered tunnel connection connIndex=0 ...
INF Registered tunnel connection connIndex=1 ...
INF Registered tunnel connection connIndex=2 ...
INF Registered tunnel connection connIndex=3 ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dashboard: Healthy. Two connectors. Airflow back on its domain.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cluster has two read paths, and they can disagree for days.&lt;/strong&gt; &lt;code&gt;kubectl get&lt;/code&gt; reads the datastore. Every controller reads the watch cache. When the cache freezes, you get a cluster where every human check passes and every automated action silently stops. Nothing errors, because waiting isn't an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Running, 0 restarts" is a claim the kubelet made once.&lt;/strong&gt; Pod status is written by the kubelet. If the kubelet stops writing, the API keeps serving the last thing it wrote, indefinitely. Status is a snapshot, not a heartbeat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every canary I had lied.&lt;/strong&gt; Episode 7's &lt;code&gt;readyz&lt;/code&gt; was green. Episode 7's coredns was &lt;code&gt;1/1&lt;/code&gt; (it was frozen there). The controller-manager lease was even renewing (leader election doesn't need informers). The one thing that told the truth was a renewTime read two ways. New laminated card: &lt;code&gt;kubectl get lease -n kube-node-lease &amp;lt;node&amp;gt;&lt;/code&gt; with and without &lt;code&gt;resourceVersion=0&lt;/code&gt;. They must match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restarting the thing that's stuck doesn't help when the stuck thing is downstream.&lt;/strong&gt; I restarted kubelite twice. The feed it depended on lived in a process I hadn't touched since October. Find the oldest PID in the chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hypotheses die by test, not by argument.&lt;/strong&gt; Five theories, each plausible, each killed by one command. The frozen cache only became visible when I stopped testing &lt;em&gt;content&lt;/em&gt; and started testing &lt;em&gt;freshness&lt;/em&gt;, with an object that was still moving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anti-affinity on two nodes is a suggestion.&lt;/strong&gt; Both tunnel replicas lived on the same node for five days. The fix is a &lt;code&gt;topologySpreadConstraint&lt;/code&gt;, which is a rule, not a wish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Joining the second node moved the datastore onto Wi-Fi, and Wi-Fi power save is a slow-motion power cut.&lt;/strong&gt; Episode 5 said this about the router dying. Turns out the laptop can do it to itself, from the inside, whenever the radio feels sleepy. &lt;code&gt;power_save off&lt;/code&gt; is on now. The real fix is a cable into msi, and I've stopped pretending otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The certificate was never the problem, and I'd have been lost without fixing it.&lt;/strong&gt; The stale kubelet cert blocked &lt;code&gt;logs&lt;/code&gt; and &lt;code&gt;exec&lt;/code&gt;, and the rotation trick from Episode 10 failed because of the same frozen cache I hadn't found yet. Hand-signing it with the cluster CA took five minutes and unblocked everything after.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;Dell is still NotReady, with its ghost pods stuck in &lt;code&gt;Terminating&lt;/code&gt; and a triggerer StatefulSet that won't schedule a replacement until the old pod is confirmed dead. That's its own evening and its own post. After that: a spread constraint and a &lt;code&gt;/ready&lt;/code&gt; liveness probe on the tunnel pods, so a dead connector gets restarted instead of memorialized. And a monitoring question I don't have a good answer to yet: what do you alert on when the cluster's own health signals are the thing that froze?&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;For thirty-eight hours my cluster was Schrödinger's: alive if you asked the datastore, dead if you asked anything that actually does work, and perfectly consistent with itself the whole time. The Wi-Fi card took a nap, the datastore cancelled its own queries, the cache stopped listening, and every controller in the building sat down to wait for an update that was never coming. Nobody errored. Nobody complained. Fifty-nine containers kept running on autopilot with no one at the controls, which is either reassuring or terrifying depending on how recently you've slept.&lt;/p&gt;

&lt;p&gt;Popcorn 🍿, coffee ☕, UPS 🔌, laminated card 🪪, chart-source bookmark 🔖, a domain 🌐, a DHCP reservation 📌, and now a second laminated card that just says &lt;em&gt;compare the renewTimes&lt;/em&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>microk8s</category>
      <category>sre</category>
    </item>
    <item>
      <title>❓ HomeLab Chronicles: Episode 10 - new router who dis</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Wed, 28 Oct 2026 08:49:28 +0000</pubDate>
      <link>https://dev.to/snskarora/episode-10-new-router-who-dis-3en9</link>
      <guid>https://dev.to/snskarora/episode-10-new-router-who-dis-3en9</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Last episode ended with a victory lap: a two-node, git-defined, nightly-backed-up, tunnel-fronted platform, and a sentence I stand by: &lt;em&gt;the router remains the weakest member of the team and has been architected around accordingly.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Reader, I changed the router.&lt;/p&gt;

&lt;p&gt;The old one had been established, across several episodes, as this saga's least reliable character. The new one arrived, got plugged in, and did what every new router does on its first day: it handed out addresses from a different subnet. My whole network moved from &lt;code&gt;192.168.68.x&lt;/code&gt; to &lt;code&gt;192.168.0.x&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Machine&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;msi (control plane)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.68.210&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.0.201&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dell (worker)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.68.111&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.0.202&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A note on format: this is my first RCA-style post on this blog. Real RCAs have an impact statement, a clean timeline, and one tidy root cause, written by someone calm. Mine has an impact statement ("kubectl said EOF"), a timeline ("evening, then more evening"), and a root cause that kept growing a plus-one every time I fixed the previous one. I am calling it an RCA anyway, because "Seven Root Causes and a Port Number" did not fit in the title bar.&lt;/p&gt;

&lt;p&gt;Here is what I learned about my own architecture. It had been carefully built around the router's &lt;em&gt;flaws&lt;/em&gt;. It had never once been built around the router's &lt;em&gt;address range&lt;/em&gt;. Episode 3 me even wrote this down, in the lessons list, in plain text: reserve the address at the router level, not just on the box. Episode 3 me was talking to a wall, and the wall was me.&lt;/p&gt;

&lt;p&gt;By the end of the night I had found seven places where my cluster had the old address memorized, and none of them had been told about the move. Here they are, in the order I found them.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚨 Symptom One: &lt;code&gt;kubectl&lt;/code&gt; Says "Unexpected EOF"
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get nodes -o wide
Unable to connect to the server: unexpected EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first theory was certificates, because it is everyone's first theory. I added the new IP to &lt;code&gt;csr.conf&lt;/code&gt; and &lt;code&gt;csr.conf.template&lt;/code&gt; on msi, ran &lt;code&gt;microk8s config&lt;/code&gt;, and pasted the output over my workstation's kubeconfig. Same error, delivered with the same calm.&lt;/p&gt;

&lt;p&gt;That should have told me something. A certificate mismatch is not shy: it says &lt;code&gt;x509: certificate is valid for X, not Y&lt;/code&gt;, out loud, in a full sentence. An EOF is the sound of a door closing. So I started ruling things out, from the outside in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The kubeconfig &lt;code&gt;server:&lt;/code&gt; line already pointed at &lt;code&gt;https://192.168.0.201:16443&lt;/code&gt;. No &lt;code&gt;KUBECONFIG&lt;/code&gt; override, no proxy variables.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ip -4 addr&lt;/code&gt; on msi showed &lt;code&gt;192.168.0.201&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nc -vz 192.168.0.201 16443&lt;/code&gt;: open. &lt;code&gt;kubelite&lt;/code&gt; was listening on it.&lt;/li&gt;
&lt;li&gt;And the one that confused me most:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -vk https://192.168.0.201:16443/version
... TLS handshake completes ...
&amp;lt; HTTP/2 401
{ "kind": "Status", "reason": "Unauthorized", "code": 401 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clean TLS handshake and a polite 401. The API server was awake, reachable, and well-mannered. This is Episode 7's trap with the sign reversed: a healthy-looking API server proves the doorman is on shift, not that anyone is home.&lt;/p&gt;

&lt;p&gt;The decisive test was boring. I ran the same command on msi itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s kubectl get nodes
^C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It hung. A command that hangs identically on the machine itself has stopped being a networking problem and become a cluster problem. So: logs. &lt;code&gt;kubelite&lt;/code&gt; first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;awaiting required bookmark event for initial events stream, no events received for 30s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API server was waiting for events from its datastore, and the datastore was not sending any. So the datastore's own logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[go-dqlite] attempt 10386: server 192.168.68.210:19001: dial: dial tcp 192.168.68.210:19001: i/o timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attempt ten thousand, three hundred and eighty-six. dqlite was trying to reach &lt;em&gt;itself&lt;/em&gt; at the old address, patiently, thousands of times. Both of its state files agreed with the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /var/snap/microk8s/current/var/kubernetes/backend/cluster.yaml
- ID: 3297041220608546238
  Address: 192.168.68.210:19001
  Role: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;info.yaml&lt;/code&gt; said the same thing. I should have seen this coming, because Episode 5 explains exactly why: the moment the Dell joined, dqlite stopped listening on loopback and bound itself to the LAN address so the other node could reach it. A single-node MicroK8s is structurally immune to network changes. Mine lost its immunity the day it made a friend. With its only member unreachable, dqlite couldn't elect a leader, and an API server without a datastore is a very confident receptionist for an empty building.&lt;/p&gt;

&lt;p&gt;The fix is the single-node version of the documented dqlite recovery: stop MicroK8s, back up the backend directory (the one step here that can't be redone), swap the address in the state files, reconfigure dqlite with the bundled tool, start.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s stop
&lt;span class="nb"&gt;sudo tar&lt;/span&gt; &lt;span class="nt"&gt;-cvf&lt;/span&gt; ~/microk8s-backend-backup.tar /var/snap/microk8s/current/var/kubernetes/backend

&lt;span class="nb"&gt;cd&lt;/span&gt; /var/snap/microk8s/current/var/kubernetes/backend
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s/192.168.68.210/192.168.0.201/g"&lt;/span&gt; info.yaml cluster.yaml
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; localnode.yaml &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"s/192.168.68.210/192.168.0.201/g"&lt;/span&gt; localnode.yaml

&lt;span class="nb"&gt;sudo&lt;/span&gt; /snap/microk8s/current/bin/dqlite &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-s&lt;/span&gt; 127.0.0.1:19001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; /var/snap/microk8s/current/var/kubernetes/backend/cluster.crt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-k&lt;/span&gt; /var/snap/microk8s/current/var/kubernetes/backend/cluster.key &lt;span class="se"&gt;\&lt;/span&gt;
  k8s &lt;span class="s2"&gt;".reconfigure /var/snap/microk8s/current/var/kubernetes/backend/ /var/snap/microk8s/current/var/kubernetes/backend/cluster.yaml"&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s start
&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;msi: &lt;code&gt;Ready&lt;/code&gt;. Nobody clapped, because the Dell was still very quiet.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧟 Symptom Two: The Dell Stops Answering the Phone
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME   STATUS     ROLES    AGE   VERSION
dell   NotReady   &amp;lt;none&amp;gt;   59d   v1.35.6
msi    Ready      &amp;lt;none&amp;gt;   59d   v1.35.6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Dell is a worker, which means it isn't part of dqlite at all. It talks to the control plane through a local &lt;code&gt;apiserver-proxy&lt;/code&gt; service, and the list of control plane endpoints that proxy forwards to lives in &lt;code&gt;args/traefik/provider.yaml&lt;/code&gt;. Mine had exactly one entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kube-apiserver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;loadBalancer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.68.210:16443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One endpoint, and it was the old address of msi. The proxy normally refreshes that list from the cluster, but it can't ask the cluster anything when its only way of asking has moved house. A worker keeps its own copy of where the control plane lives, and nobody tells it when that changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/192\.168\.68\.210/192.168.0.201/g'&lt;/span&gt; /var/snap/microk8s/current/args/traefik/provider.yaml
&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s stop
&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While grepping the Dell's config for the old subnet I found one more tenant: &lt;code&gt;can-reach=192.168.68.210&lt;/code&gt; in &lt;code&gt;args/cni-network/cni.yaml&lt;/code&gt;. That is Calico's hint for which network interface to use for pod traffic, pointed at an address that no longer exists. The live value sits on the &lt;code&gt;calico-node&lt;/code&gt; DaemonSet, so that is the place to check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get ds calico-node &lt;span class="nt"&gt;-o&lt;/span&gt; yaml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-B1&lt;/span&gt; can-reach
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🔐 Sidequest 1: The Pod That Remembered the Old Address
&lt;/h2&gt;

&lt;p&gt;The Airflow API server pod, which gets scheduled on the Dell, hit the same species of problem at startup: a certificate that only knew the old addresses. The fix was the usual ritual. Add the new IPs to the CSR template on the Dell, regenerate the certificates from msi, and the pod came up healthy.&lt;/p&gt;

&lt;p&gt;Notice how many fixes in this episode are "find the file where the old address lives and tell it about the move." That is the episode.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪪 Sidequest 2: &lt;code&gt;kubectl logs&lt;/code&gt; and the Other Certificate
&lt;/h2&gt;

&lt;p&gt;With the nodes healthy, I tried to read the logs of a &lt;code&gt;cloudflared&lt;/code&gt; pod running on the Dell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error from server: Get "https://192.168.0.202:10250/containerLogs/cloudflared/cloudflared-.../cloudflared":
tls: failed to verify certificate: x509: certificate is valid for 192.168.68.111, not 192.168.0.202
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not about &lt;code&gt;cloudflared&lt;/code&gt; at all. &lt;code&gt;kubectl logs&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt; and &lt;code&gt;port-forward&lt;/code&gt; take a different road from every other command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubectl -&amp;gt; API server (msi) -&amp;gt; kubelet on dell :10250 -&amp;gt; container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API server connects to the kubelet's HTTPS port on the node, and the address it uses must appear in the kubelet's own serving certificate. Every node has one, and it is a different certificate from the API server's, which I had already fixed and was feeling smug about. The Dell's had been issued when it joined the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subject=CN=system:node:dell, O=system:nodes
issuer=CN=10.152.183.1
X509v3 Subject Alternative Name:
    DNS:dell, IP Address:192.168.68.111
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of re-joining the node, I asked the kubelet to request a fresh serving certificate from the cluster CA, which MicroK8s doesn't turn on by default. On the Dell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--rotate-server-certificates=true"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /var/snap/microk8s/current/args/kubelet
&lt;span class="nb"&gt;sudo &lt;/span&gt;snap stop microk8s &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;snap start microk8s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kubelet filed a certificate signing request. Serving CSRs are not auto-approved, which is a sensible default and a mild inconvenience:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get csr
NAME        AGE   SIGNERNAME                      REQUESTOR          CONDITION
csr-bgrrf   19s   kubernetes.io/kubelet-serving   system:node:dell   Pending

$ kubectl certificate approve csr-bgrrf
certificatesigningrequest.certificates.k8s.io/csr-bgrrf approved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Approve only a CSR you expect, from the node you expect.) Then I asked the kubelet what it was actually serving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ echo | openssl s_client -connect 192.168.0.202:10250 2&amp;gt;/dev/null | openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
    DNS:dell, IP Address:192.168.0.202
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old &lt;code&gt;kubelet.crt&lt;/code&gt; on disk still lists the old IP, but the kubelet no longer serves it, so I left the file alone like a souvenir. &lt;code&gt;kubectl logs&lt;/code&gt; worked again.&lt;/p&gt;

&lt;p&gt;The fine print: with rotation on, the next IP change will produce a new CSR, and &lt;code&gt;logs&lt;/code&gt; and &lt;code&gt;exec&lt;/code&gt; will stay broken until I approve it. A manual step reintroduced, written on the checklist, where future-me will find it and sigh.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚇 Sidequest 3: The Tunnel Route
&lt;/h2&gt;

&lt;p&gt;Episode 9 had some fine print, and I am quoting my past self: remotely managed means the hostname routing lives in Cloudflare's dashboard, not in my repo, which makes it the one thing a cluster rebuild can't restore. This episode, that fine print sent me an invoice. The route in the dashboard still pointed at an old node address.&lt;/p&gt;

&lt;p&gt;The tunnel itself was fine. The &lt;code&gt;cloudflared&lt;/code&gt; pods run inside the cluster, so they didn't care about the router, and the logs showed four registered connections. The only broken thing was where the tunnel was sending traffic.&lt;/p&gt;

&lt;p&gt;The robust fix is to stop sending it to a number at all. &lt;code&gt;cloudflared&lt;/code&gt; runs in the cluster, which means it can resolve in-cluster Service names, and every Service gets &lt;code&gt;&amp;lt;service&amp;gt;.&amp;lt;namespace&amp;gt;.svc.cluster.local&lt;/code&gt;. So I pointed the route at the Airflow API server Service by name, and kept the port from the old route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://airflow-api-server.airflow.svc.cluster.local:30080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tunnel logs objected immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERR error="Unable to reach the origin service ... dial tcp 10.152.183.41:30080: i/o timeout"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that address again. &lt;code&gt;10.152.183.41&lt;/code&gt; is the Service's ClusterIP, so the name resolved fine and DNS was innocent. The port was the problem. &lt;code&gt;30080&lt;/code&gt; is the NodePort I pinned for the Envoy gateway back in Episode 4, and a NodePort exists on the nodes' own IPs, nowhere else. The ClusterIP listens on the Service's regular port, which for the Airflow API server is &lt;code&gt;8080&lt;/code&gt;. I had combined a perfectly good name with a port that only exists on a different kind of address, which is like mailing a letter to the right house with the apartment number from the old one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You connect to&lt;/th&gt;
&lt;th&gt;Use this port&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A node IP, like &lt;code&gt;192.168.0.201&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The NodePort (&lt;code&gt;30080&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The ClusterIP or the Service DNS name&lt;/td&gt;
&lt;td&gt;The Service port (&lt;code&gt;8080&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I changed &lt;code&gt;30080&lt;/code&gt; to &lt;code&gt;8080&lt;/code&gt; in the dashboard. Instant. No restart. The tunnel now goes straight to the Airflow API server Service, which also means the Envoy gateway from Episode 4 is no longer in the tunnel's path. And because the route is a name, the next router can do whatever it likes.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗺️ The Census: Where My Cluster Kept the Old Address
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Where it lived&lt;/th&gt;
&lt;th&gt;What it held&lt;/th&gt;
&lt;th&gt;What broke&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;dqlite &lt;code&gt;cluster.yaml&lt;/code&gt; and &lt;code&gt;info.yaml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the datastore's own address&lt;/td&gt;
&lt;td&gt;API server hung, &lt;code&gt;kubectl&lt;/code&gt; got EOF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Dell's &lt;code&gt;args/traefik/provider.yaml&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;control plane endpoint&lt;/td&gt;
&lt;td&gt;worker went &lt;code&gt;NotReady&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Calico &lt;code&gt;can-reach&lt;/code&gt; (&lt;code&gt;cni.yaml&lt;/code&gt;, &lt;code&gt;calico-node&lt;/code&gt; DaemonSet)&lt;/td&gt;
&lt;td&gt;interface detection hint&lt;/td&gt;
&lt;td&gt;nothing I noticed, which is the scary kind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;API server and pod certificates (&lt;code&gt;csr.conf.template&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;names and IPs the cert is valid for&lt;/td&gt;
&lt;td&gt;x509 errors at pod startup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Dell's kubelet serving certificate&lt;/td&gt;
&lt;td&gt;the node's IP&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl logs&lt;/code&gt; and &lt;code&gt;exec&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Workstation kubeconfig&lt;/td&gt;
&lt;td&gt;API server address&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl&lt;/code&gt; pointed at nowhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Tunnel route in the dashboard&lt;/td&gt;
&lt;td&gt;origin address&lt;/td&gt;
&lt;td&gt;errors on the public URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A cluster's address is not one fact.&lt;/strong&gt; Mine lived in seven places, in seven dialects: datastore state files, a worker's proxy list, a CNI hint, certificates, a kubeconfig, a tunnel route. Changing the IP is a census, not an edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unexpected EOF&lt;/code&gt; is not a certificate error.&lt;/strong&gt; Certificates complain in full sentences. EOF means the door closed. Curl the endpoint, then run the same command on the node itself, to split client problems from cluster problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A polite 401 proves the API server is awake, not that the control plane is.&lt;/strong&gt; Episode 7, with the sign reversed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the API server hangs, read the datastore logs.&lt;/strong&gt; &lt;code&gt;dial ... i/o timeout&lt;/code&gt; to your own old IP is a very specific kind of confession.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers keep their own copy of where the control plane lives.&lt;/strong&gt; &lt;code&gt;provider.yaml&lt;/code&gt; does not update itself when its only entry stops existing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;logs&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt; and &lt;code&gt;port-forward&lt;/code&gt; go through the kubelet's certificate.&lt;/strong&gt; A different certificate, with its own list of addresses, fixed by its own CSR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NodePort and Service port are not interchangeable.&lt;/strong&gt; Node IP plus NodePort, or Service name plus Service port. Never one from each column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Names beat numbers, and reservations beat both.&lt;/strong&gt; Route to Service DNS, and tell the router who gets which address. Episode 3 said so, and I intend to start reading Episode 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one thing git can't restore is the thing living in the dashboard.&lt;/strong&gt; Episode 9's fine print came due right on schedule.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Command / Place&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is dqlite reaching itself?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo journalctl -u snap.microk8s.daemon-k8s-dqlite -n 50 --no-pager&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dqlite's idea of its own address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo cat /var/snap/microk8s/current/var/kubernetes/backend/cluster.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same command, from the node itself&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo microk8s kubectl get nodes&lt;/code&gt; (hangs = cluster problem, not client problem)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where a worker thinks the control plane is&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/var/snap/microk8s/current/args/traefik/provider.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calico's interface hint&lt;/td&gt;
&lt;td&gt;`kubectl -n kube-system get ds calico-node -o yaml \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pending kubelet cert requests&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;kubectl get csr&lt;/code&gt;, then &lt;code&gt;kubectl certificate approve &amp;lt;name&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What the kubelet is actually serving&lt;/td&gt;
&lt;td&gt;`echo \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service port vs NodePort&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;kubectl -n airflow get svc&lt;/code&gt; (a ClusterIP service shows only &lt;code&gt;&amp;lt;port&amp;gt;/TCP&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tunnel healthy?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl -n cloudflared logs -l app=cloudflared&lt;/code&gt; -&amp;gt; &lt;code&gt;Registered tunnel connection&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;The permanent fix for all of this is one checkbox on the new router: a DHCP reservation per laptop, so the addresses stop being a suggestion. That is the next ten minutes of my life, and I am doing it before anything interesting. After that, the third-voter-shaped hole in this cluster's HA story is still there, and the Pi is still in its drawer. The drawer has heard promises before.&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;New router, same cluster. Seven places remembered the old address, one dashboard had to be told in person, and one port number turned out to belong to a different kind of address than I thought. The router remains the weakest member of the team. It now has a newer face and a different subnet, and I have stopped being surprised.&lt;/p&gt;

&lt;p&gt;Popcorn 🍿, coffee ☕, UPS 🔌, laminated card 🪪, chart-source bookmark 🔖, a domain 🌐, and now a DHCP reservation 📌.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>cloudflare</category>
      <category>dqlite</category>
    </item>
    <item>
      <title>🧭 Neovim Thinks in One Directory</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Wed, 28 Oct 2026 03:37:32 +0000</pubDate>
      <link>https://dev.to/snskarora/neovim-thinks-in-one-directory-dfi</link>
      <guid>https://dev.to/snskarora/neovim-thinks-in-one-directory-dfi</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Here is an assumption so deep in Neovim that you stop seeing it: &lt;strong&gt;there is one project, and it is wherever &lt;code&gt;:pwd&lt;/code&gt; points.&lt;/strong&gt; Telescope greps the cwd. nvim-tree roots itself at the cwd. Neogit opens the repo that contains the cwd. git-worktree.nvim &lt;em&gt;is&lt;/em&gt; a cwd changer with a picker on top. Harpoon keys its marks by cwd. Every one of these plugins is excellent, and every one of them believes the same small lie.&lt;/p&gt;

&lt;p&gt;My work does not look like that. On a normal day it is a service, the shared library it imports, and the infra repo that deploys it - three repositories, one problem. Or it is three git worktrees of the same repo, side by side, because a review and a hotfix and a feature branch all want to be open at once. VS Code has had an answer since forever: the &lt;em&gt;multi-root workspace&lt;/em&gt;, a &lt;code&gt;.code-workspace&lt;/code&gt; file with N folders in it, and every search, tree and git view spanning all of them.&lt;/p&gt;

&lt;p&gt;Neovim's project plugins mostly answer a different question - "switch me between projects" - which is one root at a time with better bookkeeping. I wanted the VS Code shape without giving up any of the plugins above, and without a plugin that replaces them. That is what &lt;strong&gt;workspaces.nvim&lt;/strong&gt; is: a name, an ordered list of folders, a session, and a set of careful conversations with plugins that only understand one directory.&lt;/p&gt;

&lt;p&gt;This post is the design: the decisions, why they went that way, and the three or four places where the mechanism was more interesting than the feature. Code is at the end.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧨 Why "just cd somewhere" does not work
&lt;/h2&gt;

&lt;p&gt;The naive answer to multi-root is &lt;code&gt;:cd&lt;/code&gt; to the common parent and let everything fall out. Three things go wrong at once, and they have different fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The common parent is usually garbage.&lt;/strong&gt; &lt;code&gt;~/github/service&lt;/code&gt;, &lt;code&gt;~/github/shared-lib&lt;/code&gt; and &lt;code&gt;~/infra/deploy&lt;/code&gt; have &lt;code&gt;~&lt;/code&gt; in common. A tree rooted at your home directory and a grep across it are not a workspace, they are a punishment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The plugins disagree about what "where" means.&lt;/strong&gt; Telescope will happily take a list of directories (&lt;code&gt;search_dirs&lt;/code&gt;). Neogit takes a &lt;code&gt;cwd&lt;/code&gt; option per call. nvim-tree takes exactly one root and will &lt;code&gt;:lcd&lt;/code&gt; its own window to get there. git-worktree resolves the repository from the process cwd, synchronously, at the top of every function. There is no single knob that moves all four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Sessions and folders are different things.&lt;/strong&gt; &lt;code&gt;:mksession&lt;/code&gt; remembers windows, buffers, tabs and the cwd. It knows nothing about "these three folders belong together", and if you let it be the only persistence you get a session that reopens your buffers in the wrong place with no memory of why.&lt;/p&gt;

&lt;p&gt;So the design has to carry its own notion of folders, hand it to each plugin in the dialect that plugin speaks, and keep its hands off anything that would make a &lt;em&gt;small&lt;/em&gt; workspace worse than no plugin at all.&lt;/p&gt;

&lt;p&gt;That last clause became the rule everything else answers to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The fallback rule.&lt;/strong&gt; With zero folders, every feature behaves exactly like stock Neovim. An empty workspace is never worse than not having the plugin.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It sounds like a nicety. It is actually the thing that made the design converge, because every integration got the same first question: &lt;em&gt;what do you do when there is nothing to do?&lt;/em&gt; The answer is always "what you did before".&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 Decision 1: what &lt;em&gt;is&lt;/em&gt; a workspace?
&lt;/h2&gt;

&lt;p&gt;A workspace is three things, stored as two files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A name&lt;/td&gt;
&lt;td&gt;the filenames&lt;/td&gt;
&lt;td&gt;validated: 1-200 chars, no &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;\&lt;/code&gt;, no control characters, no leading &lt;code&gt;.&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An ordered folder list&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;name&amp;gt;.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;plain data, read with &lt;code&gt;vim.json.decode&lt;/code&gt;, never executed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A session&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;name&amp;gt;.vim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the unmodified output of &lt;code&gt;:mksession&lt;/code&gt;, restored with &lt;code&gt;:source&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two choices in that table are worth defending.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reuse &lt;code&gt;:mksession&lt;/code&gt; instead of inventing a session format.&lt;/strong&gt; Neovim's own session machinery already handles windows, splits, tabs, folds, the argument list and cwd, and it is maintained by people who are not me. A plugin-specific layout format would be a worse version of it within a month. The cost is that a session file is Vimscript that gets &lt;em&gt;executed&lt;/em&gt; on load, which has a security consequence I will come back to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The folder list is data, not code.&lt;/strong&gt; An early version stored the folders as a Lua file and loaded it with &lt;code&gt;dofile&lt;/code&gt;. That is one line and it works, and it also means anyone who can write to your session directory can run arbitrary Lua in your editor the next time you open a workspace. The current version writes JSON. Old &lt;code&gt;.vars.lua&lt;/code&gt; files are migrated on first read through &lt;code&gt;load(src, "=workspace-folders", "t", {})&lt;/code&gt; - text-only chunk, &lt;strong&gt;empty environment&lt;/strong&gt;, so a hostile file cannot reach &lt;code&gt;os&lt;/code&gt;, &lt;code&gt;io&lt;/code&gt; or &lt;code&gt;vim&lt;/code&gt;. The test for this was a legacy file containing &lt;code&gt;os.execute("touch PWNED")&lt;/code&gt;. Nothing ran; the folder list came back empty; the file was converted and removed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLD lesson worth generalising:&lt;/strong&gt; if a file is persistence, it is data. The moment you load persistence with a code loader because it is convenient, you have an execution path you did not design, and you will not remember it is there.&lt;/p&gt;

&lt;p&gt;Storage is one directory, &lt;code&gt;stdpath("data")/workspaces/&lt;/code&gt;, created &lt;code&gt;0700&lt;/code&gt;. Switching is: save the current workspace (&lt;code&gt;:wall&lt;/code&gt;, then &lt;code&gt;mksession!&lt;/code&gt;), wipe every buffer, &lt;code&gt;:source&lt;/code&gt; the target session, read its folder list, then tell every integration the folders changed. Every one of those steps is under &lt;code&gt;pcall&lt;/code&gt; with a message, because the failure modes are mundane - a read-only directory, a session whose saved cwd was deleted last week - and none of them should abort a switch halfway. &lt;code&gt;:source&lt;/code&gt; runs with &lt;code&gt;silent!&lt;/code&gt; for the same reason: one dead &lt;code&gt;cd&lt;/code&gt; line inside a session should not stop the twenty buffer lines after it from loading.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔍 Decision 2: search is the easy part, so make it nice
&lt;/h2&gt;

&lt;p&gt;Telescope already speaks multi-root; it just calls it &lt;code&gt;search_dirs&lt;/code&gt;. &lt;code&gt;find_files&lt;/code&gt;, &lt;code&gt;live_grep&lt;/code&gt;, &lt;code&gt;grep_string&lt;/code&gt; and &lt;code&gt;oldfiles&lt;/code&gt; all get the workspace folder list, or nothing when the list is empty (the fallback rule, applied for the first time).&lt;/p&gt;

&lt;p&gt;What was worth building on top is a ripgrep prompt with a small grammar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TODO                              every file in every folder
TODO |:| *.lua                    only Lua files
TODO |:| *.lua, !**/tests/**      Lua files, but not tests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything after &lt;code&gt;|:|&lt;/code&gt; becomes &lt;code&gt;-g&lt;/code&gt; globs. The command that runs is &lt;code&gt;rg -e &amp;lt;pattern&amp;gt; -g &amp;lt;glob&amp;gt;... --color=never --no-heading --with-filename --line-number --column --smart-case -- &amp;lt;folders&amp;gt;&lt;/code&gt;. The sorter is empty on purpose: ripgrep already did the filtering, so Telescope sorting the results again is CPU spent on nothing.&lt;/p&gt;

&lt;p&gt;Then there is the picky version. &lt;code&gt;&amp;lt;leader&amp;gt;fs&lt;/code&gt; greps every folder. &lt;code&gt;&amp;lt;leader&amp;gt;fns&lt;/code&gt; first asks &lt;em&gt;where&lt;/em&gt;: a picker listing every workspace folder &lt;strong&gt;and every subdirectory under them&lt;/strong&gt;, multi-select with &lt;code&gt;&amp;lt;Tab&amp;gt;&lt;/code&gt;, then the same grep over exactly the marked paths. Three decisions make it fast enough to open without thinking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scan once, stream in.&lt;/strong&gt; &lt;code&gt;fd --type d --hidden --no-ignore . &amp;lt;folders&amp;gt;&lt;/code&gt; runs as an async job and entries appear as they arrive. The picker is usable before the scan finishes, and the process is killed when the picker closes. On a six-core laptop, 1,060 directories under &lt;code&gt;~/github&lt;/code&gt; take 26 ms; 45 plugin repos with 1,287 directories take 30 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignore &lt;code&gt;.gitignore&lt;/code&gt;, keep the exclude list.&lt;/strong&gt; &lt;code&gt;--no-ignore&lt;/code&gt; is deliberate, so a build directory or a venv can be grepped on purpose. &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;target&lt;/code&gt; and &lt;code&gt;dist&lt;/code&gt; are still skipped, with an in-picker toggle to bring them back that keeps your marks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remember the last selection in memory only.&lt;/strong&gt; The marked paths are pre-marked next time, survive a workspace switch, and are never written to disk. Between uses, the whole feature holds one short list of strings and one boolean.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a trap in the scanning that is worth its own sentence. &lt;code&gt;fd --max-results&lt;/code&gt; is the obvious way to cap a scan on a slow machine. It does not exist on fd 7.4, which is what Ubuntu 20.04 ships, and an unknown flag makes fd exit with an error and an empty picker. The cap moved into the finder: count entries as they stream, close the job at N. Works for &lt;code&gt;fd&lt;/code&gt;, works for the &lt;code&gt;find&lt;/code&gt; fallback, works on the old box.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌳 Decision 3: nvim-tree has one root, and that is not negotiable
&lt;/h2&gt;

&lt;p&gt;This is the compromise at the heart of the plugin, and I would rather say so than pretend. nvim-tree shows exactly one root. VS Code's explorer shows N. There is no setting to change that, and forking nvim-tree was never on the table.&lt;/p&gt;

&lt;p&gt;The workaround has two halves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root the tree at the deepest common ancestor of the folders.&lt;/strong&gt; For &lt;code&gt;~/github/service&lt;/code&gt; and &lt;code&gt;~/github/shared-lib&lt;/code&gt;, that is &lt;code&gt;~/github&lt;/code&gt;. For folders that share nothing, it is &lt;code&gt;/&lt;/code&gt;. Yes, really &lt;code&gt;/&lt;/code&gt; - the alternative was special-casing, and a tree rooted at &lt;code&gt;/&lt;/code&gt; with the filter below applied is actually fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filter everything that is not on the way to a folder.&lt;/strong&gt; nvim-tree reads a &lt;code&gt;filters.custom&lt;/code&gt; function from its own &lt;code&gt;setup()&lt;/code&gt;, and the plugin exports one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="n"&gt;filters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;custom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;pcall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;require&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"workspaces"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nvim_tree_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With two or more folders, a path is shown only if it &lt;em&gt;is&lt;/em&gt; a workspace folder, is &lt;em&gt;inside&lt;/em&gt; one, or is an &lt;em&gt;ancestor&lt;/em&gt; of one. Everything else is hidden. The result is surprisingly close to VS Code: your folders, nested under whatever they share, siblings gone.&lt;/p&gt;

&lt;p&gt;Then two details that took longer than the feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tree must never move your cwd.&lt;/strong&gt; nvim-tree's &lt;code&gt;change_root&lt;/code&gt; runs &lt;code&gt;:lcd&lt;/code&gt; on its own window by default (&lt;code&gt;actions.change_dir&lt;/code&gt;), so after re-rooting, the tree window has a &lt;em&gt;different&lt;/em&gt; working directory from everything else. Harmless until you press a key in the tree that opens a picker, and the picker scans somewhere you did not expect - and until &lt;code&gt;:mksession&lt;/code&gt; saves that &lt;code&gt;:lcd&lt;/code&gt; into the session. The fix is plain Vim: right after the plugin's own &lt;code&gt;change_root&lt;/code&gt;, every tree window with a local directory gets &lt;code&gt;noautocmd cd &amp;lt;global cwd&amp;gt;&lt;/code&gt;, and &lt;code&gt;:cd&lt;/code&gt; inside a window clears its window-local directory. No nvim-tree internals touched, and manual navigation in the tree behaves exactly as before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tree must never be in the session.&lt;/strong&gt; &lt;code&gt;:mksession&lt;/code&gt; with the tree open records a dead &lt;code&gt;NvimTree_N&lt;/code&gt; buffer, which comes back on load as an empty window with a filetype and no explorer behind it. So every save closes the tree first, runs &lt;code&gt;mksession!&lt;/code&gt;, then reopens it and puts focus back where it was. Switching closes it once and opens it once; a first version reopened, wiped, and reopened again, which was three flickers for the price of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLD lesson worth generalising:&lt;/strong&gt; when you integrate with a plugin, enumerate the side effects it has on &lt;em&gt;shared&lt;/em&gt; state - cwd, buffers, focus - not just its API. The API was fine. The &lt;code&gt;:lcd&lt;/code&gt; and the stray buffer were the actual work.&lt;/p&gt;




&lt;h2&gt;
  
  
  📍 Decision 4: who owns the cwd?
&lt;/h2&gt;

&lt;p&gt;The first real-use surprise: switch workspace, run &lt;code&gt;:!pwd&lt;/code&gt;, same directory as before. Nothing in the plugin had ever &lt;code&gt;:cd&lt;/code&gt;'d. The only cwd change on a switch came from &lt;code&gt;:source&lt;/code&gt; restoring whatever cwd the session was saved with, and every session had been saved from the same launch directory.&lt;/p&gt;

&lt;p&gt;The rule that replaced the accident: &lt;strong&gt;the cwd follows the common root of the folders&lt;/strong&gt;, on every switch and on every folder add or remove. One folder, the cwd is that folder. Two related folders, their shared parent. Folders that share nothing, &lt;code&gt;/&lt;/code&gt; - and it does &lt;code&gt;cd /&lt;/code&gt; anyway, because the searches use the folder list and are unaffected, and because a rule with an exception is two rules. Zero folders, the cwd is left alone (fallback rule again).&lt;/p&gt;

&lt;p&gt;It is a plain &lt;code&gt;:cd&lt;/code&gt;, so &lt;code&gt;DirChanged&lt;/code&gt; fires and every plugin that keys on it gets the news. The add-folder picker now starts at the common root. Sessions save the common root. The tree's &lt;code&gt;:lcd&lt;/code&gt; is still undone. It is one option (&lt;code&gt;cwd_follows_root&lt;/code&gt;) and it is on by default, because a workspace whose &lt;code&gt;:pwd&lt;/code&gt; lies to you is not finished.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔀 Decision 5: plugins that &lt;em&gt;take&lt;/em&gt; a cwd versus plugins that &lt;em&gt;are&lt;/em&gt; the cwd
&lt;/h2&gt;

&lt;p&gt;This is the distinction I would hand anyone integrating with the Neovim git ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neogit takes a &lt;code&gt;cwd&lt;/code&gt; option.&lt;/strong&gt; That makes routing a pure function of the current buffer. If you are in a real file (&lt;code&gt;buftype == ""&lt;/code&gt;, named, readable) inside a workspace folder, Neogit opens in that file's repository, found with &lt;code&gt;vim.fs.root(file, ".git")&lt;/code&gt; - stat calls, no git process, no lag. From anything else - the tree, a Telescope prompt, a &lt;code&gt;[No Name]&lt;/code&gt; buffer, a file outside the workspace - you get a &lt;strong&gt;repo picker&lt;/strong&gt;, always, even with one repo, because guessing wrong about which repo you meant is worse than one extra keypress.&lt;/p&gt;

&lt;p&gt;The repo picker itself earned some care because everything uses it. Entries look like &lt;code&gt;work/app/ (main, 3 changed)&lt;/code&gt;. Discovery is pure Lua (&lt;code&gt;vim.fs.root&lt;/code&gt; per folder, deduplicated, so two folders in one repo collapse to one entry and linked worktrees count separately). The picker opens instantly with &lt;code&gt;(…)&lt;/code&gt; placeholders and fills in as one async &lt;code&gt;git status --porcelain=v1 -b --untracked-files=no&lt;/code&gt; per repo returns; the jobs are killed if you close it early. Tracked changes only, by choice - untracked counts are noise on any repo with a build directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;git-worktree.nvim &lt;em&gt;is&lt;/em&gt; the cwd.&lt;/strong&gt; Its model is: create a worktree, &lt;code&gt;cd&lt;/code&gt; into it, swap your buffers. Resolving the repository happens synchronously from the process cwd at the top of each function. In a multi-root workspace that model is simply wrong: a worktree is a &lt;em&gt;folder&lt;/em&gt;, and the thing you want is to add it.&lt;/p&gt;

&lt;p&gt;So, inside a workspace, three functions on the &lt;code&gt;git-worktree&lt;/code&gt; module table are wrapped - &lt;code&gt;create_worktree&lt;/code&gt;, &lt;code&gt;switch_worktree&lt;/code&gt;, &lt;code&gt;delete_worktree&lt;/code&gt;. Its Telescope extension calls them through the module table, so no fork is needed. The wrappers change the meaning of each verb:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;git-worktree verb&lt;/th&gt;
&lt;th&gt;Upstream&lt;/th&gt;
&lt;th&gt;Inside a workspace&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;create&lt;/td&gt;
&lt;td&gt;create, then &lt;code&gt;cd&lt;/code&gt; into it&lt;/td&gt;
&lt;td&gt;create, then &lt;strong&gt;add it as a folder&lt;/strong&gt;; no &lt;code&gt;cd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;switch (picker &lt;code&gt;&amp;lt;CR&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cd&lt;/code&gt; + buffer swap&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;add it as a folder&lt;/strong&gt; (warn if already present)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;delete (picker &lt;code&gt;&amp;lt;C-d&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git worktree remove&lt;/code&gt; in cwd&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git -C &amp;lt;repo&amp;gt; worktree remove [--force] -- &amp;lt;path&amp;gt;&lt;/code&gt;, then &lt;strong&gt;drop the folder&lt;/strong&gt; (and any folder nested inside it)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one wrinkle: because git-worktree reads the repo from the cwd &lt;em&gt;synchronously at the start&lt;/em&gt;, and every job it spawns afterwards carries an explicit cwd, a momentary &lt;code&gt;noautocmd cd &amp;lt;repo&amp;gt;&lt;/code&gt; → call → &lt;code&gt;cd&lt;/code&gt; back is enough and is invisible to everything else. A small context object &lt;code&gt;{ kind, repo }&lt;/code&gt; is set when the plugin's own worktree picker opens, consumed by the first wrapped call it applies to, and reset on every worktree key press, so a half-finished flow can never hijack a later real one. Outside a workspace none of this happens and git-worktree is byte-for-byte upstream.&lt;/p&gt;

&lt;p&gt;There is also a cross-repo changed-files picker (&lt;code&gt;&amp;lt;leader&amp;gt;gs&lt;/code&gt;): one &lt;code&gt;git status --porcelain=v1 -z&lt;/code&gt; per workspace repo, two-letter &lt;code&gt;XY&lt;/code&gt; status plus folder label plus path, &lt;code&gt;git diff HEAD -- &amp;lt;file&amp;gt;&lt;/code&gt; as the preview. The preview kills the previous diff job before starting the next, because holding &lt;code&gt;j&lt;/code&gt; in a picker should not fork thirty git processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLD lesson worth generalising:&lt;/strong&gt; sort your integrations by what they &lt;em&gt;assume&lt;/em&gt;, not by what they &lt;em&gt;do&lt;/em&gt;. "Takes a cwd" is a parameter. "Is the cwd" is a worldview, and you integrate with a worldview by wrapping the three functions where it touches reality.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔌 Everything else rides on hooks the plugins already offer
&lt;/h2&gt;

&lt;p&gt;The remaining integrations needed no cleverness, only using what was there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LSP.&lt;/strong&gt; Every running server that advertises &lt;code&gt;workspace.workspaceFolders.supported&lt;/code&gt; gets a &lt;code&gt;workspace/didChangeWorkspaceFolders&lt;/code&gt; notification with the workspace folders. Only folders the plugin added are ever removed again; a server's own root is never touched. Workspace symbols and references now span repos. It adapts to the client API change between Neovim 0.10 (&lt;code&gt;client.notify&lt;/code&gt;) and 0.11+ (&lt;code&gt;client:notify&lt;/code&gt;), because the dot form logs a deprecation on one and the colon form crashes on the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Harpoon 2.&lt;/strong&gt; Its &lt;code&gt;settings.key&lt;/code&gt; hook decides which mark list is active. &lt;code&gt;require("workspaces").harpoon_key()&lt;/code&gt; returns &lt;code&gt;workspace:&amp;lt;name&amp;gt;&lt;/code&gt; inside a workspace and the cwd otherwise, so marks become per-workspace and Harpoon's own behaviour returns the moment no workspace is active.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Statusline.&lt;/strong&gt; &lt;code&gt;statusline()&lt;/code&gt; returns &lt;code&gt;&amp;lt;name&amp;gt; (&amp;lt;folder count&amp;gt;)&lt;/code&gt; or an empty string. Drop it in lualine as a component; styling is yours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then the piece that turned "optional" from a comment into a contract. &lt;strong&gt;Every external integration has a switch&lt;/strong&gt;, on by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="n"&gt;integrations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;nvim_tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;neogit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;git_worktree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;harpoon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="n"&gt;lsp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;servers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Off means the plugin never even &lt;code&gt;require&lt;/code&gt;s that plugin: its default keymaps are not registered, calling its API warns, and the hooks your own config calls (&lt;code&gt;harpoon_key&lt;/code&gt;, &lt;code&gt;nvim_tree_filter&lt;/code&gt;) fall back to stock behaviour so your snippets can stay in place. &lt;code&gt;pcall(require, ...)&lt;/code&gt; handles "not installed". A switch handles "installed, and I do not want you touching it", which is a different thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔒 Hardening, because some of these machines are bastion hosts
&lt;/h2&gt;

&lt;p&gt;Some of the hosts this runs on are small, slow, and not entirely mine. That forced a round of reading every line with three questions: &lt;em&gt;can this run something it should not, can this crash the editor, and can this be cheaper?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execution surface.&lt;/strong&gt; Nothing in the plugin runs a shell; every external command is an argv list, with paths after &lt;code&gt;--&lt;/code&gt; where the tool supports it. The folder list is JSON (above). Workspace names are validated everywhere a name arrives, which closes the &lt;code&gt;:WorkspaceDelete ../something&lt;/code&gt; shape of path traversal. The session directory is created &lt;code&gt;0700&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the one that cannot be fixed, stated plainly: &lt;strong&gt;&lt;code&gt;&amp;lt;name&amp;gt;.vim&lt;/code&gt; is Vimscript and &lt;code&gt;:source&lt;/code&gt; executes it.&lt;/strong&gt; That is inherent to &lt;code&gt;:mksession&lt;/code&gt;. Whoever can write to your session directory can run code in your editor. The directory defaults to &lt;code&gt;stdpath("data")&lt;/code&gt;, is private by default, and should never be a synced or shared folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crash surface.&lt;/strong&gt; The things that used to throw now report: &lt;code&gt;mksession&lt;/code&gt; into a read-only directory, &lt;code&gt;:source&lt;/code&gt; of a session whose cwd is gone, &lt;code&gt;:cd&lt;/code&gt; to nowhere, &lt;code&gt;%bd!&lt;/code&gt; on a stubborn buffer. Missing Neogit, missing git-worktree, missing its Telescope extension, missing &lt;code&gt;rg&lt;/code&gt;, missing Telescope itself all warn instead of erroring. The startup picker only opens on a &lt;em&gt;clean interactive&lt;/em&gt; launch - a UI attached, no file arguments, no &lt;code&gt;-S&lt;/code&gt;, nothing piped in - because the first version opened Telescope inside &lt;code&gt;nvim --headless&lt;/code&gt;, which is a wonderful way to hang a scripted run on a server.&lt;/p&gt;

&lt;p&gt;Three bugs from that round were the kind that pass every test and lie to you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vim.fn.expand&lt;/code&gt; on a typed path.&lt;/strong&gt; &lt;code&gt;~&lt;/code&gt; and &lt;code&gt;$VAR&lt;/code&gt; expand, which is what you want. &lt;code&gt;%&lt;/code&gt; and &lt;code&gt;#&lt;/code&gt; &lt;em&gt;also&lt;/em&gt; expand, to the current and alternate buffer names, which is not. A directory with a &lt;code&gt;%&lt;/code&gt; in its name became a path to nowhere. &lt;code&gt;vim.fs.normalize&lt;/code&gt; does the first job without the second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;find -path "*/.*" -prune&lt;/code&gt;&lt;/strong&gt; as the fallback for machines without &lt;code&gt;fd&lt;/code&gt;. It prunes any path containing a dot-directory - including the &lt;em&gt;scan base itself&lt;/em&gt; when the cwd is under &lt;code&gt;~/.config&lt;/code&gt;. Result: an empty picker, only on machines without &lt;code&gt;fd&lt;/code&gt;, only from inside a dotfiles directory. Replaced with &lt;code&gt;-mindepth 1&lt;/code&gt; and &lt;code&gt;-name&lt;/code&gt; prunes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fd --max-results&lt;/code&gt;&lt;/strong&gt; as described above: present on the laptop, absent on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Footprint.&lt;/strong&gt; One &lt;code&gt;mkdir&lt;/code&gt; per session instead of one per storage call. &lt;code&gt;readdir&lt;/code&gt; with a filter instead of &lt;code&gt;glob&lt;/code&gt;. The &lt;code&gt;fd&lt;/code&gt;/&lt;code&gt;rg&lt;/code&gt; lookups cached after the first &lt;code&gt;PATH&lt;/code&gt; scan. Recent-files filtered by path prefix &lt;em&gt;before&lt;/em&gt; any &lt;code&gt;stat&lt;/code&gt;. One tree close and one open per switch. The previous diff job killed before the next preview. Auto-saves silent, because a notification after every folder change produces a hit-enter prompt on a 24-line terminal.&lt;/p&gt;

&lt;p&gt;None of these are clever. All of them are the difference between a plugin that is fine on a Ryzen and one that is fine on a two-core VM you reach through a jump host.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚧 Honest gaps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;nvim-tree is an approximation.&lt;/strong&gt; Common ancestor plus filter looks like multi-root. It is not. Unrelated folders live under &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switching loses unnamed buffers.&lt;/strong&gt; &lt;code&gt;:wall&lt;/code&gt; cannot save &lt;code&gt;[No Name]&lt;/code&gt;, and &lt;code&gt;%bd!&lt;/code&gt; then discards it, along with terminals and Neogit buffers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two pickers lean on Telescope internals&lt;/strong&gt; - &lt;code&gt;async_oneshot_finder&lt;/code&gt; for seeding results, and the &lt;code&gt;_multi&lt;/code&gt; option for sharing a multi-select across a refresh. A Telescope refactor could break them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deleted cwd breaks Telescope previews&lt;/strong&gt; until you &lt;code&gt;:cd&lt;/code&gt; elsewhere. That is Neovim behaviour (&lt;code&gt;vim.fs.abspath&lt;/code&gt; asserting inside the buffer previewer), not plugin code, but you will meet it if you remove a workspace's only folder from disk while it is open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The name.&lt;/strong&gt; &lt;code&gt;natecraddock/workspaces.nvim&lt;/code&gt; already answers to &lt;code&gt;require("workspaces")&lt;/code&gt;. The two cannot be installed together as things stand.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🧠 What I would tell the next person
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write the fallback rule before the first feature.&lt;/strong&gt; "Zero folders means stock Neovim" decided a dozen edge cases before they were edge cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse the host's persistence; own only your data.&lt;/strong&gt; &lt;code&gt;:mksession&lt;/code&gt; for layout, a JSON list for the thing Neovim does not know about. Inventing a session format is a month of bugs for no feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence is data. Load it as data.&lt;/strong&gt; &lt;code&gt;dofile&lt;/code&gt; on a config file is an execution path you did not design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrate with a plugin's side effects, not just its API.&lt;/strong&gt; The &lt;code&gt;:lcd&lt;/code&gt;, the stray buffer, the focus steal - that was the work. The API calls were two lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort plugins by what they assume about the world.&lt;/strong&gt; "Takes a cwd" is a parameter. "Is the cwd" means wrapping the three functions where that belief meets reality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A switch is not a &lt;code&gt;pcall&lt;/code&gt;.&lt;/strong&gt; "Not installed" and "installed, hands off" are different requirements, and a plugin that is going to live in other people's configs needs both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on the machine that has the old tools.&lt;/strong&gt; &lt;code&gt;fd 7.4&lt;/code&gt;, no &lt;code&gt;fd&lt;/code&gt; at all, a dot-directory as cwd, &lt;code&gt;nvim --headless&lt;/code&gt;. The laptop passes everything; the bastion host is where the real test suite lives.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New workspace (name popup)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;wn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:WorkspaceNew&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open / switch (picker, &lt;code&gt;&amp;lt;C-d&amp;gt;&lt;/code&gt; deletes)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;wo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:WorkspaceOpen&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete (picker, or by name)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;wD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:WorkspaceDelete [name]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Save now&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:WorkspaceSave&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add folder (picker: &lt;code&gt;&amp;lt;C-o&amp;gt;&lt;/code&gt; browse mode, &lt;code&gt;&amp;lt;C-y&amp;gt;&lt;/code&gt; typed path)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;wa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:WorkspaceAddFolder [dir]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove folders (multi-select, &lt;code&gt;&amp;lt;C-s&amp;gt;&lt;/code&gt; applies)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;wd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:WorkspaceRemoveFolder&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find files / recent / live grep / word&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;leader&amp;gt;ff&lt;/code&gt; &lt;code&gt;fr&lt;/code&gt; &lt;code&gt;feus&lt;/code&gt; &lt;code&gt;grep&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grep with `pattern \&lt;/td&gt;
&lt;td&gt;:\&lt;/td&gt;
&lt;td&gt;globs`, all folders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, but pick folders first&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;fns&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:WorkspaceGrepSelective&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neogit (file's repo, else picker)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;gt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changed files across repos&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;gs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worktrees: list → add as folder / create → add as folder&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;leader&amp;gt;gwt&lt;/code&gt; / &lt;code&gt;&amp;lt;leader&amp;gt;gwa&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Install with lazy.nvim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"snskArora/workspaces.nvim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dependencies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"nvim-telescope/telescope.nvim"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Needs Neovim 0.10+ and &lt;code&gt;rg&lt;/code&gt;; &lt;code&gt;fd&lt;/code&gt; is recommended and &lt;code&gt;find&lt;/code&gt; is the fallback. The plugin is at &lt;strong&gt;&lt;a href="https://github.com/snskArora/workspaces.nvim" rel="noopener noreferrer"&gt;snskArora/workspaces.nvim&lt;/a&gt;&lt;/strong&gt;, and the config it lives in - lualine pill, nvim-tree filter, Harpoon hook, the lot - is &lt;strong&gt;&lt;a href="https://github.com/snskArora/nvim_configs" rel="noopener noreferrer"&gt;snskArora/nvim_configs&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Neovim still thinks in one directory. It just has a plugin now that speaks to each of its friends in the dialect they understand, and keeps a list of the directories it should have been thinking about all along.&lt;/p&gt;

&lt;p&gt;One window, several repos, and &lt;code&gt;:pwd&lt;/code&gt; finally telling the truth.&lt;/p&gt;

</description>
      <category>neovim</category>
      <category>lua</category>
      <category>plugins</category>
      <category>vim</category>
    </item>
    <item>
      <title>📊 One File, Twenty-Five Images, Part 2/2 - The Numbers</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 27 Sep 2026 18:17:00 +0000</pubDate>
      <link>https://dev.to/snskarora/one-file-twenty-five-images-part-22-the-numbers-1dg5</link>
      <guid>https://dev.to/snskarora/one-file-twenty-five-images-part-22-the-numbers-1dg5</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sunny-snskarora.hashnode.dev/one-file-twenty-five-images-part-1-2-the-design" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; worked out how to move twenty-five container images across an air gap as one file: pack them into a single OCI layout so shared layers are stored once, and pick between two modes - &lt;strong&gt;pristine&lt;/strong&gt;, where layers travel exactly as the registry stores them and every digest survives, and &lt;strong&gt;repack&lt;/strong&gt;, where layers travel uncompressed and the whole bundle is compressed as one zstd stream.&lt;/p&gt;

&lt;p&gt;Nice theory. This half is the part where the theory meets a laptop: how I measured it, the several ways my measurements were wrong before they were right, what four reviewers found when they went looking for holes, and the numbers that came out - including the one that overruled a decision I had just finished defending.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔬 The process: build it small, measure it honestly
&lt;/h2&gt;

&lt;p&gt;I wrote the prototype in &lt;strong&gt;bash&lt;/strong&gt;, on purpose. The final implementation is planned to be in Go (a static binary you can drop on the far side), but the question M0 had to answer was "how big is the file, and what does it cost", not "is my Go code nice". Bash plus skopeo, zstd, jq and GNU tar reaches that answer in a day or two, and every step stays visible.&lt;/p&gt;

&lt;p&gt;One rule was meant to keep it honest: &lt;strong&gt;anything that decides a digest lives in Go&lt;/strong&gt;, in the pinned &lt;code&gt;cz1&lt;/code&gt; helper, with bash owning only the outer compression (digest-irrelevant) and the glue. I half-kept it. Layer bytes are Go; the &lt;em&gt;manifest&lt;/em&gt; rewrite is jq - and the manifest digest is the image digest, so jq is deciding identity too. That shortcut is exactly where a reviewer later found a hole, and it is why the manifest rewrite rules have to be frozen alongside the encoder. (I also learned the hard way that you cannot reproduce klauspost's output with the &lt;code&gt;zstd&lt;/code&gt; CLI. Different implementations, different bytes, same settings.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Measuring, and the several ways I got it wrong first
&lt;/h3&gt;

&lt;p&gt;The first version of my measurement rig was wrong in ways that flattered the results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU&lt;/strong&gt; was the change in the shell's &lt;code&gt;cutime&lt;/code&gt;, which swept up the tar, tee and hashing processes alongside the compressor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peak memory&lt;/strong&gt; was sampled from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; every 100 ms. On short runs that read &lt;strong&gt;24-38% low&lt;/strong&gt;, and sometimes sampled before &lt;code&gt;exec&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The report said zstd used "12 threads". &lt;code&gt;zstd -T0&lt;/code&gt; uses one worker per &lt;strong&gt;physical&lt;/strong&gt; core: 6 on this machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix was a 58-line Go helper that runs one command and reports what the kernel says via &lt;code&gt;wait4&lt;/code&gt;: exact user+sys CPU and exact &lt;code&gt;ru_maxrss&lt;/code&gt;. No sampling, no guessing.&lt;/p&gt;

&lt;p&gt;Then, because a laptop is not a lab, every run also records &lt;strong&gt;how much CPU the rest of the machine used&lt;/strong&gt; during it (system-wide busy time minus our own) and &lt;strong&gt;how many pages swapped&lt;/strong&gt;. Rows where outside load exceeded 10% get flagged in the report, and cached measurements taken under load are automatically re-timed. An idle desktop here sits at 2-5%, so the threshold had to be calibrated rather than guessed - at my first attempt (3%) every single row got flagged, which is the same as flagging none.&lt;/p&gt;

&lt;p&gt;There is also a guard that estimates zstd's peak RAM from level, window and worker count, and skips a variant the machine cannot hold. It puts a 2 GiB window at level 19 at about 4.8 GiB: roughly 3.8 GiB of estimated zstd footprint - it actually peaked at 3.90 - plus a deliberate 1 GiB margin for everything else on the machine. It still swapped 68 MiB on that run, which is why the timing carries a flag.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four reviewers and a skeptic
&lt;/h3&gt;

&lt;p&gt;Before trusting any of it, I had the prototype reviewed along four independent lenses (AI Agents 🫣) - OCI correctness, bash robustness, measurement validity, architecture fidelity - with a separate pass trying to &lt;em&gt;refute&lt;/em&gt; each finding. &lt;strong&gt;29 findings, 22 confirmed.&lt;/strong&gt; Then the same treatment for the report itself: fact-checking every number against the raw data, technical claims against the sources, and a read-through as the person receiving it. That pass produced about 40 corrections, including a percentage stated backwards and a claim that three of four bundles were round-tripped when I had written "all".&lt;/p&gt;

&lt;p&gt;The bash bugs are worth listing, because they are the kind that pass tests and lie to you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$(...)&lt;/code&gt; strips trailing newlines.&lt;/strong&gt; I hashed a manifest through a command substitution; two images in my test set have manifests ending in &lt;code&gt;\n&lt;/code&gt;; those two "landed with the wrong digest". The registry was right, my check was wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;[[ $a == $(cmd) ]]&lt;/code&gt;&lt;/strong&gt; treats the right side as a &lt;em&gt;glob pattern&lt;/em&gt;. JSON is full of &lt;code&gt;[&lt;/code&gt; and &lt;code&gt;]&lt;/code&gt;, so a comparison that looked fine silently never matched, and the code path it guarded was never exercised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;set -e&lt;/code&gt; + &lt;code&gt;pipefail&lt;/code&gt; + &lt;code&gt;[[ cond ]] &amp;amp;&amp;amp; cmd&lt;/code&gt; as the last line of a function&lt;/strong&gt; exits the whole script silently when the condition is false, because the function returns 1. Hunting that one is how I learned to add an &lt;code&gt;ERR&lt;/code&gt; trap that prints the failing command and line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A backgrounded command gets &lt;code&gt;/dev/null&lt;/code&gt; as stdin&lt;/strong&gt; unless it has its own redirect, so my measured compressor was cheerfully compressing nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FIFO deadlock:&lt;/strong&gt; a reader blocked on &lt;code&gt;open()&lt;/code&gt; waits forever if the writer never starts. Always check the producer's status &lt;em&gt;before&lt;/em&gt; waiting on consumers.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📊 Results
&lt;/h2&gt;

&lt;p&gt;The size and time tables below are from a clean-slate run: every generated artifact deleted, all 25 images re-pulled, measured on a 6-core/12-thread laptop. The window/level sweep and the layer-order result after them come from the earlier full measurement matrix - same machine, same images, a week earlier - because the clean-slate re-run only rebuilt the settings I would recommend. The dataset is 190 layer references collapsing to &lt;strong&gt;123 unique layers&lt;/strong&gt;, 7.00 GiB uncompressed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Size
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Packaging&lt;/th&gt;
&lt;th&gt;gzip -9 †&lt;/th&gt;
&lt;th&gt;zstd -6&lt;/th&gt;
&lt;th&gt;zstd -9&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One &lt;code&gt;podman save&lt;/code&gt; archive per image (today)&lt;/td&gt;
&lt;td&gt;3.43 GiB&lt;/td&gt;
&lt;td&gt;3.44 GiB&lt;/td&gt;
&lt;td&gt;3.43 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registry blobs per image, no dedup&lt;/td&gt;
&lt;td&gt;3.30 GiB&lt;/td&gt;
&lt;td&gt;3.30 GiB&lt;/td&gt;
&lt;td&gt;3.30 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicated, registry gzip layers (&lt;strong&gt;pristine&lt;/strong&gt;, before ingest)&lt;/td&gt;
&lt;td&gt;2.30 GiB&lt;/td&gt;
&lt;td&gt;2.30 GiB&lt;/td&gt;
&lt;td&gt;2.29 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicated, &lt;code&gt;cz1&lt;/code&gt; zstd layers (&lt;strong&gt;pristine&lt;/strong&gt;, after ingest) ‡&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.82 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicated, uncompressed layers, one zstd stream (&lt;strong&gt;repack&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;2.26 GiB&lt;/td&gt;
&lt;td&gt;1.93 GiB&lt;/td&gt;
&lt;td&gt;1.84 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;… same, with a 1 GiB window&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.48 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;† Every &lt;code&gt;gzip -9&lt;/code&gt; figure here was produced with &lt;code&gt;pigz -9&lt;/code&gt; - same format, deflate spread across all twelve threads, so gzip gets the same cores zstd does. Plain &lt;code&gt;gzip -9&lt;/code&gt; produces the same bytes and spends the whole CPU figure on the clock.&lt;br&gt;
‡ From the spike matrix rather than the clean-slate re-run: this is the mode where each layer is compressed individually with &lt;code&gt;cz1&lt;/code&gt;, so there is no single-stream column for it.&lt;/p&gt;

&lt;p&gt;(The zstd -9 row of the first line lands on exactly the size of my original &lt;code&gt;images.tar.zst&lt;/code&gt; - 3,681,162,191 bytes - so the baseline is anchored to a re-measurement, not remembered from a week ago.)&lt;/p&gt;

&lt;p&gt;The savings stack in four steps: &lt;strong&gt;deduplication ~30%&lt;/strong&gt;, plus &lt;strong&gt;3%&lt;/strong&gt; for not letting podman re-compress (3.43 → 2.30 GiB), then &lt;strong&gt;re-encoding each layer with &lt;code&gt;cz1&lt;/code&gt; instead of gzip&lt;/strong&gt; to reach 47% (2.30 → 1.82 GiB), then &lt;strong&gt;compressing the whole bundle as one stream with a big window&lt;/strong&gt; to 57% (1.82 → 1.48 GiB).&lt;/p&gt;

&lt;h3&gt;
  
  
  Time
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Packaging&lt;/th&gt;
&lt;th&gt;Codec&lt;/th&gt;
&lt;th&gt;Compress (wall / CPU)&lt;/th&gt;
&lt;th&gt;Decompress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One archive per image (today)&lt;/td&gt;
&lt;td&gt;gzip -9 (via &lt;code&gt;pigz&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;11.7 s / 112.9 s&lt;/td&gt;
&lt;td&gt;5.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One archive per image (today)&lt;/td&gt;
&lt;td&gt;zstd -9&lt;/td&gt;
&lt;td&gt;6.2 s / 42.1 s&lt;/td&gt;
&lt;td&gt;1.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicated, registry gzip layers (&lt;strong&gt;pristine&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;zstd -9&lt;/td&gt;
&lt;td&gt;4.0 s / 25.6 s&lt;/td&gt;
&lt;td&gt;0.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicated, uncompressed layers (&lt;strong&gt;repack&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;gzip -9 (via &lt;code&gt;pigz&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;84.0 s / &lt;strong&gt;898.5 s&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;15.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicated, uncompressed layers (&lt;strong&gt;repack&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;zstd -6&lt;/td&gt;
&lt;td&gt;20.3 s / 124.7 s&lt;/td&gt;
&lt;td&gt;5.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicated, uncompressed layers (&lt;strong&gt;repack&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;zstd -9&lt;/td&gt;
&lt;td&gt;34.4 s / 203.1 s&lt;/td&gt;
&lt;td&gt;5.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deduplicated, uncompressed layers (&lt;strong&gt;repack&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;zstd -9, 1 GiB window&lt;/td&gt;
&lt;td&gt;32.9 s / 188.7 s&lt;/td&gt;
&lt;td&gt;4.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;gzip loses on every axis that matters here&lt;/strong&gt;: on uncompressed layers it burns 899 CPU-seconds to produce a &lt;em&gt;larger&lt;/em&gt; file (2.26 GiB) than zstd -9 does in 203 (1.84 GiB), and it decodes 3× slower - and that is with parallel deflate; single-threaded &lt;code&gt;gzip -9&lt;/code&gt; would take fifteen minutes of wall clock for the privilege.&lt;/p&gt;

&lt;p&gt;There is no budget at which gzip is the right answer for the bundle &lt;em&gt;itself&lt;/em&gt;. In registries it survives for compatibility, and that part is not nothing: both modes land &lt;code&gt;tar+zstd&lt;/code&gt; layers, so every registry on the path has to accept OCI 1.1 media types and every puller has to be containerd ≥ 1.5 or Docker ≥ 23. That is a precondition to check before adopting any of this, not a footnote.&lt;/p&gt;

&lt;h3&gt;
  
  
  The finding that overruled my design
&lt;/h3&gt;

&lt;p&gt;I had specified a 128 MiB long-distance window as the repack default. The data says that is the wrong knob setting, and it is not close:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Bundle&lt;/th&gt;
&lt;th&gt;Compress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;level 6, 128 MiB window&lt;/td&gt;
&lt;td&gt;1.78 GiB&lt;/td&gt;
&lt;td&gt;24.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;level 9, 128 MiB window&lt;/td&gt;
&lt;td&gt;1.70 GiB&lt;/td&gt;
&lt;td&gt;36.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;level 9, 1 GiB window&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.48 GiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;level 9, 2 GiB window&lt;/td&gt;
&lt;td&gt;1.43 GiB&lt;/td&gt;
&lt;td&gt;141 s §&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;level 19, 128 MiB window&lt;/td&gt;
&lt;td&gt;1.46 GiB&lt;/td&gt;
&lt;td&gt;625 s §&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;level 19, 2 GiB window&lt;/td&gt;
&lt;td&gt;1.21 GiB&lt;/td&gt;
&lt;td&gt;869 s §&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those three runs hit the 15 GiB laptop's memory ceiling rather than measuring zstd cleanly: they swapped 18, 538 and 68 MiB, and 19%, 5% and 10% of the machine's CPU went somewhere other than the compressor. The sizes are deterministic and unaffected; the wall times are inflated by an unknown amount. A repeat of the 2 GiB/level 9 run in digest order took 132 s, so the roughly 4× gap to the 1 GiB window is real, not an artifact.&lt;/p&gt;

&lt;p&gt;Moving through &lt;strong&gt;levels&lt;/strong&gt; 6→9 buys 4%. Moving the &lt;strong&gt;window&lt;/strong&gt; from 128 MiB to 1 GiB buys 13%, at the same speed. Level 19 gets you to roughly the same place as the bigger window, 17× slower.&lt;/p&gt;

&lt;p&gt;The window is not free at the far end, though, and this is worth budgeting before adopting it: peak decode memory goes from 133 MiB to 1.01 GiB, the &lt;code&gt;zstd&lt;/code&gt; CLI refuses a frame needing more than a 128 MiB window unless you pass &lt;code&gt;--long=31&lt;/code&gt;, and klauspost's Go decoder rejects anything above 512 MiB until you call &lt;code&gt;WithDecoderMaxWindow&lt;/code&gt; - which has to happen &lt;em&gt;before&lt;/em&gt; decoding starts, so the importer cannot read the setting out of the bundle it has not opened yet.&lt;/p&gt;

&lt;p&gt;The mechanism is visible in the data. Remember those six near-identical revisions of the Debian base - same 74.3 MiB of files, different digests, so deduplication cannot touch them. In first-use order they sit at offsets &lt;strong&gt;142, 216, 403, 549, 5367 and 5563 MiB&lt;/strong&gt; in a 7 GiB stream. A 128 MiB window reaching back from 403 MiB sees nothing but itself; a 1 GiB window covers the whole early cluster at once, and separately pairs the two stragglers at 5367 and 5563. That is where the 13% comes from: not from any single image compressing better, but from the compressor finally being able to look far enough back to notice it has seen this Debian base several times already.&lt;/p&gt;

&lt;p&gt;(On a 5-image test set the whole tar is 181 MiB, everything related is already within reach, and larger windows add exactly nothing - which is how a small test set talks you out of the right answer.)&lt;/p&gt;

&lt;p&gt;One more small thing with a mechanism behind it: writing layers in &lt;strong&gt;first-use order&lt;/strong&gt; beat digest order by 2.8% at a 2 GiB window, and made no difference at 128 MiB. Related images' layers end up near each other only if you don't scatter them by hash.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧪 The clean-slate test
&lt;/h2&gt;

&lt;p&gt;Numbers in a table are a claim. This was the test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Delete every generated artifact and re-pull all 25 images.&lt;/li&gt;
&lt;li&gt;Export the 1.48 GiB bundle, verify it offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;podman rmi --all&lt;/code&gt;&lt;/strong&gt; - 28 images gone, &lt;code&gt;images left: 0&lt;/code&gt;. That includes the skopeo and &lt;code&gt;registry:3&lt;/code&gt; images the import tooling itself runs, which the import then re-pulls. So this is not a test of the air gap; it is a test that nothing about the twenty-five payload images was left cached anywhere on the machine.&lt;/li&gt;
&lt;li&gt;Import from that one file into a fresh registry.&lt;/li&gt;
&lt;li&gt;Pull all 25 back and compare every image ID against the original config digest.&lt;/li&gt;
&lt;li&gt;Run containers.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;image-ID mismatches: 0

alpine:3.24                  Alpine Linux v3.24 x86_64 ok
debian:bookworm-slim         12.15
python:3.13-slim-bookworm    3.13.15 2689367b205c
node:22-bookworm-slim        v22.23.3 x64
redis:7-alpine               Redis server v=7.4.11 ...
postgres:16-bookworm         postgres (PostgreSQL) 16.15
busybox:1.36                 busybox ok 406
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;25 of 25 image IDs matched, podman verified every layer digest on pull, and the containers ran. 3.43 GiB of per-image tarballs became a 1.48 GiB file that fully reconstructs the fleet.&lt;/p&gt;

&lt;p&gt;One honest gap, since it is the premise of the whole post: the destination here was a local &lt;code&gt;registry:3&lt;/code&gt;, not the Artifactory this is ultimately for. Whether Artifactory accepts OCI 1.1 zstd manifests, exposes the referrers API and allows cross-repo mounts is the first thing on the list, and until that run happens these numbers describe a design that has been proven against a reference registry, not against production.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 What I would tell the next person
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find the decision that forks the design, and make the fork explicit.&lt;/strong&gt; Here it was digest fidelity. Everything else - bundle format, compression, import logic - followed from it. If I had started with "which compression level?", I would have optimised the wrong axis for a week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the expensive property a property of the format, not of the code.&lt;/strong&gt; Dedup came from content addressing. Streaming import came from entry ordering. Reproducibility came from tar flags. No algorithms required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a hash of your output is an identity, freeze everything that can change a byte&lt;/strong&gt; - library version, API call, serialisation, key order - and add a test that fails when it moves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure before you tune.&lt;/strong&gt; My design said a 128 MiB window; the data said 1 GiB, for 13% at the same speed. My instinct said single-stream compression was the big win; the data said deduplication was (3.43 → 2.30 GiB), that re-encoding each layer once with &lt;code&gt;cz1&lt;/code&gt; was the next step (2.30 → 1.82 GiB), and that compressing the bundle as one stream added 18.5% on top (1.82 → 1.48 GiB) only with a window large enough to see across it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then measure your measurements.&lt;/strong&gt; Sampled memory read a third low; CPU accounting swept in helper processes; a "quiet" laptop is 2-5% busy. If a number is going to justify a decision, know how it was produced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adversarial review earns its keep.&lt;/strong&gt; Reviewing the &lt;em&gt;code&lt;/em&gt;, then the &lt;em&gt;numbers&lt;/em&gt;, then the &lt;em&gt;write-up&lt;/em&gt;, each with someone trying to refute the findings, caught a reversed percentage, an encoder that was not as frozen as I claimed, and a signature claim I could not support.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code, the architecture document, the delta design and the full measurement report - including the bits that did not flatter me - are on GitHub: &lt;strong&gt;&lt;a href="https://github.com/snskArora/oci_export" rel="noopener noreferrer"&gt;snskArora/oci_export&lt;/a&gt;&lt;/strong&gt;. The bash prototype is complete end to end (fetch → transform → bundle → verify → import) and the Go implementation is next.&lt;/p&gt;

&lt;p&gt;Until then: one file, twenty-five images, and a very quiet air gap.&lt;/p&gt;

</description>
      <category>benchmarks</category>
      <category>zstd</category>
      <category>containers</category>
      <category>go</category>
    </item>
    <item>
      <title>📦 One File, Twenty-Five Images, Part 1/2 - The Design</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 27 Sep 2026 17:59:34 +0000</pubDate>
      <link>https://dev.to/snskarora/one-file-twenty-five-images-part-12-the-design-23e3</link>
      <guid>https://dev.to/snskarora/one-file-twenty-five-images-part-12-the-design-23e3</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Here is a problem that sounds boring until you try it: &lt;strong&gt;move twenty-five container images from one world to another, where the two worlds share no network.&lt;/strong&gt; Source registry on one side, Artifactory on the other, and between them a link, a laptop, or in the worst case a person carrying a disk.&lt;/p&gt;

&lt;p&gt;The obvious script exists, everybody writes it, and it was the first thing I wrote too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;image &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;images&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;podman pull &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  podman save &lt;span class="nt"&gt;--format&lt;/span&gt; oci-archive &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="s2"&gt;"images/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;safe_name&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tar"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$image&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;--use-compress-program&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"zstd -T0 -9"&lt;/span&gt; &lt;span class="nt"&gt;-cf&lt;/span&gt; images.tar.zst images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-five images in, &lt;strong&gt;3.43 GiB&lt;/strong&gt; out. It works. Ship it, go home.&lt;/p&gt;

&lt;p&gt;Except it bothered me, because I knew what was inside that file. Nine of those images start from the &lt;em&gt;exact same&lt;/em&gt; &lt;code&gt;debian:bookworm-slim&lt;/code&gt; base layer - same digest, 74 MiB each - and every one carries its own private copy. Four Alpine images share a second base, the three Jupyter notebooks a third. And five more each start from a slightly &lt;em&gt;different&lt;/em&gt; revision of that same Debian base: five near-identical 74 MiB layers with five different digests, which content addressing cannot collapse at all. The tarball is a museum of duplication, and then we compress the duplicates &lt;em&gt;separately&lt;/em&gt;, so the compressor cannot even notice they are duplicates.&lt;/p&gt;

&lt;p&gt;So I did what the situation deserved: designed it properly, built a prototype, measured everything, and let the numbers overrule my instincts twice. Two answers came out, not one. &lt;strong&gt;1.82 GiB&lt;/strong&gt; (47% smaller) keeps every image digest unconditionally and imports with a plain push - that is the default I would ship. &lt;strong&gt;1.48 GiB&lt;/strong&gt; (57% smaller) goes further by compressing the whole bundle as one stream, at the cost of re-encoding every layer on import, about 690 CPU-seconds for this set. Either way it is one file, and that file reconstructs all twenty-five working images after you delete every image on the machine.&lt;/p&gt;

&lt;p&gt;This post is the whole path - every design decision, why it went that way, and what the measurements said when I finally stopped guessing. Code is at the end.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧨 Why the naive approach is expensive
&lt;/h2&gt;

&lt;p&gt;Three separate costs stack up in that one-liner, and it is worth separating them because they have different fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. No deduplication.&lt;/strong&gt; A container image is a manifest pointing at a config blob and a list of layer blobs, each addressed by the SHA-256 of its bytes. &lt;code&gt;podman save&lt;/code&gt; writes one self-contained archive per image, so a layer shared by nine images is written nine times. Content addressing already gives you deduplication for free - &lt;em&gt;if&lt;/em&gt; you put all the images in one content-addressed store instead of twenty-five separate ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Layers are gzip, and gzip is old.&lt;/strong&gt; Registries store layers gzipped, mostly for compatibility reasons dating to Docker's first manifest format. zstd at a comparable CPU budget is roughly a fifth smaller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Compressing already-compressed bytes.&lt;/strong&gt; Wrapping gzipped layers in &lt;code&gt;zstd -9&lt;/code&gt; buys almost nothing - it is re-compressing entropy. In my measurements the outer level changed a pristine bundle's size by &lt;strong&gt;less than 0.2%&lt;/strong&gt; between zstd -3 and zstd -9. All that CPU for nothing.&lt;/p&gt;

&lt;p&gt;There is also a fourth cost that only showed up when I measured: &lt;code&gt;podman save&lt;/code&gt; &lt;strong&gt;re-compresses&lt;/strong&gt; layers as it writes them. Podman stores layers unpacked and re-gzips on the way out, and its output is about &lt;strong&gt;3% larger&lt;/strong&gt; than the bytes the registry served. You pay a few percent for a round trip through local storage.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 HLD, decision 1: what &lt;em&gt;is&lt;/em&gt; the artifact?
&lt;/h2&gt;

&lt;p&gt;First real decision. The bundle format could be anything - a custom container format with an index, a squashfs, a big tarball with a manifest at the front.&lt;/p&gt;

&lt;p&gt;I picked: &lt;strong&gt;a valid &lt;a href="https://github.com/opencontainers/image-spec/blob/main/image-layout.md" rel="noopener noreferrer"&gt;OCI image layout&lt;/a&gt; inside a tar, compressed with zstd.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bundle.tar.zst
└── (zstd)
    └── (tar)
        ├── oci-layout
        ├── bundle-meta.json      &amp;lt;- my metadata: mode, images, inventory
        ├── index.json            &amp;lt;- the OCI index: one entry per image
        └── blobs/sha256/
            ├── &amp;lt;manifest digests&amp;gt;
            ├── &amp;lt;config digests&amp;gt;
            └── &amp;lt;layer digests&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this and not something bespoke:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deduplication falls out of the format.&lt;/strong&gt; &lt;code&gt;blobs/sha256/&amp;lt;digest&amp;gt;&lt;/code&gt; is content-addressed by definition. Two images sharing a layer write the same path. I did not implement dedup; I picked a layout where dedup is the only possible behaviour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not a dead end.&lt;/strong&gt; If my tool is unavailable on the far side - and on an air-gapped host, that is a real scenario - &lt;code&gt;skopeo copy oci:unpacked:&amp;lt;ref&amp;gt; docker://registry/...&lt;/code&gt; pushes images straight out of an unpacked &lt;em&gt;pristine&lt;/em&gt; bundle. That is the case I actually tried, and it wants skopeo ≥ 1.22, because older versions refuse the Docker schema-2 manifests that three of these twenty-five images still carry. A repack bundle is spec-valid too, so the same command should work, but it would push the uncompressed layers as-is. The format degrades to "standard tools work", with an asterisk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ecosystem already agreed on it.&lt;/strong&gt; Manifests, configs, indexes, media types, annotations: all specified, all understood by every registry client. Inventing a format means inventing all of that badly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting design work is not the container, it is what goes inside it.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚖️ HLD, decision 2: the fork that surprised me
&lt;/h2&gt;

&lt;p&gt;Here is the thing I did not see coming when I sketched the first version. My plan was: &lt;em&gt;store layers uncompressed inside the bundle, compress the whole bundle with zstd, recompress layers at import.&lt;/em&gt; A bigger compression window - zstd's window is how far back in the stream it may look for a repeat, 4 MiB at level 9 by default and up to 2 GiB with long-distance matching enabled - would finally let the compressor see that those nine Debian bases are the same bytes. Cross-image redundancy, everybody wins.&lt;/p&gt;

&lt;p&gt;It does win. It also &lt;strong&gt;changes every image digest&lt;/strong&gt;, and that took me a while to accept.&lt;/p&gt;

&lt;p&gt;A manifest references each layer by the digest of its &lt;strong&gt;compressed&lt;/strong&gt; bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mediaType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/vnd.oci.image.layer.v1.tar+gzip"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"digest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256:e2de96513ba9..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3849738&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decompress a layer for transport and you cannot reproduce those exact bytes later. gzip output depends on the implementation, the level, the window, the version. So the importer must recompress, which yields different layer digests, which changes every manifest, and the manifest digest &lt;strong&gt;is&lt;/strong&gt; the image digest. Everything pinned as &lt;code&gt;image@sha256:...&lt;/code&gt; breaks. Every cosign signature, which signs the manifest digest, stops verifying.&lt;/p&gt;

&lt;p&gt;What survives is more subtle and worth knowing: the image config lists &lt;code&gt;rootfs.diff_ids&lt;/code&gt;, the digests of the &lt;strong&gt;uncompressed&lt;/strong&gt; layers. Those are preserved exactly, and runtimes verify them on pull. So integrity is fully intact and tags work perfectly; it is identity that changes.&lt;/p&gt;

&lt;p&gt;I considered reconstructing the original compressed bytes (the trick &lt;code&gt;pristine-tar&lt;/code&gt; uses for Debian source packages) and rejected it: it means capturing enough of the gzip encoder's state to replay it bit-exactly across versions, which is fragile machinery in exchange for an edge case.&lt;/p&gt;

&lt;p&gt;One term first, because everything after this leans on it. &lt;strong&gt;Ingest&lt;/strong&gt; is a one-time step that runs &lt;em&gt;before&lt;/em&gt; an image is signed or pinned: pull it, re-encode each layer with the canonical profile described in the next section, push it back. From then on the registry itself holds canonical zstd layers, and that is the version tags, pins and signatures refer to.&lt;/p&gt;

&lt;p&gt;The resolution I landed on is the part of the design I would defend hardest: &lt;strong&gt;pair the transport mode with the trust anchor.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;pristine&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;repack&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What is signed&lt;/td&gt;
&lt;td&gt;each image, individually&lt;/td&gt;
&lt;td&gt;the bundle, once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layers travel&lt;/td&gt;
&lt;td&gt;exactly as stored&lt;/td&gt;
&lt;td&gt;uncompressed, deduplicated by &lt;code&gt;diff_id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compression happens&lt;/td&gt;
&lt;td&gt;once per layer, at ingest&lt;/td&gt;
&lt;td&gt;over the whole bundle, at export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Digests&lt;/td&gt;
&lt;td&gt;preserved byte for byte&lt;/td&gt;
&lt;td&gt;rewritten (but see the twist below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If images are individually signed, their bytes may not be touched, so the bundle carries them verbatim and the outer zstd does almost nothing (the layers are already compressed). If only the bundle is signed, layers can travel raw and the outer compressor can do real work across image boundaries. &lt;strong&gt;One flag, two coherent worlds, and no mode where a signature quietly stops verifying.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the kind of decision I now look for in designs generally: not "which is better", but "what do these two options actually &lt;em&gt;depend on&lt;/em&gt;, and can I make the dependency explicit in the interface?"&lt;/p&gt;




&lt;h2&gt;
  
  
  🧊 HLD, decision 3: a frozen encoder, and why determinism is the whole game
&lt;/h2&gt;

&lt;p&gt;If images are going to be re-encoded (once at ingest for pristine, at every import for repack), the encoder's output must be &lt;strong&gt;reproducible byte for byte, forever&lt;/strong&gt;. If it is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the same image ingested twice gets two different digests;&lt;/li&gt;
&lt;li&gt;the importer cannot tell "already present" from "different";&lt;/li&gt;
&lt;li&gt;deduplication against the destination stops working;&lt;/li&gt;
&lt;li&gt;and repack's digests drift every time a library is upgraded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;cz1&lt;/code&gt; (canonical zstd, profile 1) is a &lt;em&gt;pinned set of settings&lt;/em&gt;, not "zstd -19":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithEncoderLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SpeedBestCompression&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithWindowSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithEncoderConcurrency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c"&gt;// concurrency changes block boundaries&lt;/span&gt;
    &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithEncoderCRC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithAllLitEntropyCompression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithZeroFrames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;zstd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithLowerEncoderMem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The library version is pinned in &lt;code&gt;go.mod&lt;/code&gt;. Upgrading it is not a dependency bump, it is a &lt;strong&gt;profile change&lt;/strong&gt; (&lt;code&gt;cz2&lt;/code&gt;) and a deliberate, scheduled re-canonicalisation.&lt;/p&gt;

&lt;p&gt;Then a hole is uncovered, and it is a good one. With &lt;em&gt;identical options&lt;/em&gt;, klauspost/compress writes &lt;strong&gt;different bytes depending on how you drive it&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NewWriter&lt;/code&gt; + streaming, for input larger than one block → frame with no &lt;code&gt;Frame_Content_Size&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ResetContentSize&lt;/code&gt; → content size in the header;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EncodeAll&lt;/code&gt; → content size, plus the single-segment flag when the input fits the window;&lt;/li&gt;
&lt;li&gt;and &lt;code&gt;NewWriter&lt;/code&gt; + streaming for input &lt;em&gt;smaller&lt;/em&gt; than one block → the library takes the &lt;code&gt;EncodeAll&lt;/code&gt; path internally and emits that header shape anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three different digests for the same layer and the same settings. And a Go implementation written from my architecture document would plausibly reach for &lt;code&gt;EncodeAll&lt;/code&gt;, because tar headers hand you the size up front. My own doc would have produced a tool that disagreed with my prototype.&lt;/p&gt;

&lt;p&gt;Fix, in two parts. The frame shape became part of the profile definition: one frame, driven through &lt;code&gt;ReadFrom&lt;/code&gt;, with whatever header the library writes for that input size. Naming an API is not enough, because the library crosses between them on its own - so the profile also got &lt;strong&gt;golden vectors&lt;/strong&gt;, checked on every run and in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# sha256 of 'seq 1 N | cz1' - frozen outputs of the cz1 profile
8adb12de4536b3c50b4fed4c43fa4411088d61374243b51ae0b8237807d33d92 10000
719f1575295ca6deeb551131294ebc3c20d7c186ea76ad94e9e82726ffdcc878 500000
4ed7f465a721e86af9d211023cc8e473cf1f8787550a5f58d9db5a31504eaaae 3000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three sizes on purpose. &lt;code&gt;N&lt;/code&gt; is the argument to &lt;code&gt;seq&lt;/code&gt;, so the inputs are 48 KB, 3.4 MB and 22.9 MB: one that fits in a single 128 KiB block, one below the 8 MiB window, one spanning several windows. They exercise different frame-header paths, and you can see it in the first byte after the magic: the smallest vector gets &lt;code&gt;0x64&lt;/code&gt; (content size present, single segment), the larger two &lt;code&gt;0x04&lt;/code&gt; (neither). That is the library switching paths on input size - precisely the behaviour a hand-written implementation would get wrong.&lt;/p&gt;

&lt;p&gt;If a library upgrade changes a single byte of output, CI fails with "cz1 drift" instead of silently giving every future image a new identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLD lesson worth generalising:&lt;/strong&gt; when a hash of your output is part of your system's identity, your dependency's &lt;em&gt;default behaviour&lt;/em&gt; is not a detail you can leave to the library. Pin it, and prove the pin with a test that would fail if it moved.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧱 LLD: the bundle, in detail
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Entry order is a design decision
&lt;/h3&gt;

&lt;p&gt;The tar is written in a fixed order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;oci-layout → bundle-meta.json → index.json → manifests → configs → layers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because a tar is a &lt;strong&gt;stream&lt;/strong&gt;. An importer reading it sequentially meets the metadata first: it can plan, check the format version, verify sizes, and decide what to skip &lt;em&gt;before&lt;/em&gt; gigabytes of layers arrive. Put the index last and streaming import becomes impossible - you would have to buffer everything to disk first. Ordering is free at write time and decides what the reader is capable of.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tar must be deterministic
&lt;/h3&gt;

&lt;p&gt;Same images in, byte-identical file out. That makes bundles cacheable, resumable, comparable, and auditable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;--create&lt;/span&gt; &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;- &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;posix &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--pax-option&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--mtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'@0'&lt;/span&gt; &lt;span class="nt"&gt;--owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nt"&gt;--numeric-owner&lt;/span&gt; &lt;span class="nt"&gt;--mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'a=r,u+w,a+X'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--no-recursion&lt;/span&gt; &lt;span class="nt"&gt;--verbatim-files-from&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$layout&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--files-from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$list&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every flag there is load-bearing, and one guards against a trap: the pax extended-header name. POSIX specifies &lt;code&gt;%d/PaxHeaders.%p/%f&lt;/code&gt;, and &lt;strong&gt;&lt;code&gt;%p&lt;/code&gt; is the process ID&lt;/strong&gt; - so on GNU tar 1.32 and older, or on any version with &lt;code&gt;POSIXLY_CORRECT&lt;/code&gt; set, a "deterministic" archive quietly embeds the PID of the process that made it. Two runs, two different files, identical content. GNU tar 1.35 (what I measured with) already defaults to the PID-free form, which means this is exactly the kind of bug that works on your laptop and breaks on a build box.&lt;/p&gt;

&lt;p&gt;(Bonus finding: zstd's compressed output is byte-identical at 1, 2, 4 and 12 threads. So the compressed bundle is reproducible too, not just the tar.)&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;bundle-meta.json&lt;/code&gt; carries the inventory
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"formatVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cz1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"linux/amd64"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"codec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"zstd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"windowLog"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"images"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"digest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256:..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sourceDigest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256:..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inventory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"digest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha256:..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"layer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;41234567&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"included"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exceptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"digest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mediaType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unrecognized layer media type"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two pieces of foresight in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inventory&lt;/code&gt; with &lt;code&gt;included: true&lt;/code&gt;&lt;/strong&gt; - today everything is included. But it is the hook that makes &lt;em&gt;delta bundles&lt;/em&gt; possible later: ship only the blobs the destination lacks, and mark the rest &lt;code&gt;included: false&lt;/code&gt; for the importer to find locally. Designing the field in now costs nothing; adding it later is a format break. (The full delta design is a separate document in the repo, deliberately &lt;strong&gt;not&lt;/strong&gt; built yet.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exceptions&lt;/code&gt;&lt;/strong&gt; - the safety rule. Only recognised image layers are ever transformed. Helm charts, ORAS artifacts, foreign/nondistributable layers, anything with a media type I don't know: carried verbatim and listed here. &lt;strong&gt;A converter that touches things it doesn't understand is a data-corruption bug waiting for a slow afternoon.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Verify while you are already touching the bytes
&lt;/h3&gt;

&lt;p&gt;Every layer is decompressed exactly once during a repack export, and its &lt;code&gt;diff_id&lt;/code&gt; is checked &lt;em&gt;in the same pass&lt;/em&gt;, not in a second read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkfifo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;.fifo"&lt;/span&gt;
&lt;span class="nb"&gt;sha256sum&lt;/span&gt; &amp;lt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;.fifo"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;.sha"&lt;/span&gt; &amp;amp;
&lt;span class="nv"&gt;shapid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;
&lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="nt"&gt;-dc&lt;/span&gt; &amp;lt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;.fifo"&lt;/span&gt; | &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$shapid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"sha256:&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp&lt;/span&gt;&lt;span class="s2"&gt;.sha"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$diff_id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; die &lt;span class="s2"&gt;"layer does not match its diff_id"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verification that costs a second pass over 7 GB gets switched off "temporarily" by someone in a hurry. Verification that rides along a pass you are already making is free, so it stays on. &lt;strong&gt;Design your integrity checks to be cheap enough that nobody has an argument for disabling them.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🔁 LLD: the import side, and a twist I did not plan
&lt;/h2&gt;

&lt;p&gt;Import is the mirror, with one ordering rule that comes from the registry protocol: &lt;strong&gt;blobs before manifests.&lt;/strong&gt; A registry rejects a manifest whose blobs are not uploaded yet, which conveniently means a half-finished import is invisible - unreferenced blobs, no tags, and re-running is idempotent.&lt;/p&gt;

&lt;p&gt;For repack, the importer re-encodes every layer with &lt;code&gt;cz1&lt;/code&gt; and rewrites the manifests. And here is where the measurement produced something better than the design:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because &lt;code&gt;cz1&lt;/code&gt; is deterministic, a repack import lands on &lt;em&gt;exactly&lt;/em&gt; the digests that the ingest step would have produced.&lt;/strong&gt; Both modes converge on one canonical digest per image. I tested this at 25 images: 25/25 identical.&lt;/p&gt;

&lt;p&gt;Which leads to the twist. If the images were &lt;em&gt;already&lt;/em&gt; ingested - canonical &lt;code&gt;cz1&lt;/code&gt; layers - then the importer's re-encode reproduces the original layer blobs bit for bit. So the importer can put the &lt;strong&gt;original manifest&lt;/strong&gt; back, byte for byte, and the image keeps its digest through repack transport. The prototype does this: it carries the pre-repack manifest in the bundle, and restores it when the re-encode reproduces every blob it references. 25/25 restored.&lt;/p&gt;

&lt;p&gt;The consequence is a change to the architecture I wrote: &lt;strong&gt;repack no longer has to break signatures.&lt;/strong&gt; For already-ingested images it preserves digests, so a per-image signature made at ingest would still verify - &lt;em&gt;if&lt;/em&gt; the bundle also carried the signature artifact, which my repack design deliberately doesn't, and which the prototype never tested, and as long as the importer runs the same &lt;code&gt;cz1&lt;/code&gt; profile the ingest did. My trust table was more pessimistic than reality; it is still the honest version that says "digests preserved", not "signatures shipped".&lt;/p&gt;

&lt;p&gt;But a second reviewer caught me overstating it, and the correction is the honest version: &lt;strong&gt;digest preservation here depends on the manifest rewrite rules as much as on the encoder.&lt;/strong&gt; Annotation set, key order, JSON serialisation - jq preserves insertion order; Go's &lt;code&gt;encoding/json&lt;/code&gt; sorts map keys and HTML-escapes by default. Two implementations following my architecture doc would produce different manifest bytes and therefore different digests. So the rewrite rules have to be frozen &lt;em&gt;alongside&lt;/em&gt; the encoder, with golden manifests in the test suite. The digest is a hash of the whole manifest; every byte of it is part of your public interface.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔜 Next
&lt;/h2&gt;

&lt;p&gt;That is the design: two transport modes tied to their trust anchors, a frozen encoder, a bundle whose format does the deduplication for you, and an importer that turned out to preserve more than I designed it to.&lt;/p&gt;

&lt;p&gt;Designs are cheap. Part 2 is where I stop reasoning and start measuring - the bash prototype, the several ways my own measurements lied to me, four reviewers trying to break it, and the numbers. Including the one that overruled a decision I had just finished making.&lt;/p&gt;

</description>
      <category>containers</category>
      <category>go</category>
      <category>artifactory</category>
      <category>zstd</category>
    </item>
    <item>
      <title>🕳️ HomeLab Chronicles: Episode 9 - Tunnel Vision</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 30 Aug 2026 10:04:13 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-9-tunnel-vision-c38</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-9-tunnel-vision-c38</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;State of the lab: two nodes, Flux in charge, Airflow alive, backups nightly, RBAC banished to therapy. The UI works from anywhere in the house. But "in the house" is a service radius measured in Wi-Fi bars, and I wanted my Airflow at &lt;code&gt;airflow.sanskararora.in&lt;/code&gt;, from anywhere, like a person with a real platform and not two laptops fighting a router for their lives.&lt;/p&gt;

&lt;p&gt;The classic move is port forwarding: open a hole in the router, point it at the NodePort, pray. Every part of that sentence should upset you. My router has already been established, across two episodes, as this saga's least reliable character — and now I'd make it my &lt;em&gt;front door&lt;/em&gt; too? Also: home IP published to the world, DDoS surface, dynamic-IP DNS jank. No.&lt;/p&gt;

&lt;p&gt;Enter &lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt;. The trick that makes it homelab-perfect: a small daemon (&lt;code&gt;cloudflared&lt;/code&gt;) runs &lt;em&gt;inside&lt;/em&gt; the cluster and dials &lt;strong&gt;out&lt;/strong&gt; to Cloudflare's edge. Traffic to my domain rides that already-open outbound connection back in. Zero inbound ports. Zero public IP. The router is demoted from front door to hallway, which is frankly all it ever deserved.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚇 Two Tunnels Diverged in a Yellow Dashboard
&lt;/h2&gt;

&lt;p&gt;First fork in the road, and I took both prongs like a professional: Cloudflare tunnels come in two flavors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locally-managed:&lt;/strong&gt; &lt;code&gt;cloudflared tunnel create&lt;/code&gt; on your machine, credentials JSON, routing rules in a config file → which for me means &lt;em&gt;in git&lt;/em&gt;. GitOps-pure. Chef's kiss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remotely-managed:&lt;/strong&gt; click "create tunnel" in the Zero Trust dashboard, get a token, routing rules live in the UI.&lt;/p&gt;

&lt;p&gt;I wrote the manifests for flavor one, then created the tunnel in the dashboard, which is flavor two. The dashboard then helpfully offers you install commands — &lt;code&gt;sudo cloudflared service install eyJ...&lt;/code&gt; — which are for running it &lt;em&gt;on a host&lt;/em&gt;, i.e., not what a cluster deployment wants at all. The only thing you need from that whole screen is the token. The long &lt;code&gt;eyJhIjoi...&lt;/code&gt; string. That's the tunnel's entire identity; treat it like a password, because it is one.&lt;/p&gt;

&lt;p&gt;The in-cluster version is refreshingly small — a Deployment, two replicas with pod anti-affinity so the tunnel survives a node dying, &lt;code&gt;cloudflared tunnel run&lt;/code&gt; reading &lt;code&gt;TUNNEL_TOKEN&lt;/code&gt; from a Secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TUNNEL_TOKEN&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;tunnel-token&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;token&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token secret is created by hand with &lt;code&gt;stringData:&lt;/code&gt; (paste as-is, the API server base64s it — hand-encoding a token that's &lt;em&gt;already&lt;/em&gt; opaque gibberish is how you double-encode and then debug "auth errors" for an hour). The manifest holding it never touches git. &lt;code&gt;.gitignore&lt;/code&gt; first, file second — that order, specifically, because &lt;code&gt;git add -A&lt;/code&gt; has ended better tokens than mine.&lt;/p&gt;

&lt;p&gt;Then in the dashboard: Public Hostname → &lt;code&gt;airflow.sanskararora.in&lt;/code&gt; → HTTP → the Envoy gateway service's in-cluster FQDN on 8080. DNS record appears automatically. And because my HTTPRoute matches any Host header (Episode 4's hard-won lesson about hostnames), the request flows: Cloudflare edge → tunnel → Envoy → route → Airflow. Login page. On my phone. On mobile data. In a parking lot. I looked deranged and I did not care.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n cloudflared logs -l app=cloudflared --tail=5
INF Registered tunnel connection ...
INF Registered tunnel connection ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four registered connections per replica. The dashboard's sad little "No connectors installed" flips to two healthy connectors. Lovely.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚪 The Part You Don't Get to Skip
&lt;/h2&gt;

&lt;p&gt;Now the uncomfortable paragraph. What I just built is: &lt;strong&gt;my Airflow admin login, on the public internet, guarded by one password, terminating inside my house.&lt;/strong&gt; Airflow is a genuinely juicy target — it runs arbitrary code on schedule, that's its &lt;em&gt;job&lt;/em&gt; — and it has the CVE history of software that important.&lt;/p&gt;

&lt;p&gt;And if you're thinking "nobody knows my subdomain exists" — the TLS certificate for it landed in public Certificate Transparency logs the moment it was issued. Scanners watch those logs like RSS feeds. Your "secret" subdomain gets its first uninvited visitors in minutes, not months. Obscurity isn't a moat; it's a countdown. But in order for me to access it freely i am skipping it for now.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧾 The Fine Print I'm Choosing to Live With
&lt;/h2&gt;

&lt;p&gt;Remotely-managed means the hostname→service routing lives in Cloudflare's dashboard, &lt;strong&gt;not in my repo&lt;/strong&gt;. Which dents the Episode 6 doctrine: a full cluster rebuild now restores everything &lt;em&gt;except&lt;/em&gt; the tunnel's routing table. That's one manual step reintroduced into a system I spent three episodes making manual-step-free. It's written in the README, on the rebuild checklist, where future-me will find it and sigh.&lt;/p&gt;

&lt;p&gt;The pure path exists — locally-managed tunnel, config in git, SOPS-encrypted token committed alongside it, &lt;code&gt;flux bootstrap --decryption-provider=sops&lt;/code&gt; — and it's genuinely the endgame. But the dashboard tunnel was already made, it works, and I have learned (Episodes 5 through 8, inclusive) to stop refactoring things that are currently succeeding.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Outbound-only beats port forwarding in every dimension that matters.&lt;/strong&gt; No inbound holes, no public IP, no router in the trust path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard tunnel = token + rules in UI. CLI tunnel = credentials file + rules in git.&lt;/strong&gt; Pick one &lt;em&gt;before&lt;/em&gt; writing manifests, unlike some people.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two replicas + anti-affinity&lt;/strong&gt; makes the tunnel survive exactly the node-death this cluster has proven it can produce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate Transparency means your subdomain was never secret.&lt;/strong&gt; Assume discovery is immediate, because it approximately is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access in front of everything.&lt;/strong&gt; Authentication at the edge, before your network — not a nice-to-have, the whole point of fronting with Cloudflare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stringData&lt;/code&gt; for token secrets.&lt;/strong&gt; Let the API server do the base64. You will mangle it; it won't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Command / Place&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Token into cluster&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl -n cloudflared create secret generic tunnel-token --from-literal=token='eyJ...'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tunnel healthy?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl -n cloudflared logs -l app=cloudflared&lt;/code&gt; → &lt;code&gt;Registered tunnel connection&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route a hostname&lt;/td&gt;
&lt;td&gt;Dashboard → Tunnels → Public Hostname → &lt;code&gt;HTTP&lt;/code&gt; → &lt;code&gt;&amp;lt;envoy-svc&amp;gt;.envoy-gateway-system.svc.cluster.local:8080&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find the Envoy svc name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl -n envoy-gateway-system get svc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The moat&lt;/td&gt;
&lt;td&gt;Zero Trust → Access → Applications → Self-hosted → Allow: your email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who already knows your subdomain&lt;/td&gt;
&lt;td&gt;crt.sh — search your domain, feel observed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;The Pi is still in the drawer. Cilium is still a rumor. But there's now a third-voter-shaped hole in this cluster's HA story, and dqlite quorum math says three is the first number that actually means anything. So: next episode, the drawer opens. Probably. The drawer has heard promises before.&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Nine episodes ago this was one laptop that couldn't survive a reboot. It's now a two-node, git-defined, nightly-backed-up, tunnel-fronted, edge-authenticated platform that survives power cuts out of spite. The router remains the weakest member of the team and has been architected around accordingly.&lt;/p&gt;

&lt;p&gt;Popcorn 🍿, coffee ☕, UPS 🔌, laminated card 🪪, chart-source bookmark 🔖 — and now a domain. The lab is public. Send help, or DAGs.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>cloudflare</category>
      <category>airflow</category>
      <category>devops</category>
    </item>
    <item>
      <title>🤫 HomeLab Chronicles: Episode 8 - The Silent Treatment</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 30 Aug 2026 09:58:28 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-8-the-silent-treatment-424a</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-8-the-silent-treatment-424a</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Quick recap: power cut (Ep5), GitOps (Ep6), locked myself out of my own control plane (Ep7), rebuilt in twenty minutes because GitOps (Ep6, smugly). Now: getting Airflow back up. Which should be easy, since I've installed it before — Episode 4, eight sidequests, badge earned.&lt;/p&gt;

&lt;p&gt;This episode is about a different kind of enemy. Not the loud failure — the &lt;strong&gt;silent one&lt;/strong&gt;. The config that gets accepted, nodded at, and thrown away. Because here's Helm's dirtiest little behavior, in one sentence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Helm does not validate your values. An unknown key isn't an error. It's a shrug.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Typo a field, use a field from an older chart, invent a field that sounds plausible — Helm renders the templates, your key matches nothing, and the install proceeds looking exactly like success. No warning. No error. Just vibes.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪦 The Graveyard of Values I Was Setting
&lt;/h2&gt;

&lt;p&gt;Instead of trusting tutorials (or my own Episode 4, ahem), I finally did the thing: &lt;strong&gt;fetched the actual chart source&lt;/strong&gt; — &lt;code&gt;apache/airflow&lt;/code&gt; at tag &lt;code&gt;helm-chart/1.22.0&lt;/code&gt;, whose &lt;code&gt;appVersion&lt;/code&gt; is exactly my Airflow 3.2.2 — and checked every field I was setting against &lt;code&gt;values.yaml&lt;/code&gt; and the templates. The census results:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit A: &lt;code&gt;webserver:&lt;/code&gt; — valid key, wrong decade.&lt;/strong&gt; Chart 1.22.0 ships &lt;em&gt;both&lt;/em&gt; &lt;code&gt;webserver:&lt;/code&gt; (Airflow 2) and &lt;code&gt;apiServer:&lt;/code&gt; (Airflow 3). My replicas and resource limits sat under &lt;code&gt;webserver:&lt;/code&gt;, where Airflow 3 never looks. Every limit I thought I'd set on the UI? Decorative. Episode 4 established the &lt;em&gt;service&lt;/em&gt; was renamed; it didn't occur to me the values tree split too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit B: &lt;code&gt;logs.persistence.accessMode&lt;/code&gt; — does not exist.&lt;/strong&gt; This one's beautiful. The chart's log-PVC template &lt;em&gt;hardcodes&lt;/em&gt; &lt;code&gt;accessModes: ["ReadWriteMany"]&lt;/code&gt;. Which means my careful Episode 4 setting of &lt;code&gt;ReadWriteOnce&lt;/code&gt; — with its confident little comment about single-node clusters — &lt;strong&gt;was ignored the entire time.&lt;/strong&gt; I got RWX whether I asked or not. The chart was protecting me from a config I was proud of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit C: &lt;code&gt;webserverSecretKeySecretName&lt;/code&gt; — Airflow &amp;lt;3 only.&lt;/strong&gt; The comment in the chart source says it plainly: that's the Flask secret for the &lt;em&gt;old&lt;/em&gt; webserver. Airflow 3 signs JWTs, wants &lt;code&gt;jwtSecretName&lt;/code&gt;, and the secret's key must be &lt;em&gt;exactly&lt;/em&gt; &lt;code&gt;jwt-secret&lt;/code&gt; — that name comes from a template helper, not from you. Get this wrong and the chart generates a fresh key on every render, and your components restart "for no reason." A warning my very first install printed, months of installs ago, that I finally understand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exhibit D: &lt;code&gt;knownHosts&lt;/code&gt; — a string, not a secret.&lt;/strong&gt; I'd stuffed a &lt;code&gt;known_hosts&lt;/code&gt; file into the SSH secret next to the deploy key, feeling organized. The chart wanted it as a plain &lt;em&gt;values string&lt;/em&gt; under &lt;code&gt;dags.gitSync.knownHosts&lt;/code&gt;. My copy sat in the secret, unread, while git-sync remained one bad default away from host-key failure. (Also, &lt;code&gt;wait:&lt;/code&gt; got superseded by &lt;code&gt;period:&lt;/code&gt;. Also-also, the SSH secret's key must be named &lt;code&gt;gitSshKey&lt;/code&gt; verbatim — that one Episode 4 got right. Growth.)&lt;/p&gt;

&lt;p&gt;Four fields. Four different flavors of silently doing nothing. &lt;code&gt;helm lint&lt;/code&gt; caught zero of them, because none of them are &lt;em&gt;syntax&lt;/em&gt; errors. They're fluent, grammatical nonsense.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎩 Sidequest: The Job That Vanished Like a Magician
&lt;/h2&gt;

&lt;p&gt;With values corrected, install. Pods appear! Pods crash-loop. Init container says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TimeoutError: There are still unapplied migrations after 60 seconds.
MigrationHead(s) in DB: set() | Migration Head(s) in Source Code: {'1d6611b6ab7c'}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;set()&lt;/code&gt; — empty. Postgres reachable, schema &lt;em&gt;blank&lt;/em&gt;. The migration job never ran. And when I went looking for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get jobs -A
No resources found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing. Anywhere. Because by default the chart runs migrations as a &lt;strong&gt;Helm hook&lt;/strong&gt; — it lives inside the install ceremony and gets cleaned up after, success or failure. My install had stalled (see below), Helm aborted early, the hook evaporated, and I was left with pods waiting for a schema that a ghost was supposed to write. Under Flux this deadlocks &lt;em&gt;beautifully&lt;/em&gt;: pods can't start without migrations, Helm won't rerun migrations because pods failed.&lt;/p&gt;

&lt;p&gt;The chart's own answer for GitOps land:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;migrateDatabaseJob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;useHelmHooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;createUserJob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;useHelmHooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the jobs are ordinary resources: applied like everything else, visible in &lt;code&gt;kubectl get jobs&lt;/code&gt;, logs readable when they fail. Debuggable beats ceremonial.&lt;/p&gt;




&lt;h2&gt;
  
  
  🥷 Sidequest: The Disk That Was "Already In Use"
&lt;/h2&gt;

&lt;p&gt;One more silent saboteur, this time below Kubernetes entirely. Fresh Postgres volume, brand new, zero history — and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MountVolume.MountDevice failed ... mke2fs 1.47.0
/dev/longhorn/pvc-... is apparently in use by the system;
will not make a filesystem here!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In use? It was &lt;em&gt;born&lt;/em&gt; thirty seconds ago. I deleted every PV, PVC, and Longhorn volume in the cluster and got the identical error on the replacement — which is the tell that the problem isn't in the cluster at all.&lt;/p&gt;

&lt;p&gt;It's &lt;strong&gt;multipathd&lt;/strong&gt;. Ubuntu ships it enabled, and its hobby is grabbing new block devices the instant they appear, in case they're part of a multipath SAN. I do not have a SAN. I have two laptops. But multipathd claimed each fresh Longhorn device anyway, held it open, and &lt;code&gt;mke2fs&lt;/code&gt; politely declined to format a held disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; multipathd multipathd.socket
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; airflow delete pod airflow-postgresql-0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres: Running. It's a documented Longhorn prerequisite that approximately nobody's tutorial mentions, including — checks notes — my own host-prep list from Episode 4.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧷 Bonus Round: The Password That Broke YAML
&lt;/h2&gt;

&lt;p&gt;The admin password flows in through a generated values snippet. First version built that YAML with shell string concatenation, which works perfectly until someone's password contains a &lt;code&gt;"&lt;/code&gt; — at which point the YAML is invalid and the failure surfaces &lt;em&gt;much&lt;/em&gt; later as an inscrutable HelmRelease error, with the password (correctly!) absent from every log. The fix is to stop hand-assembling YAML like it's 1997: serialize with &lt;code&gt;json.dumps&lt;/code&gt; — JSON is valid YAML, it's stdlib, and it escapes quotes, backslashes, colons and leading &lt;code&gt;#&lt;/code&gt;s without opinion. Tested against the most hostile passwords I could invent. All survived.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Helm ignores unknown values silently. All of them. Forever.&lt;/strong&gt; The only defense is reading the chart's actual &lt;code&gt;values.yaml&lt;/code&gt; — at the &lt;em&gt;tag you're installing&lt;/em&gt; — not tutorials, not memory, not last episode's notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;helm show values &amp;lt;chart&amp;gt; | grep &amp;lt;field&amp;gt;&lt;/code&gt; before setting anything load-bearing.&lt;/strong&gt; Thirty seconds. Cheaper than a weekend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A valid key from the wrong era is worse than a typo.&lt;/strong&gt; &lt;code&gt;webserver:&lt;/code&gt; parsed fine, rendered fine, and did nothing. Typos at least look wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helm hooks and GitOps are a bad marriage.&lt;/strong&gt; &lt;code&gt;useHelmHooks: false&lt;/code&gt; turns invisible ceremonies into inspectable resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MigrationHead(s) in DB: set()&lt;/code&gt; means "empty schema," not "slow schema."&lt;/strong&gt; The waiter isn't slow; the kitchen never got the order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;multipathd eats block devices on stock Ubuntu.&lt;/strong&gt; If &lt;code&gt;mke2fs&lt;/code&gt; claims a newborn disk is busy, that's your poltergeist.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never build YAML with string concatenation when a serializer is right there.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The ground truth for chart fields&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;helm show values apache-airflow/airflow &amp;gt; /tmp/v.yaml&lt;/code&gt; then grep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Even grounder truth&lt;/td&gt;
&lt;td&gt;read the chart repo at the release tag — templates don't lie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Airflow 3 UI settings live under&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apiServer:&lt;/code&gt; (not &lt;code&gt;webserver:&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT secret&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jwtSecretName:&lt;/code&gt; → secret key must be &lt;code&gt;jwt-secret&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jobs you can actually see&lt;/td&gt;
&lt;td&gt;&lt;code&gt;migrateDatabaseJob.useHelmHooks: false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk claimed at birth&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl disable --now multipathd multipathd.socket&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema actually migrated?&lt;/td&gt;
&lt;td&gt;look for &lt;code&gt;MigrationHead(s) in DB:&lt;/code&gt; ≠ &lt;code&gt;set()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;Airflow is up, migrations ran, and I can see it from my couch. But couch-radius availability is so Episode 4. Next time: a Cloudflare Tunnel puts it on my actual domain, from anywhere, without opening a single port on the plastic router of doom.&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Loud failures cost hours. Silent ones cost weekends — and this episode had four values, one hook, one daemon, and one quotation mark, all failing without making a sound.&lt;/p&gt;

&lt;p&gt;Popcorn 🍿, coffee ☕, UPS 🔌, laminated coredns card 🪪, and now a bookmark to the chart source. The toolkit is mostly talismans at this point.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>airflow</category>
      <category>helm</category>
      <category>longhorn</category>
    </item>
    <item>
      <title>🔒 HomeLab Chronicles: Episode 7 - Locked Out of My Own House</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 30 Aug 2026 09:50:03 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-7-locked-out-of-my-own-house-200j</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-7-locked-out-of-my-own-house-200j</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Short episode. Painful episode. The kind where the call is coming from inside the house, the house is the control plane, and nobody inside the house is allowed to answer the phone.&lt;/p&gt;

&lt;p&gt;Fresh rebuild, Flux repo ready, and I did the &lt;em&gt;responsible&lt;/em&gt; thing: enabled RBAC while the cluster was empty. Every best-practices doc agrees — turn on authorization early, before workloads exist, while it's painless.&lt;/p&gt;

&lt;p&gt;Reader, it was not painless.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚨 Symptom One: Flux Bootstrap Times Out
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ timeout waiting for: [CustomResourceDefinition/kustomizations... status: 'InProgress', ...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven CRDs, all stuck. But committed and pushed fine — the repo half worked, the cluster half hung. And when I inspected a stuck CRD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;acceptedNames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;plural&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;conditions: null&lt;/code&gt;. Not "failing." Not "pending." &lt;em&gt;Never touched.&lt;/em&gt; On a healthy cluster the apiserver stamps a CRD as Established within milliseconds. These sat blank, like forms nobody would ever process.&lt;/p&gt;

&lt;h2&gt;
  
  
  🚨 Symptom Two: A Deployment Frozen in Time
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n kube-system get deploy
NAME       READY   UP-TO-DATE   AVAILABLE   AGE
coredns    0/1     0            0           5h7m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five hours old. &lt;strong&gt;UP-TO-DATE: 0.&lt;/strong&gt; That column being zero means no ReplicaSet was ever created — which means the deployment controller never even &lt;em&gt;looked&lt;/em&gt; at it. And with no DNS pod, everything downstream was queued up to fail alphabetically.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 The Logs Confess Immediately
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;E... leaderelection.go: "Error retrieving lease lock"
  err="leases... is forbidden: User \"system:kube-controller-manager\"
  cannot get resource \"leases\" in namespace \"kube-system\""

E... "Failed to watch" err="csidrivers... is forbidden:
  User \"system:kube-scheduler\" cannot list resource \"csidrivers\""
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sit with that. The &lt;strong&gt;controller-manager&lt;/strong&gt; — the component whose entire job is running every controller in Kubernetes — was forbidden from acquiring its own leader-election lease. No lease, no leadership; no leadership, no controllers; no controllers, no ReplicaSets, no pods, no anything. The scheduler was equally banned from reading the objects it schedules around.&lt;/p&gt;

&lt;p&gt;I enabled authorization, and the first citizens it deported were the government.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 The Part That Made It Weird
&lt;/h2&gt;

&lt;p&gt;The obvious theory: missing ClusterRoleBindings. Checked. &lt;strong&gt;They existed.&lt;/strong&gt; Correct subjects (&lt;code&gt;User: system:kube-controller-manager&lt;/code&gt;), correct roles, roles containing exactly the permissions being denied. Checked the client certificates on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subject=CN=system:kube-controller-manager
subject=CN=system:kube-scheduler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfect CNs. Perfect bindings. Perfect roles. Still forbidden.&lt;/p&gt;

&lt;p&gt;Then the tell. I tried impersonation to test permissions, and even &lt;em&gt;that&lt;/em&gt; failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;selfsubjectaccessreviews is forbidden: User "system:kube-controller-manager"
cannot create resource "selfsubjectaccessreviews"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That permission comes from &lt;code&gt;system:basic-user&lt;/code&gt;, bound to &lt;code&gt;system:authenticated&lt;/code&gt; — the most universal binding in all of Kubernetes. Literally everyone gets it. When &lt;em&gt;that&lt;/em&gt; denies, the story isn't "a binding is missing." The story is &lt;strong&gt;the RBAC authorizer isn't loading policy at all.&lt;/strong&gt; Every rule in the cluster: correct, present, and completely unread.&lt;/p&gt;

&lt;p&gt;Meanwhile &lt;code&gt;kubectl get --raw='/readyz?verbose'&lt;/code&gt; said &lt;code&gt;readyz check passed&lt;/code&gt;, because my admin cert (hello, &lt;code&gt;system:masters&lt;/code&gt;) bypasses authorization entirely. The apiserver was healthy. The apiserver was also the only one allowed in the building. Health checks measure the doorman, not whether anyone can get past him.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧯 The Retreat
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s disable rbac
&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s stop &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s start
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;authorization-mode /var/snap/microk8s/current/args/kube-apiserver
&lt;span class="nt"&gt;--authorization-mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;AlwaysAllow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forbidden errors: gone. Lease: held and renewing. And coredns... still &lt;code&gt;0/1, UP-TO-DATE 0&lt;/code&gt;. Because five hours of objects written into a paralyzed control plane don't spring to life on forgiveness — they'd been created into a void, and some of them (plus a growing chorus of &lt;code&gt;node "msi" not found&lt;/code&gt;) were simply inert. At that point the cluster was 24 hours old and held nothing I loved, so I invoked Episode 6's superpower: reset, rejoin, bootstrap. Twenty minutes. This is what the git repo was &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The rebuilt version skips &lt;code&gt;enable rbac&lt;/code&gt;, and adds the check I'll never skip again: &lt;strong&gt;coredns must reach 1/1 before anything else happens.&lt;/strong&gt; It's the cheapest possible proof that the datastore persists writes and the controllers act on them. One deployment, one honest answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;readyz&lt;/code&gt; measures the apiserver, not the cluster.&lt;/strong&gt; A green apiserver can preside over a completely dead controller plane, especially when your admin cert skips the auth queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UP-TO-DATE: 0&lt;/code&gt; on an old deployment is a five-alarm signal.&lt;/strong&gt; It doesn't mean "unhealthy pods." It means "no controller has ever processed this."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When even &lt;code&gt;system:basic-user&lt;/code&gt; is denied, stop auditing bindings.&lt;/strong&gt; Universal denial means the authorizer itself, not the policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A homelab without RBAC is a defensible trade.&lt;/strong&gt; It governs in-cluster identities, not network exposure; the API server is still cert-authenticated. MicroK8s ships it off by default, and I now understand that as a mercy, not an oversight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Enable it while it's painless" assumes it works.&lt;/strong&gt; Best practice on paper, lockout in my kitchen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is the controller plane actually alive&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl -n kube-system get lease kube-controller-manager -o jsonpath='{.spec.renewTime}'&lt;/code&gt; — must be seconds old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The canary&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl -n kube-system get deploy coredns&lt;/code&gt; → wants &lt;code&gt;1/1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRD ever processed?&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;-o jsonpath='{.status.acceptedNames.kind}'&lt;/code&gt; — empty = never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Current auth mode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grep authorization-mode /var/snap/microk8s/current/args/kube-apiserver&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component identities&lt;/td&gt;
&lt;td&gt;decode &lt;code&gt;client-certificate-data&lt;/code&gt; from &lt;code&gt;/var/snap/microk8s/current/credentials/*.config&lt;/code&gt; → &lt;code&gt;openssl x509 -noout -subject&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;Cluster rebuilt, Flux green, Airflow installing... and then Helm taught me its favorite trick: accepting configuration it has no intention of using, silently, with a straight face. Next episode is about every value I set that did absolutely nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;I gave my cluster a security system. The security system's first act was arresting the janitor, the electrician, and itself. The house stood perfectly secure and perfectly dark.&lt;/p&gt;

&lt;p&gt;Popcorn 🍿, coffee ☕, UPS 🔌, and a laminated card that says &lt;em&gt;check coredns first&lt;/em&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>rbac</category>
      <category>homelab</category>
      <category>microk8s</category>
    </item>
    <item>
      <title>📜 HomeLab Chronicles: Episode 6 - Source of Truth</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 30 Aug 2026 09:45:04 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-6-source-of-truth-50h2</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-6-source-of-truth-50h2</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Last episode a power cut exposed an uncomfortable fact: my cluster's entire memory lived in one SQLite-flavored database, on one laptop, bound to one Wi-Fi address, guarded by one aging battery. Four single points of failure in a trench coat.&lt;/p&gt;

&lt;p&gt;The fix isn't making that database unkillable. The fix is making it &lt;em&gt;unimportant&lt;/em&gt;. If every manifest lives in git and something reconciles the cluster against git continuously, then "the datastore died" stops being a tragedy and becomes a reboot with extra steps.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;Flux&lt;/strong&gt;. Here's the setup, and the four ways I face-planted installing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗂️ The Shape of the Repo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clusters/homelab/
  flux-system/          &amp;lt;- Flux writes this at bootstrap; hands off
  infrastructure.yaml   &amp;lt;- points at infrastructure/
  apps.yaml             &amp;lt;- points at apps/
infrastructure/
  controllers/          &amp;lt;- Longhorn + Envoy Gateway HelmReleases
  configs/              &amp;lt;- GatewayClass, Gateway, StorageClasses
apps/homelab/
  airflow/              &amp;lt;- the actual point of all this
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three Flux Kustomizations, chained: &lt;code&gt;infra-controllers&lt;/code&gt; → &lt;code&gt;infra-configs&lt;/code&gt; → &lt;code&gt;apps&lt;/code&gt;, via &lt;code&gt;dependsOn&lt;/code&gt;. That chaining is not decoration. My &lt;code&gt;GatewayClass&lt;/code&gt; can't exist until Envoy Gateway's CRDs exist, and the CRDs arrive with the controller's Helm chart. Without &lt;code&gt;dependsOn&lt;/code&gt;, Flux sprints ahead, tries to create a GatewayClass into a cluster that's never heard of GatewayClasses, and fails with the enthusiasm of a golden retriever running into a glass door.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dependsOn&lt;/code&gt; plus &lt;code&gt;wait: true&lt;/code&gt; turns that into: install controllers, &lt;em&gt;wait until healthy&lt;/em&gt;, then configs, then apps. Boring. Sequential. Correct. The three great virtues.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔑 Sidequest 1: The Token Bureaucracy
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;flux bootstrap github&lt;/code&gt; needs a GitHub token, and the docs-diving summary is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classic PAT:&lt;/strong&gt; &lt;code&gt;repo&lt;/code&gt; scope. Needed if Flux should &lt;em&gt;create&lt;/em&gt; the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-grained PAT&lt;/strong&gt; (pre-created repo): &lt;strong&gt;Contents&lt;/strong&gt; read/write, &lt;strong&gt;Metadata&lt;/strong&gt; read, and — the one everyone misses — &lt;strong&gt;Administration read/write&lt;/strong&gt;, because Flux installs an SSH &lt;em&gt;deploy key&lt;/em&gt; on the repo, and deploy keys are an admin operation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the nice part: the deploy key is what Flux uses &lt;em&gt;forever after&lt;/em&gt;. The PAT is scaffolding. Bootstrap, confirm &lt;code&gt;flux get kustomizations&lt;/code&gt; goes green, then &lt;strong&gt;revoke the token&lt;/strong&gt;. Give it a 7-day expiry at creation and let it die of natural causes. Also: I renamed the repo mid-project and exactly zero manifests cared — the repo URL lives only in the bootstrap-generated &lt;code&gt;gotk-sync.yaml&lt;/code&gt;. Rename &lt;em&gt;after&lt;/em&gt; bootstrapping though, and Flux keeps pulling the old URL. Ask me how I know I almost knew that.&lt;/p&gt;




&lt;h2&gt;
  
  
  💥 Sidequest 2: The Secret That Must Come First
&lt;/h2&gt;

&lt;p&gt;Some things cannot live in a public-ish git repo: the git deploy key for DAG sync, the Airflow admin password, the JWT signing key. Those get created by hand (well — by script), out-of-band.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;order matters more than I expected.&lt;/strong&gt; My Airflow HelmRelease pulls its password via &lt;code&gt;valuesFrom&lt;/code&gt; with &lt;code&gt;optional: false&lt;/code&gt; — no secret, no install. I bootstrapped first and created secrets second, like a person with hope in their heart. Flux tried to install Airflow, found no secret, retried three times, exhausted its retry budget, and parked the release in a failed state.&lt;/p&gt;

&lt;p&gt;Then I ran the secrets script and hit &lt;em&gt;this&lt;/em&gt; beauty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ Failed to perform remediation: missing target release for rollback:
  cannot remediate failed release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Translation: the &lt;em&gt;first ever&lt;/em&gt; install failed, so there's no previous good release to roll back to, so Flux's remediation has nothing to grab. A plain &lt;code&gt;flux reconcile&lt;/code&gt; won't retry it. You need &lt;code&gt;--force&lt;/code&gt;, or a suspend/resume, or — the actual lesson — &lt;strong&gt;create the secrets before you bootstrap&lt;/strong&gt;. The cluster exists after MicroK8s comes up; Flux doesn't need to be watching yet for you to &lt;code&gt;kubectl create secret&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  📌 Sidequest 3: Pin Your Charts Like You Mean It
&lt;/h2&gt;

&lt;p&gt;First draft, every HelmRelease said &lt;code&gt;version: "1.x"&lt;/code&gt;. Feels flexible. Is flexible. Flexible means "a major chart upgrade can land at 3am while a reconcile loop runs unattended," and for a storage system that's not a version policy, that's a séance.&lt;/p&gt;

&lt;p&gt;So I went and found the &lt;em&gt;real&lt;/em&gt; versions — including the fun discovery that Airflow's chart &lt;code&gt;1.22.0&lt;/code&gt; has &lt;code&gt;appVersion: 3.2.2&lt;/code&gt;, exactly the Airflow I was running. Now the repo says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;airflow:      version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.22.0"&lt;/span&gt;
&lt;span class="na"&gt;longhorn:     version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.12.1"&lt;/span&gt;
&lt;span class="na"&gt;gateway-helm: version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1.9.1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upgrades still happen. They just happen when I commit them, on purpose, with coffee.&lt;/p&gt;




&lt;h2&gt;
  
  
  🫥 Sidequest 4: The Dot of Shame
&lt;/h2&gt;

&lt;p&gt;One evening a reconcile hung with &lt;code&gt;context deadline exceeded&lt;/code&gt; and zero pods appearing. Twenty minutes of cluster spelunking later, I looked at my own shell prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;╭─sunny@sunny ~/github/lab ‹main●›
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That little dot. Uncommitted changes. I'd committed the kustomization entry for a new directory but not the directory. Flux was faithfully applying exactly what I'd pushed, which was a reference to a folder that existed only on my laptop. Flux cannot read your working tree. Flux cannot read your intentions. Flux reads &lt;code&gt;origin/main&lt;/code&gt;, and &lt;code&gt;origin/main&lt;/code&gt; reads whatever you actually pushed.&lt;/p&gt;

&lt;p&gt;The dot was there the whole time. Judging me.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚖️ What Flux Is Not
&lt;/h2&gt;

&lt;p&gt;Worth saying plainly, because I briefly believed otherwise: &lt;strong&gt;Flux is not a backup system.&lt;/strong&gt; It restores what's &lt;em&gt;declared&lt;/em&gt; — Deployments, HelmReleases, routes. It knows nothing about what accumulated at runtime: your Postgres data, DAG run history, Longhorn volume contents, the secrets you created by hand.&lt;/p&gt;

&lt;p&gt;So the full recovery story is a two-piece set: Flux rebuilds the skeleton from git, and last episode's nightly &lt;code&gt;dbctl&lt;/code&gt; snapshots plus (eventually) Longhorn backup targets restore the organs. Either one alone is half a cluster.&lt;/p&gt;

&lt;p&gt;The payoff, though, is real: the rebuild I did mid-way through all this — full &lt;code&gt;microk8s reset&lt;/code&gt;, rejoin the Dell, bootstrap, secrets script — took about twenty minutes, most of it image pulls. Episode 5 me would have wept. Episode 6 me made tea.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dependsOn&lt;/code&gt; is load-bearing.&lt;/strong&gt; CRDs before custom resources, controllers before configs, or Flux runs face-first into glass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets before bootstrap.&lt;/strong&gt; An &lt;code&gt;optional: false&lt;/code&gt; reference plus a missing secret burns the retry budget and needs &lt;code&gt;--force&lt;/code&gt; to resurrect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A first-install failure has no rollback target.&lt;/strong&gt; "Cannot remediate failed release" means delete-and-retry, not reconcile-harder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version ranges are unattended upgrades wearing a disguise.&lt;/strong&gt; Pin, then upgrade on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flux reads the remote, not your working tree.&lt;/strong&gt; The &lt;code&gt;●&lt;/code&gt; in your prompt is a Flux error message you're rendering locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitOps restores manifests, backups restore data.&lt;/strong&gt; You need both, and neither is impressed by the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bootstrap&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flux bootstrap github --owner=X --repository=Y --branch=main --path=clusters/homelab --personal&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fine-grained PAT needs&lt;/td&gt;
&lt;td&gt;Contents RW, Metadata R, Administration RW&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What's reconciling&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flux get kustomizations&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chart status + real errors&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flux get helmreleases -A&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push a change &lt;em&gt;now&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flux reconcile kustomization apps --with-source&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resurrect a dead first install&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flux reconcile helmrelease X -n ns --force&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why is it stuck&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flux logs --kind=HelmRelease --name=X -n ns&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;With the repo in charge, I did the responsible thing and enabled RBAC on the fresh cluster — you know, &lt;em&gt;best practice&lt;/em&gt; — and the control plane immediately locked itself out of its own house. Next episode is short, painful, and has a body count of one coredns.&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Git is now the cluster. The cluster is now a cache. The dot in my prompt is now a trauma response.&lt;/p&gt;

&lt;p&gt;Popcorn 🍿, coffee ☕, UPS 🔌, and a pre-push &lt;code&gt;git status&lt;/code&gt; — the ritual expands.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>fluxcd</category>
      <category>homelab</category>
    </item>
    <item>
      <title>🔦 HomeLab Chronicles: Episode 5 - Blackout</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 30 Aug 2026 09:40:23 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-5-blackout-4kb1</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-5-blackout-4kb1</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Last episode ended on a high. Airflow UI on my phone, logs working, DAGs syncing, and a solemn promise: Dell out of the drawer, Pi out of the other drawer, Cilium out of my dreams and into the cluster.&lt;/p&gt;

&lt;p&gt;Here's the scoreboard. The Dell came out of the drawer — actually joined the cluster, same night I published. The Pi remains in its drawer, thriving, unbothered. Cilium remains a concept I respect from a distance. One out of three. In this economy, that's a win.&lt;/p&gt;

&lt;p&gt;And then at 05:22 the next morning, the power went out. And when it came back, my cluster had amnesia.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get deployments &lt;span class="nt"&gt;-A&lt;/span&gt;
NAMESPACE     NAME                      READY
kube-system   calico-kube-controllers   1/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the whole cluster. Airflow: gone. Longhorn: gone. My will to live: buffering.&lt;/p&gt;




&lt;h2&gt;
  
  
  🕵️ The Crime Scene
&lt;/h2&gt;

&lt;p&gt;First instinct: the datastore got corrupted by the power cut. Reasonable. Dramatic. Wrong — but let's earn that.&lt;/p&gt;

&lt;p&gt;MicroK8s doesn't use etcd; it uses &lt;strong&gt;dqlite&lt;/strong&gt; — Raft-replicated SQLite, living at &lt;code&gt;/var/snap/microk8s/current/var/kubernetes/backend/&lt;/code&gt;. So that's where I went digging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /var/snap/microk8s/current/var/kubernetes/backend/
&lt;span class="nt"&gt;-rw-rw-r--&lt;/span&gt; cluster.crt        Aug 28 21:42
&lt;span class="nt"&gt;-rw-rw----&lt;/span&gt; cluster.key        Aug 28 21:42
&lt;span class="nt"&gt;-rw-rw----&lt;/span&gt; metadata1          Aug 28 21:42
&lt;span class="nt"&gt;-rw-rw----&lt;/span&gt; 0000000000074753-0000000000075264   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read those timestamps again. The database certificates were born at &lt;strong&gt;21:42 the night before&lt;/strong&gt;. The raft index was in the seventy-thousands — a day of idle heartbeats, not weeks of a running cluster. And:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get ns
NAME              STATUS   AGE
default           Active   16h
kube-node-lease   Active   16h
kube-public       Active   16h
kube-system       Active   16h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four namespaces. The four a cluster is &lt;em&gt;born&lt;/em&gt; with. All sixteen hours old.&lt;/p&gt;

&lt;p&gt;The plot twist, delivered by my own filesystem: &lt;strong&gt;nothing was lost in the power cut, because nothing was there.&lt;/strong&gt; When I joined the Dell the previous night, the datastore got reinitialized underneath me. The database I spent the morning mourning was an empty sixteen-hour-old husk. The power cut didn't rob the house. The power cut just knocked on the door of a house that was already empty.&lt;/p&gt;

&lt;p&gt;Grief has five stages. &lt;code&gt;ls -la&lt;/code&gt; has one.&lt;/p&gt;




&lt;h2&gt;
  
  
  📡 So What &lt;em&gt;Did&lt;/em&gt; the Power Cut Do?
&lt;/h2&gt;

&lt;p&gt;Something sneakier. Look at the dqlite logs from 05:22:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dial: Failed to connect to HTTP endpoint: dial tcp 192.168.68.210:19001:
connect: network is unreachable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeated. For a while. Now, the tempting read is "boot race — Wi-Fi wasn't up yet when dqlite started." I almost bought it. But the dqlite process was &lt;strong&gt;PID 1919211&lt;/strong&gt;. Kubelite was PID 2.3 million. You don't get seven-digit PIDs on a fresh boot. That machine had been up for eight hours when this happened.&lt;/p&gt;

&lt;p&gt;So it wasn't a boot race. The network vanished &lt;em&gt;mid-run&lt;/em&gt;. And here's the geometry of my particular disaster:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My nodes are laptops. My router is not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the power cuts, the MSI and the Dell shrug and switch to battery. The router — a small plastic box with the survival instincts of a houseplant — dies instantly. Wi-Fi drops, the interface loses its address, and dqlite, which is bound to &lt;code&gt;192.168.68.210:19001&lt;/code&gt;, suddenly can't reach &lt;em&gt;itself&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And why is it bound to the LAN address at all? Because of the Dell. dqlite binds to &lt;code&gt;127.0.0.1&lt;/code&gt; by default — a fresh single-node MicroK8s is &lt;em&gt;structurally immune&lt;/em&gt; to network outages. The moment a second node joins, dqlite moves to the LAN IP, because the other node has to reach it. Joining the Dell put my datastore on Wi-Fi. The drawer had a curse on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔋 The Villain Nobody Suspects
&lt;/h2&gt;

&lt;p&gt;But wait — a frozen datastore recovers when the network comes back. Frozen isn't corrupted. Where does actual damage come from?&lt;/p&gt;

&lt;p&gt;Follow the battery. Power cut → laptops keep running → outage outlasts the battery → laptop hits 0% → &lt;strong&gt;hard poweroff&lt;/strong&gt;. No filesystem sync. No dqlite checkpoint. Just lights out, mid-write. &lt;em&gt;That&lt;/em&gt; is the dirty shutdown that corrupts a SQLite-family datastore, and it doesn't happen during the outage. It happens hours later, quietly, while you're asleep and feeling safe.&lt;/p&gt;

&lt;p&gt;The fix has absolutely nothing to do with Kubernetes. It's laptop power management:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/UPower/UPower.conf
&lt;/span&gt;&lt;span class="py"&gt;UsePercentageForPolicy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;PercentageLow&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;25&lt;/span&gt;
&lt;span class="py"&gt;PercentageCritical&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;22&lt;/span&gt;
&lt;span class="py"&gt;PercentageAction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;20&lt;/span&gt;
&lt;span class="py"&gt;CriticalPowerAction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PowerOff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean shutdown at 20% instead of a crash at 0%. (UPower insists Low &amp;gt; Critical &amp;gt; Action, so mind the ordering.) Plus telling logind that a closed lid on a server is decor, not a command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/logind.conf.d/99-homelab.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Login]&lt;/span&gt;
&lt;span class="py"&gt;HandleLidSwitch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;span class="py"&gt;HandleLidSwitchExternalPower&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;span class="py"&gt;IdleAction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a longer shutdown grace so dqlite gets to checkpoint instead of eating SIGKILL at the 90-second default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system.conf.d/99-shutdown-timeout.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Manager]&lt;/span&gt;
&lt;span class="py"&gt;DefaultTimeoutStopSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;120s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🧠 The Mental Model I Was Missing
&lt;/h2&gt;

&lt;p&gt;The reason "my deployments are gone" felt so confusing is that three completely different things all get called "the cluster":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Lives in&lt;/th&gt;
&lt;th&gt;Survives a reboot?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Cluster state&lt;/strong&gt; — Deployments, Services, routes&lt;/td&gt;
&lt;td&gt;dqlite, on disk&lt;/td&gt;
&lt;td&gt;Yes, &lt;em&gt;if&lt;/em&gt; dqlite comes up clean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Data&lt;/strong&gt; — Postgres, volumes&lt;/td&gt;
&lt;td&gt;Longhorn, on disk&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pods&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nowhere. Vapor.&lt;/td&gt;
&lt;td&gt;No — and that's &lt;em&gt;correct&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pods are supposed to vanish and be recreated from Deployments. That's not fragility, that's the design. The only question that ever matters after an outage is: &lt;strong&gt;did layer one come back intact?&lt;/strong&gt; Everything else follows from it.&lt;/p&gt;

&lt;p&gt;And ranked by what actually threatens layer one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Battery draining to a dirty poweroff&lt;/strong&gt; ← the real one&lt;/li&gt;
&lt;li&gt;Network loss while running (freezes it; recovers)&lt;/li&gt;
&lt;li&gt;Clean reboot (total non-event)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd been emotionally preparing for #3, mildly worried about #2, and completely blind to #1.&lt;/p&gt;




&lt;h2&gt;
  
  
  💾 And Because Paranoia Is Now a Feature
&lt;/h2&gt;

&lt;p&gt;dqlite has a backup tool nobody talks about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;microk8s dbctl backup /var/backups/microk8s/dqlite-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mine now runs nightly from a systemd timer, and building &lt;em&gt;that&lt;/em&gt; had its own comedy: &lt;code&gt;/snap/bin&lt;/code&gt; isn't on systemd's default PATH, so the naïve version dies at 3am with &lt;code&gt;command not found&lt;/code&gt;, silently, forever — a backup job that never runs but that you &lt;em&gt;believe&lt;/em&gt; exists is strictly worse than no backup job. Absolute paths, artifact-size verification, a free-space guard, 7-day retention with a keep-minimum floor, and &lt;code&gt;chmod 700&lt;/code&gt; on the directory because a full cluster dump contains every Secret you own.&lt;/p&gt;

&lt;p&gt;Oh — and copy them off the node. A backup on the disk that died is a souvenir.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check timestamps before you grieve.&lt;/strong&gt; &lt;code&gt;ls -la&lt;/code&gt; on the datastore told me in ten seconds what I'd theorized about for an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Joining a second node moves dqlite from loopback to the LAN.&lt;/strong&gt; Single-node clusters are accidentally immune to network chaos. Multi-node clusters opt into it. Nobody sends you a consent form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laptops-as-servers invert the failure mode.&lt;/strong&gt; The nodes survive the outage; the &lt;em&gt;router&lt;/em&gt; is the single point of failure, and the battery turns a power cut into a delayed-action dirty shutdown.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pods vanishing is fine. The question is always the datastore.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A silent backup job is worse than none.&lt;/strong&gt; Verify the artifact exists, verify it has bytes, and scream loudly if not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where dqlite lives&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/var/snap/microk8s/current/var/kubernetes/backend/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who dqlite binds to&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo cat .../backend/cluster.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datastore's actual age&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo ls -la .../backend/&lt;/code&gt; — read &lt;code&gt;cluster.crt&lt;/code&gt;'s date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snapshot the datastore&lt;/td&gt;
&lt;td&gt;&lt;code&gt;microk8s dbctl backup &amp;lt;path&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;microk8s stop &amp;amp;&amp;amp; microk8s dbctl restore &amp;lt;path&amp;gt; &amp;amp;&amp;amp; microk8s start&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop battery murder&lt;/td&gt;
&lt;td&gt;UPower &lt;code&gt;PercentageAction=20&lt;/code&gt;, &lt;code&gt;CriticalPowerAction=PowerOff&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lid ≠ off switch&lt;/td&gt;
&lt;td&gt;logind &lt;code&gt;HandleLidSwitch=ignore&lt;/code&gt; + mask sleep targets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;If the cluster can be vaporized by a plastic router and a tired battery, then the cluster can't be the source of truth anymore. Next episode: I put the entire thing in git and make "everything is gone" a twenty-minute inconvenience instead of a personality crisis.&lt;/p&gt;

&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;The power cut was innocent. The router was negligent. The battery was armed. And the database was already empty before any of them showed up.&lt;/p&gt;

&lt;p&gt;Stay tuned. Popcorn 🍿, coffee ☕, and now a UPS 🔌 — the shopping list grows with the wisdom.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>dqlite</category>
      <category>devops</category>
    </item>
    <item>
      <title>🌪️ HomeLab Chronicles: Episode 4 - Turbulence</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Fri, 28 Aug 2026 14:47:14 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-4-turbulence-479m</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-4-turbulence-479m</guid>
      <description>&lt;p&gt;Hey all 👋&lt;br&gt;
Sit tight! Because this is a little lengthy one.&lt;/p&gt;

&lt;p&gt;Last episode I closed by announcing that I would be adding two more nodes and bringing in Cilium. Confident. Specific. A real roadmap.&lt;/p&gt;

&lt;p&gt;The Dell is still in the drawer. The Raspberry Pi is still in a different drawer. Cilium remains a concept I respect from a distance.&lt;/p&gt;

&lt;p&gt;What I did instead was install Apache Airflow on the one node I already had, which I estimated at "an evening." Reader, it was not an evening. It was eight distinct sidequests, two of which were caused by me, three by Airflow 3 quietly renaming things, and one by a YAML field that Kubernetes refuses to let you edit after the fact.&lt;/p&gt;

&lt;p&gt;Here's all of it.&lt;/p&gt;


&lt;h2&gt;
  
  
  🎯 Why Kubernetes At All
&lt;/h2&gt;

&lt;p&gt;The honest alternative was Docker Compose on bare Ubuntu, and for a single-box homelab that's a completely defensible choice -- lower overhead, one file, done in twenty minutes.&lt;/p&gt;

&lt;p&gt;I went with MicroK8s anyway, for three reasons that I still think hold up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Task isolation.&lt;/strong&gt; With &lt;code&gt;KubernetesExecutor&lt;/code&gt;, every task run gets its own ephemeral pod with its own dependencies and its own CPU/memory bounds. A runaway DAG starves itself, not the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No idle workers.&lt;/strong&gt; Pods spawn on demand and die when they're done. No permanent Celery workers sitting there eating 1–2GB of RAM to do nothing at 3am.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production parity.&lt;/strong&gt; This is roughly how Airflow runs on EKS/GKE/Composer. If I'm going to fight something, I'd rather fight the thing people actually run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tuning that follows from that: &lt;code&gt;KubernetesExecutor&lt;/code&gt;, Postgres with persistent storage, and &lt;code&gt;statsd&lt;/code&gt; / &lt;code&gt;redis&lt;/code&gt; / &lt;code&gt;flower&lt;/code&gt; all switched off, since with KubernetesExecutor the Kubernetes API &lt;em&gt;is&lt;/em&gt; the queue and those three are just RAM tax.&lt;/p&gt;


&lt;h2&gt;
  
  
  🕹️ Driving From the Couch
&lt;/h2&gt;

&lt;p&gt;First useful realization: Helm is a client-side tool. It talks to the cluster over the Kubernetes API (port &lt;code&gt;16443&lt;/code&gt; on MicroK8s) using nothing but a kubeconfig. There is no reason to SSH into the server to run it.&lt;/p&gt;

&lt;p&gt;So the server exports its config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And my laptop takes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.kube
ssh msi@192.168.68.210 &lt;span class="s2"&gt;"sudo microk8s config"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.kube/config-microk8s
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;KUBECONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.kube/config-microk8s
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.kube/config-microk8s

kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: if the generated config says &lt;code&gt;127.0.0.1:16443&lt;/code&gt;, swap it for the server's actual LAN IP or you'll spend a confusing minute deploying Airflow to your own laptop.&lt;/p&gt;

&lt;p&gt;From here on, everything in this post runs from my laptop. The server just quietly does as it's told.&lt;/p&gt;




&lt;h2&gt;
  
  
  💥 Sidequest 1: The Migration Job That Wasn't the Problem
&lt;/h2&gt;

&lt;p&gt;First install. Confident. Straight into a wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: INSTALLATION FAILED: failed post-install: resource Job/airflow/airflow-run-airflow-migrations
not ready. status: InProgress, message: Job in progress
context deadline exceeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database migration job timed out. Fine, let's read its logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl logs airflow-run-airflow-migrations -n airflow
error: error from server (NotFound): pods "airflow-run-airflow-migrations" not found in namespace "airflow"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's me being sloppy -- that's a &lt;strong&gt;Job&lt;/strong&gt; name, not a &lt;strong&gt;Pod&lt;/strong&gt; name. &lt;code&gt;kubectl logs&lt;/code&gt; wants a pod. And by the time I asked, the job controller had blown past its backoff limit and deleted the pod anyway. Use the selector instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-n&lt;/span&gt; airflow &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;component&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;run-airflow-migrations &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which returned nothing, because there was nothing left to return. So I went and looked at what everything else was doing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get pods -n airflow -o wide
NAME                                     READY   STATUS                  RESTARTS      AGE
airflow-api-server-669b5665d7-5h87d      0/1     Init:CrashLoopBackOff   6 (98s ago)   9m14s
airflow-dag-processor-6d6ffd87cc-lr9n9   0/2     Pending                 0             9m14s
airflow-postgresql-0                     0/1     Pending                 0             9m14s
airflow-scheduler-7d9648844f-g248q       0/2     Init:CrashLoopBackOff   6 (90s ago)   9m14s
airflow-triggerer-0                      0/2     Pending                 0             9m14s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three pods &lt;code&gt;Pending&lt;/code&gt;, two crash-looping on init. The crash-loopers are just waiting for the database, so they're symptoms. Postgres is the interesting one, and &lt;code&gt;describe&lt;/code&gt; gave it up immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning  FailedScheduling  4m10s  default-scheduler
  0/1 nodes are available: pod has unbound immediate PersistentVolumeClaims.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. &lt;strong&gt;MicroK8s had no default StorageClass.&lt;/strong&gt; No provisioner meant no PVC could ever bind, which meant Postgres could never schedule, which meant the migration job had nothing to migrate against, which meant Helm reported a migration timeout.&lt;/p&gt;

&lt;p&gt;The error I got was four layers downstream of the actual problem. The install didn't fail because of migrations. It failed because I never turned on storage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;microk8s &lt;span class="nb"&gt;enable &lt;/span&gt;hostpath-storage
kubectl get storageclass   &lt;span class="c"&gt;# microk8s-hostpath (default)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson filed: when a Helm install fails on a post-install hook, the hook is almost never the thing that's broken. Go find whatever's &lt;code&gt;Pending&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  🐄 Sidequest 2: Longhorn, and the Snap Path Nobody Mentions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;hostpath-storage&lt;/code&gt; unblocked me, and I could have stopped there. I did not stop there, because "hostpath" means "a directory on the node" and I wanted an actual CSI layer with snapshots and a UI before I started putting data I cared about into this thing.&lt;/p&gt;

&lt;p&gt;Longhorn needs host-level prep first -- it's iSCSI-based, and that has to exist on the machine, not in the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; open-iscsi nfs-common util-linux
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; iscsid
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe iscsi_tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the values file, which has two things in it that matter enormously and are easy to miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;defaultClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;defaultClassReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;reclaimPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Delete&lt;/span&gt;

&lt;span class="na"&gt;defaultSettings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;defaultReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;replicaSoftAntiAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;storageOverProvisioningPercentage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
  &lt;span class="na"&gt;storageMinimalAvailablePercentage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;

&lt;span class="na"&gt;csi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kubeletRootDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/snap/microk8s/common/var/lib/kubelet"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Replica count 1&lt;/strong&gt;, because Longhorn defaults to 3 and on a one-node cluster every single volume will sit there forever politely waiting for two more nodes that are, as established, in drawers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;kubeletRootDir&lt;/code&gt;&lt;/strong&gt;, because MicroK8s is a snap, and snaps don't put kubelet where every Longhorn tutorial on the internet assumes it is. Get this wrong and the CSI plugin comes up looking healthy and then mounts nothing.&lt;/p&gt;

&lt;p&gt;And then Helm couldn't even download the chart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: INSTALLATION FAILED: Get "https://release-assets.githubusercontent.com/...longhorn-1.12.1.tgz":
context deadline exceeded (Client.Timeout exceeded while awaiting headers)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a cluster problem at all -- my laptop's connection to GitHub's release CDN was too slow for Helm's built-in client timeout. The fix is to stop asking Helm to be your downloader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm pull longhorn/longhorn
helm &lt;span class="nb"&gt;install &lt;/span&gt;longhorn ./longhorn-&lt;span class="k"&gt;*&lt;/span&gt;.tgz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; longhorn-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--values&lt;/span&gt; longhorn-values.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; 15m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetch the tarball with a tool whose entire job is fetching things, then install from disk. Works offline too, which is a nice side effect.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚪 Sidequest 3: Two Owners, One CRD
&lt;/h2&gt;

&lt;p&gt;Airflow was up. Now I wanted to reach it from my phone without &lt;code&gt;kubectl port-forward&lt;/code&gt;, which meant an ingress, and I decided on Envoy Gateway -- the CNCF one, driven by the Gateway API instead of a pile of NGINX annotations.&lt;/p&gt;

&lt;p&gt;Step one: install the Gateway API CRDs. Step two: install Envoy Gateway. Both from the docs, both correct in isolation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml

helm &lt;span class="nb"&gt;install &lt;/span&gt;eg oci://docker.io/envoyproxy/gateway-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.2.4 &lt;span class="nt"&gt;--namespace&lt;/span&gt; envoy-gateway-system &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: INSTALLATION FAILED: failed to install CRD crds/gatewayapi-crds.yaml: conflict occurred while
applying object /gatewayclasses.gateway.networking.k8s.io: Apply failed with 3 conflicts:
conflicts with "kubectl-client-side-apply" using apiextensions.k8s.io/v1:
- .metadata.annotations.gateway.networking.k8s.io/bundle-version
- .metadata.annotations.gateway.networking.k8s.io/channel
- .spec.versions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one's genuinely interesting. The Envoy Gateway chart &lt;strong&gt;ships the Gateway API CRDs itself&lt;/strong&gt;. So I installed them twice: once via &lt;code&gt;kubectl apply&lt;/code&gt; (which registers a field manager called &lt;code&gt;kubectl-client-side-apply&lt;/code&gt;), then again via Helm's server-side apply. Kubernetes tracks who owns which field, saw two different managers claiming the same annotations, and refused to guess.&lt;/p&gt;

&lt;p&gt;Two ways out. Let Helm own them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm uninstall eg &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system 2&amp;gt;/dev/null
kubectl delete &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.0/standard-install.yaml
&lt;span class="c"&gt;# reinstall -- the chart brings its own&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or keep mine and tell the chart to stay out of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;eg oci://docker.io/envoyproxy/gateway-helm &lt;span class="nt"&gt;--skip-crds&lt;/span&gt; ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I went with letting Helm own them, so version compatibility stays somebody else's problem on future upgrades. Worth noting the delete nukes any existing Gateway/HTTPRoute objects -- harmless on a fresh cluster, less harmless later.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 The Manifests, And Two Things The Obvious Version Gets Wrong
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;GatewayClass&lt;/code&gt; registers Envoy's controller. The &lt;code&gt;Gateway&lt;/code&gt; defines a listener, and creating it makes Envoy Gateway provision an actual Envoy data-plane Deployment and Service. Standard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GatewayClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;controllerName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/gatewayclass-controller&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-homelab-gateway&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;allowedRoutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;All&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;allowedRoutes.namespaces.from: All&lt;/code&gt; is what lets the HTTPRoute live over in the &lt;code&gt;airflow&lt;/code&gt; namespace and still attach here.&lt;/p&gt;

&lt;p&gt;Now the two traps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap one: you cannot put an IP address in &lt;code&gt;hostnames&lt;/code&gt;.&lt;/strong&gt; The Gateway API &lt;code&gt;Hostname&lt;/code&gt; type explicitly excludes numeric IPs. The CRD's regex is loose enough that &lt;code&gt;192.168.68.210&lt;/code&gt; slips past admission -- each octet looks like a valid DNS label -- so nothing errors and you assume it worked. It is not supported and the matching behaviour isn't guaranteed. (This one bit me later. See sidequest 5.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap two: don't &lt;code&gt;kubectl patch&lt;/code&gt; the Envoy Service.&lt;/strong&gt; Every guide tells you to find the generated service and patch it to NodePort:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl patch svc &lt;span class="nv"&gt;$ENVOY_SVC&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec": {"type": "NodePort", ...}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works, right up until it doesn't. That Service is controller-owned -- Envoy Gateway recomputes it from the Gateway spec on controller restarts, leader-election events, and Gateway updates. Nothing in the cluster knows you wanted NodePort, so the next reconcile quietly resets it to ClusterIP. It'll break at the worst possible moment and look like a networking problem.&lt;/p&gt;

&lt;p&gt;The durable way is an &lt;code&gt;EnvoyProxy&lt;/code&gt; resource wired into the GatewayClass via &lt;code&gt;parametersRef&lt;/code&gt;, which also lets you pin the nodePort declaratively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EnvoyProxy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-proxy-config&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kubernetes&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;envoyService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePort&lt;/span&gt;
        &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;StrategicMerge&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http-8080&lt;/span&gt;
                  &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
                  &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
                  &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
                  &lt;span class="na"&gt;nodePort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the &lt;code&gt;parametersRef&lt;/code&gt; to the GatewayClass pointing at it. Now NodePort 30080 is part of the desired state, and reconciliation &lt;em&gt;enforces&lt;/em&gt; it instead of undoing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🕵️ Sidequest 4: A 500 From A Service That Doesn't Exist
&lt;/h2&gt;

&lt;p&gt;Gateway green. Route applied. Browser: nothing.&lt;/p&gt;

&lt;p&gt;Start at the top and work down. Gateway first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get gateway envoy-homelab-gateway -n envoy-gateway-system -o yaml
status:
  addresses:
  - value: 192.168.68.210
  conditions:
  - reason: Accepted    status: "True"
  - reason: Programmed  status: "True"   message: Address assigned, 1/1 envoy replicas available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfect. Service is NodePort on 30080, Envoy pod 2/2 Running. So the whole ingress layer is fine. Then the route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get httproute airflow-route -n airflow -o yaml
status:
  parents:
  - conditions:
    - reason: Accepted           status: "True"    message: Route is accepted
    - reason: BackendNotFound    status: "False"   message: Service airflow/airflow-webserver not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Service airflow/airflow-webserver not found&lt;/code&gt;. And sure enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl get svc -n airflow
NAME                    TYPE        CLUSTER-IP       PORT(S)
airflow-api-server      ClusterIP   10.152.183.153   8080/TCP
airflow-postgresql      ClusterIP   10.152.183.194   5432/TCP
airflow-postgresql-hl   ClusterIP   None             5432/TCP
airflow-triggerer       ClusterIP   None             8794/TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;airflow-webserver&lt;/code&gt;. &lt;strong&gt;Airflow 3.x deleted the webserver.&lt;/strong&gt; The UI got folded into the API server, so the chart now creates &lt;code&gt;&amp;lt;release&amp;gt;-api-server&lt;/code&gt;. Every tutorial written for Airflow 2 points at a service that no longer exists.&lt;/p&gt;

&lt;p&gt;This is also why the local curl returned a &lt;code&gt;500&lt;/code&gt; rather than a connection error -- Envoy was alive and listening, it just had no valid backend cluster to route to, so it answered every request with an empty 500. The proxy was working perfectly. It had simply been told to forward traffic to a ghost.&lt;/p&gt;

&lt;p&gt;One word changed in &lt;code&gt;backendRefs&lt;/code&gt;, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -v -H "Host: airflow.local" http://localhost:30080/
&amp;lt; HTTP/1.1 200 OK
&amp;lt; server: uvicorn
&amp;lt;!doctype html&amp;gt;
&amp;lt;title&amp;gt;Airflow&amp;lt;/title&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🌐 Sidequest 5: curl Works, The Browser 404s
&lt;/h2&gt;

&lt;p&gt;Victory lap over to the browser on my phone. &lt;code&gt;http://192.168.68.210:30080/&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Looks like there's a problem with this site&lt;/strong&gt; Error code: 404 Not Found&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two problems stacked, and I'd caused both.&lt;/p&gt;

&lt;p&gt;First, I'd been typing &lt;code&gt;192.168.68.21&lt;/code&gt; into the browser. The node is &lt;code&gt;192.168.68.210&lt;/code&gt;. I stared at that for longer than I want to admit.&lt;/p&gt;

&lt;p&gt;Second, and more instructive: &lt;strong&gt;my curl was lying to me.&lt;/strong&gt; Look at what I'd been testing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Host: airflow.local"&lt;/span&gt; http://localhost:30080/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I was manually forcing the Host header to the one value my HTTPRoute matched. The browser sends &lt;code&gt;Host: 192.168.68.210:30080&lt;/code&gt;, my route only had &lt;code&gt;hostnames: ["airflow.local"]&lt;/code&gt;, so Envoy had no matching route and correctly 404'd. Note the failure mode here -- a 404 from the browser is Chrome &lt;em&gt;rendering a real HTTP response from Envoy&lt;/em&gt;, not a connection failure. The packet arrived. The routing table just didn't want it.&lt;/p&gt;

&lt;p&gt;Since the Gateway API won't let me legitimately match an IP anyway (trap one, from earlier), the fix is to stop matching hostnames entirely. An HTTPRoute with no &lt;code&gt;hostnames&lt;/code&gt; field matches any Host header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-homelab-gateway&lt;/span&gt;
      &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-gateway-system&lt;/span&gt;
      &lt;span class="na"&gt;sectionName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PathPrefix&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
      &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;airflow-api-server&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Airflow UI, on my phone, on the couch. Roughly six hours after "this should take an evening."&lt;/p&gt;




&lt;h2&gt;
  
  
  📂 Sidequest 6: Where Do DAGs Even Go?
&lt;/h2&gt;

&lt;p&gt;Next: actually put a DAG in it. I went looking for the DAGs folder on the scheduler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl describe pod -n airflow -l component=scheduler
Volumes:
  config:  ConfigMap
  logs:    EmptyDir
  kube-api-access-7zh2t: Projected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No DAGs volume. At all. Not empty -- absent.&lt;/p&gt;

&lt;p&gt;Because &lt;strong&gt;Airflow 3 also split DAG parsing out of the scheduler&lt;/strong&gt; into its own &lt;code&gt;dag-processor&lt;/code&gt; component. The scheduler genuinely doesn't need DAG files anymore. The volume was on the pod I hadn't looked at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl describe pod airflow-dag-processor-... -n airflow
Mounts:
  /opt/airflow/dags from dags (rw)
Volumes:
  dags:
    Type:       PersistentVolumeClaim
    ClaimName:  airflow-dags
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's two components Airflow 3 renamed or relocated out from under every guide I was reading. Worth internalizing if you're following Airflow 2 material: &lt;strong&gt;webserver → api-server&lt;/strong&gt;, and &lt;strong&gt;DAG parsing → dag-processor&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I could have &lt;code&gt;kubectl cp&lt;/code&gt;'d files into that PVC. I wanted git-sync instead, from a private repo, which needs a read-only deploy key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"airflow-gitsync"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ./airflow-gitsync-key &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;

kubectl create secret generic airflow-git-ssh-key-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; airflow &lt;span class="nt"&gt;--from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;gitSshKey&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./airflow-gitsync-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The secret key &lt;strong&gt;must&lt;/strong&gt; be named &lt;code&gt;gitSshKey&lt;/code&gt; -- that's the exact name the chart looks for. Public half goes on the repo as a deploy key with write access unchecked; git-sync only ever pulls.&lt;/p&gt;

&lt;p&gt;Then in values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;      &lt;span class="c1"&gt;# keep this ON -- git-sync syncs *into* the PVC&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5Gi&lt;/span&gt;
    &lt;span class="na"&gt;accessMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;gitSync&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;git@github.com:&amp;lt;user&amp;gt;/&amp;lt;private-repo&amp;gt;.git"&lt;/span&gt;
    &lt;span class="na"&gt;branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main"&lt;/span&gt;
    &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dags"&lt;/span&gt;
    &lt;span class="na"&gt;sshKeySecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;airflow-git-ssh-key-secret"&lt;/span&gt;
    &lt;span class="na"&gt;wait&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git-sync runs as a sidecar on the dag-processor, worker, and triggerer pods -- not the scheduler, for the reason above.&lt;/p&gt;




&lt;h2&gt;
  
  
  📜 Sidequest 7: Logs That Point At Ghosts
&lt;/h2&gt;

&lt;p&gt;Triggered a DAG. It ran. Clicked the logs tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Could not read served logs: HTTPConnectionPool(host='test-branch-and-bash-option-a-2njq669w', port=8793):
Max retries exceeded ... Failed to resolve 'test-branch-and-bash-option-a-2njq669w'
([Errno -2] Name or service not known)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two episodes ago I spent an evening on a ghost that answered pings. This time it's a hostname that used to be a pod.&lt;/p&gt;

&lt;p&gt;Here's the mechanism: by default the API server fetches task logs by making an HTTP request &lt;strong&gt;directly to the pod that ran the task&lt;/strong&gt;, on port 8793. Under KubernetesExecutor, task pods are deleted the instant they finish. So the API server is trying to resolve the DNS name of something that stopped existing several seconds ago -- and a bare pod name isn't independently resolvable anyway without a governing Service.&lt;/p&gt;

&lt;p&gt;The fix is to stop making it a network problem. Give everything a shared volume and let the API server read the file off disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;persistence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The usual caveat is that this wants &lt;code&gt;ReadWriteMany&lt;/code&gt; and most simple provisioners don't do RWX. On a single-node cluster it doesn't matter -- RWO means "mountable by one &lt;em&gt;node&lt;/em&gt;", not one pod, and every pod here lands on the same node regardless. One-node homelabs get to skip a whole class of storage problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧊 Sidequest 8: The StatefulSet That Wouldn't Budge
&lt;/h2&gt;

&lt;p&gt;Applied that. Helm said no:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: UPGRADE FAILED: server-side apply failed for object airflow/airflow-triggerer
apps/v1, Kind=StatefulSet: StatefulSet.apps "airflow-triggerer" is invalid: spec: Forbidden:
updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template',
'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and
'minReadySeconds' are forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a chart bug, not a values mistake -- a hard Kubernetes rule. &lt;strong&gt;&lt;code&gt;volumeClaimTemplates&lt;/code&gt; on a StatefulSet is immutable after creation.&lt;/strong&gt; The triggerer had been created with its own per-pod log volume. Turning on shared log persistence removes that block entirely. Kubernetes doesn't allow that field to change on a live object, full stop.&lt;/p&gt;

&lt;p&gt;The only move is to delete the object and let Helm rebuild it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete statefulset airflow-triggerer &lt;span class="nt"&gt;-n&lt;/span&gt; airflow
helm upgrade airflow apache-airflow/airflow &lt;span class="nt"&gt;-n&lt;/span&gt; airflow &lt;span class="nt"&gt;--values&lt;/span&gt; airflow-values.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod blips for a few seconds, deferred-task processing pauses, nothing is lost. StatefulSets don't garbage-collect their PVCs, so the old per-pod log volume survives the delete as an orphan -- worth cleaning up once you've confirmed the new setup is healthy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pvc &lt;span class="nt"&gt;-n&lt;/span&gt; airflow          &lt;span class="c"&gt;# look for logs-airflow-triggerer-0&lt;/span&gt;
kubectl delete pvc logs-airflow-triggerer-0 &lt;span class="nt"&gt;-n&lt;/span&gt; airflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🚧 And Then, A Cliff to jump off of
&lt;/h2&gt;

&lt;p&gt;or at-least it looked like one&lt;/p&gt;

&lt;p&gt;Logs working. DAGs syncing. UI reachable from every device in the house. I typed in the admin password, hit enter, and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Bad Request&lt;/strong&gt; The CSRF session token is missing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Created tens of theories hundreds of stories but at a end one patient moment solved it. When i tried longing in after a few seconds it worked, my working hypothesis as of is that the pod to pod auth/authz was not initialised and once it got properly synced i was error free for a few mins. &lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A failing Helm hook is usually a symptom, not a cause.&lt;/strong&gt; The migration job "failed" because Postgres couldn't schedule because there was no StorageClass. Go find whatever's &lt;code&gt;Pending&lt;/code&gt; before you read a single hook log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl logs &amp;lt;job-name&amp;gt;&lt;/code&gt; doesn't work.&lt;/strong&gt; Jobs aren't pods. Use &lt;code&gt;-l component=&amp;lt;label&amp;gt;&lt;/code&gt;, and do it before the controller cleans up the evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installing the same CRDs twice is a real failure mode.&lt;/strong&gt; Field managers collide, and "the docs told me to" doesn't help. Check whether your chart already ships them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a controller generates a resource, don't &lt;code&gt;kubectl patch&lt;/code&gt; it.&lt;/strong&gt; Your change lives until the next reconcile and then vanishes without a trace. Put it in the spec the controller reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curl with a forced &lt;code&gt;-H "Host:"&lt;/code&gt; is not the same test as a browser.&lt;/strong&gt; I proved my stack worked for a request no real client would ever send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Airflow 3 renamed the furniture.&lt;/strong&gt; &lt;code&gt;webserver&lt;/code&gt; → &lt;code&gt;api-server&lt;/code&gt;, DAG parsing → &lt;code&gt;dag-processor&lt;/code&gt;, &lt;code&gt;[scheduler] dag_dir_list_interval&lt;/code&gt; → &lt;code&gt;[dag_processor]&lt;/code&gt;. Most tutorials out there are Airflow 2 and will point you at things that no longer exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;volumeClaimTemplates&lt;/code&gt; is immutable.&lt;/strong&gt; Some Helm upgrades simply require deleting the StatefulSet. That's not a workaround, that's the procedure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-node clusters make RWO vs RWX a non-problem.&lt;/strong&gt; One of the very few advantages of having exactly one node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the &lt;code&gt;status.conditions&lt;/code&gt; block.&lt;/strong&gt; &lt;code&gt;BackendNotFound&lt;/code&gt;, &lt;code&gt;ResolvedRefs: False&lt;/code&gt;, &lt;code&gt;FailedScheduling&lt;/code&gt; -- the Gateway API and Kubernetes both told me exactly what was wrong, in plain English, every single time. I just had to look.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pull kubeconfig off the server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssh user@host "sudo microk8s config" &amp;gt; ~/.kube/config-microk8s&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable storage before anything else&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo microk8s enable hostpath-storage&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find what's actually broken&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl get pods -n airflow -o wide&lt;/code&gt; then &lt;code&gt;describe&lt;/code&gt; the &lt;code&gt;Pending&lt;/code&gt; one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs from a Job's pod&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl logs -n airflow -l component=&amp;lt;label&amp;gt; --tail=100&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check a Gateway is live&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl get gateway &amp;lt;name&amp;gt; -n &amp;lt;ns&amp;gt; -o yaml&lt;/code&gt; → &lt;code&gt;Accepted&lt;/code&gt; + &lt;code&gt;Programmed&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check a route resolved its backend&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl get httproute &amp;lt;name&amp;gt; -n &amp;lt;ns&amp;gt; -o yaml&lt;/code&gt; → &lt;code&gt;ResolvedRefs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find the generated Envoy service&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl get svc -n envoy-gateway-system -l gateway.envoyproxy.io/owning-gateway-name=&amp;lt;gw&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirm the real service names&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl get svc -n airflow&lt;/code&gt; (it's &lt;code&gt;api-server&lt;/code&gt;, not &lt;code&gt;webserver&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create the git-sync key secret&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl create secret generic &amp;lt;name&amp;gt; -n airflow --from-file=gitSshKey=&amp;lt;keyfile&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watch git-sync do its thing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl logs -n airflow -l component=dag-processor -c git-sync&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escape an immutable StatefulSet&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kubectl delete statefulset &amp;lt;name&amp;gt; -n airflow&lt;/code&gt;, then &lt;code&gt;helm upgrade&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;Genuinely, this time -- the Dell and the Pi come out of the drawers. Three nodes. Cilium. The thing I promised you last episode and will keep promising until it becomes true or I run out of drawers.&lt;/p&gt;




&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Eight sidequests. Two self-inflicted, three courtesy of Airflow 3's renaming spree, one Kubernetes immutability rule, one CRD ownership fight, and one CDN that was too slow for Helm's patience.&lt;/p&gt;

&lt;p&gt;The actual Airflow install -- the part I'd budgeted the evening for -- took about four minutes and worked on the first try.&lt;/p&gt;

&lt;p&gt;Stay tuned. Popcorn 🍿 this time optional, but tea is now mandatory &lt;code&gt;🫖&lt;/code&gt;. &lt;/p&gt;

</description>
      <category>airflow</category>
      <category>kubernetes</category>
      <category>helm</category>
      <category>devops</category>
    </item>
    <item>
      <title>🏠 HomeLab Chronicles: Episode 3 - Ghost in the Mesh</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 23 Aug 2026 22:21:33 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-3-ghost-in-the-mesh-1hna</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-3-ghost-in-the-mesh-1hna</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Last episode I said the network was going to need luck instead of the laptop. I'd like to formally apologize to the network. It didn't need luck. It needed an exorcism.&lt;/p&gt;

&lt;p&gt;The task itself was supposed to be the single most boring item on the entire mission list: &lt;strong&gt;Step 2, remote SSH access.&lt;/strong&gt; Generate a key, copy it over, done, never touch a keyboard directly on this machine again. Ten minutes, tops. I've done it a hundred times on a hundred boxes.&lt;/p&gt;

&lt;p&gt;This time it took an evening, a diagram, and a newfound distrust of my own WiFi.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔑 The Part That Was Supposed to Be Boring
&lt;/h2&gt;

&lt;p&gt;Standard stuff, nothing to see here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"msi@homelab"&lt;/span&gt;
ssh-copy-id msi@192.168.68.110
ssh msi@192.168.68.110
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key on the client, key copied to the server, connect, no password, go live my life. That's the whole plan. It fell apart before I even got through it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚫 Connection Refused, Apparently
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh msi@192.168.68.110
&lt;span class="go"&gt;ssh: connect to host 192.168.68.110 port 22: Connection refused
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refused. Not timed out — refused, like the server had looked at me personally and said no. Which felt extra unfair, because when I actually checked the server, it had done nothing wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl status ssh
&lt;span class="go"&gt;● ssh.service - OpenBSD Secure Shell server
     Active: active (running)

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; :22
&lt;span class="go"&gt;LISTEN  0  128  0.0.0.0:22  0.0.0.0:*  users:(("sshd",...))
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sshd&lt;/code&gt; up, listening on every interface, no firewall even configured yet to blame. By every metric the server had access to about itself, it was innocent. And yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  📡 Clue One: A Ping With Main Character Energy
&lt;/h2&gt;

&lt;p&gt;Before spiraling into SSH configs, I did the normal thing and pinged it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ping 192.168.68.110
&lt;span class="go"&gt;64 bytes from 192.168.68.110: icmp_seq=1 ttl=64 time=312 ms
64 bytes from 192.168.68.110: icmp_seq=2 ttl=64 time=298 ms
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It answered! Great, host's up, must be an SSH-level problem — except 300ms on a LAN isn't a number, it's a cry for help. Same-subnet pings on wired gear should be sub-millisecond. 300ms is what you get pinging a different continent, not a laptop three feet away on the same switch. I almost let that slide. I am glad I didn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  🕵️ Clue Two: The ARP Table Doesn't Lie (Unlike Ping, Apparently)
&lt;/h2&gt;

&lt;p&gt;Here's the part nobody warns you about: a ping reply only proves &lt;em&gt;something&lt;/em&gt; answered at that IP. It doesn't prove it was the thing you meant. On a LAN, IP-to-hardware resolution happens over ARP, and your machine caches whatever answer it got first. Wrong cache, wrong destination — for every protocol, ping included.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip neigh show 192.168.68.110
&lt;span class="go"&gt;192.168.68.110 dev eth0 lladdr aa:bb:cc:11:22:33 REACHABLE
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, straight from the server's own mouth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;show enp0s3
&lt;span class="gp"&gt;2: enp0s3: &amp;lt;BROADCAST,MULTICAST,UP,LOWER_UP&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mtu 1500 qdisc fq_codel state UP
&lt;span class="go"&gt;    link/ether aa:bb:cc:44:55:66 brd ff:ff:ff:ff:ff:ff
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aa:bb:cc:11:22:33&lt;/code&gt; versus &lt;code&gt;aa:bb:cc:44:55:66&lt;/code&gt;. Two different pieces of silicon, one IP address, and my laptop had confidently bet on the wrong one.&lt;/p&gt;




&lt;h2&gt;
  
  
  👻 So Who's the Impostor?
&lt;/h2&gt;

&lt;p&gt;Somewhere on my network, something else was also answering to &lt;code&gt;.110&lt;/code&gt;. And once I actually thought about my network topology instead of just interrogating my server, I had a solid suspect.&lt;/p&gt;

&lt;p&gt;Here's the setup: fiber comes into a modem, the modem feeds a TP-Link mesh router, and that mesh router has an extender hanging off it to cover the dead zone at the far end of the apartment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet → Modem → TP-Link Mesh (main node)
                         │
                         ├── extender ── tp-link device, same IP as my server
                         │
                         └── my server (when the mesh feels like it)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mesh systems are supposed to make all of this invisible — one big happy network, roam anywhere, nobody notices the seams. In practice, an extender bolted onto a mesh is still, underneath, its own little hop with its own opinions about who's connected where. My working theory: something on the extender's side ended up holding &lt;code&gt;.110&lt;/code&gt; at the same time my server did, and depending on which hop answered the ARP broadcast first, my laptop's cache latched onto the extender's guest instead of my actual box.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ping worked&lt;/strong&gt; because whatever was squatting on &lt;code&gt;.110&lt;/code&gt; answered ICMP just fine — a real, alive device, just not mine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH got refused, not timed out&lt;/strong&gt;, because a refusal means the SYN reached a live host that slammed the door on port 22 — nothing was listening there. A timeout would've meant something dropping packets silently. A refusal meant I'd reached &lt;em&gt;a&lt;/em&gt; machine. Just not &lt;em&gt;the&lt;/em&gt; machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 300ms latency&lt;/strong&gt; fits the theory too — an extra wireless hop through an extender is exactly the kind of detour that turns sub-millisecond LAN pings into "did this packet go on vacation" pings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing here was actually broken. It was all working exactly as designed. The design just never accounted for me.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ The Fix: Stop Negotiating, Just Move
&lt;/h2&gt;

&lt;p&gt;I could've gone spelunking through the mesh app's client list hunting for the impostor. I did not have that kind of patience left in me. I gave the server a new, boring, unclaimed IP instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/netplan/99-static.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;renderer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networkd&lt;/span&gt;
  &lt;span class="na"&gt;ethernets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enp0s3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;dhcp4&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;dhcp6&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;192.168.68.230/24&lt;/span&gt;
      &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
          &lt;span class="na"&gt;via&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;192.168.68.1&lt;/span&gt;
      &lt;span class="na"&gt;nameservers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;1.1.1.1&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;8.8.8.8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;netplan try
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;netplan try&lt;/code&gt; deserves a fan club. It applies the change immediately and auto-reverts after 120 seconds unless you confirm — extremely relevant when you're one YAML indent away from locking yourself out over the very connection you're using to make the change. Confirmed the box was still reachable, then made it permanent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;netplan apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  ✅ Receipts
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ping 192.168.68.210
&lt;span class="go"&gt;64 bytes from 192.168.68.210: icmp_seq=1 ttl=64 time=0.91 ms

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip neigh show 192.168.68.210
&lt;span class="go"&gt;192.168.68.210 dev eth0 lladdr aa:bb:cc:44:55:66 REACHABLE
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sub-millisecond, and the MAC finally matches the server's actual NIC. Proof, at last, that I was talking to my own hardware.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔑 Back to the Boring Part, Finally
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id msi@192.168.68.210
ssh msi@192.168.68.210
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Straight in. No password. The ten-minute task, delivered roughly one evening and one network diagram late:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/ssh/sshd_config
&lt;span class="c"&gt;# PasswordAuthentication no&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🧠 What This Taught Me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ping proves &lt;em&gt;a&lt;/em&gt; device answered. Not &lt;em&gt;which&lt;/em&gt; device. Don't let it clear a network problem it never actually investigated.&lt;/li&gt;
&lt;li&gt;"Refused" and "timed out" are different diagnoses wearing the same "it didn't work" costume — refused means a live host said no, timeout means something's silently eating your packets. Learn to tell them apart fast.&lt;/li&gt;
&lt;li&gt;Weird LAN latency is a symptom, not background noise. Sub-millisecond is the bar; three hundred milliseconds is a red flag wearing a ping reply as a disguise.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ip neigh&lt;/code&gt; plus &lt;code&gt;ip link&lt;/code&gt; settles an identity crisis in about ten seconds.&lt;/li&gt;
&lt;li&gt;Mesh WiFi plus a bolted-on extender is a surprisingly good recipe for exactly this kind of ghost — worth remembering before blaming the server for anything again.&lt;/li&gt;
&lt;li&gt;Reserve the address at the router level (MAC-based DHCP reservation), not just on the box. A static IP the router doesn't know about is one collision away from becoming somebody else's problem — namely mine, again.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📋 Quick Reference (For Skimmers)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Confirm sshd is listening&lt;/td&gt;
&lt;td&gt;`sudo ss -tlnp \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check the MAC your client has cached&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;ip neigh show &amp;lt;ip&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check a device's real MAC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ip link show &amp;lt;interface&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear a stale ARP entry&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo ip neigh flush dev &amp;lt;interface&amp;gt;&lt;/code&gt; or &lt;code&gt;sudo arp -d &amp;lt;ip&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apply a network change with a safety net&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo netplan try&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copy your public key to a server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssh-copy-id user@host&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🚀 What's Next
&lt;/h2&gt;

&lt;p&gt;Given how well "one server, one IP" just went, the obvious next move is clearly to add more servers and more IPs. So: the Dell Inspiron isn't staying benched after all — it's getting reflashed with the same minimal Ubuntu Server + LXD/LXC combo that worked the first time, except this time with a job description it can actually survive, alongside an old Raspberry Pi I dug out of a drawer. Three nodes, one fledgling cluster.&lt;/p&gt;

&lt;p&gt;And since apparently I haven't suffered enough at the hands of networking this episode, the plan is to bring in &lt;strong&gt;Cilium&lt;/strong&gt; — eBPF-based pod networking, or as I now think of it, round two against the concept of "IP address" as a stable idea — to actually handle traffic across all of it instead of hoping defaults figure it out on their own.&lt;/p&gt;

&lt;p&gt;Given this episode's track record, I would like the record to reflect I have concerns.&lt;/p&gt;




&lt;h2&gt;
  
  
  💬 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Ghost: identified, evicted. SSH: finally, actually working. Trust in my own home WiFi: still recovering.&lt;/p&gt;

&lt;p&gt;Stay tuned. Popcorn 🍿 mandatory this time, not optional.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>ubuntu</category>
      <category>ssh</category>
      <category>networking</category>
    </item>
  </channel>
</rss>
