<?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 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>
    <item>
      <title>🏠 HomeLab Chronicles: Episode 2 - The Frankenstack</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 23 Aug 2026 22:09:05 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-2-the-frankenstack-7n3</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-2-the-frankenstack-7n3</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;Quick recap for anyone walking in mid-movie: last time, I ragequit my garbage collector project (StopLang, RIP, still not sorry), decided Arch Linux would be the homelab OS "because I hate myself but in a fun way," and set out to Frankenstein an old Dell Inspiron into a server.&lt;/p&gt;

&lt;p&gt;Reader. Arch won. Again.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏴‍☠️ Arch, Round Two (And Some Other News)
&lt;/h2&gt;

&lt;p&gt;I would like to say the Arch install went fine and I switched to Ubuntu for Strategic Reasons. That would be a lie. What actually happened was a rerun of the exact 2024 highlight reel from last episode, a bootloader that needed convincing, drivers that needed convincing, and me, at 1am, needing convincing this was a good use of a Tuesday. Turns out "Arch-nemesis" wasn't just a clever episode title, it was a prophecy, and apparently my prophecies about my own life choices self-fulfill on a pretty tight schedule.&lt;/p&gt;

&lt;p&gt;So: Ubuntu Server, similar as my daily driver (debian), same as the "Easy Life Gang" I already confessed to joining. No shame left to lose here.&lt;/p&gt;

&lt;p&gt;While I was in there rethinking the OS, I also did some honest math on what this box actually needed to run; a K8s cluster, several flavors of containers, a metrics stack, all at once — and concluded that "8GB of dreams" was not going to survive contact with that plan either.&lt;/p&gt;

&lt;p&gt;The Dell Inspiron got benched — not buried, though I didn't know that at the time. In its place, for now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU:&lt;/strong&gt; 10th-gen i5, quad-core — not a data center chip, but it can hold a conversation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; 16GB — double the dreams, still cheaper than therapy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU:&lt;/strong&gt; RTX 2050 — currently doing nothing but idling. This is foreshadowing. I don't know for what yet, but it's foreshadowing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage:&lt;/strong&gt; 1TB SSD — room for several Kubernetes clusters and all my feelings about them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still a laptop cosplaying as a server. Just one with a better résumé.&lt;/p&gt;




&lt;h2&gt;
  
  
  🐧 Ubuntu Server, Minimal, No Notes
&lt;/h2&gt;

&lt;p&gt;No desktop environment, no bloat, nothing I'm going to get emotionally attached to this time. Just a prompt and my own poor decisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lscpu | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Model name|CPU&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;s&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
free &lt;span class="nt"&gt;-h&lt;/span&gt;
lsblk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four cores, sixteen gigs, a terabyte of blank canvas. A reasonable enough starting point for whatever's about to happen to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏗️ Building the Stack (or: How I Learned to Stop Worrying and Run Five Container Tools At Once)
&lt;/h2&gt;

&lt;p&gt;Episode 1's Step 3 was, and I quote myself, embarrassingly: &lt;em&gt;"No kubeadm, no fancy tools. Just pure, uncut YAML &amp;amp; CLI pain."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That promise survived for about as long as it took me to remember I have a job and a sleep schedule to protect. Here's what actually landed on the box. (the pure pain plan is postponed for later not scrapped)&lt;/p&gt;

&lt;h3&gt;
  
  
  📦 LXC/LXD — containers that pretend to be VMs
&lt;/h3&gt;

&lt;p&gt;First up, &lt;strong&gt;LXD&lt;/strong&gt; (built on LXC) for anything that wants to feel like a real machine instead of a single disposable process. Docker-style containers are great for "run this one thing," but sometimes you want something that boots, keeps its own init system, and behaves like a small persistent VM without the overhead of actually virtualizing hardware. That's LXD's whole pitch, and it's a genuinely different tool from everything else on this 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;sudo &lt;/span&gt;snap &lt;span class="nb"&gt;install &lt;/span&gt;lxd
&lt;span class="nb"&gt;sudo &lt;/span&gt;lxd init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lxd init&lt;/code&gt; walks you through storage backend and networking choices. I took most of the defaults and made a mental note to regret that later, which is extremely on-brand for this project.&lt;/p&gt;

&lt;h3&gt;
  
  
  🐳 MicroK8s - K8s, but I broke my own rule immediately
&lt;/h3&gt;

&lt;p&gt;This is the "K8s or bust" step from last time. It bust, slightly — I said no fancy tools, then installed the fanciest, most single-command Kubernetes distro Canonical makes:&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;snap &lt;span class="nb"&gt;install &lt;/span&gt;microk8s &lt;span class="nt"&gt;--classic&lt;/span&gt;
microk8s status &lt;span class="nt"&gt;--wait-ready&lt;/span&gt;
microk8s &lt;span class="nb"&gt;enable &lt;/span&gt;dns storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MicroK8s packs an entire lightweight control plane into one snap, which felt like cheating right up until I remembered this laptop has four cores total and would like to keep a couple for itself. Efficiency won. Pride lost. I've made peace with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔧 CRI-O — because the default runtime felt too easy
&lt;/h3&gt;

&lt;p&gt;MicroK8s ships with &lt;code&gt;containerd&lt;/code&gt; as its runtime out of the box, which would've been the sensible thing to just use. Instead I swapped the runtime over to &lt;strong&gt;CRI-O&lt;/strong&gt; — built specifically around Kubernetes' CRI spec rather than being a general-purpose runtime that also happens to speak CRI. Mostly this meant repointing the kubelet at CRI-O's socket instead of containerd's, then losing an evening to why nothing was pulling images anymore, which feels like a homelab rite of passage at this point.&lt;/p&gt;

&lt;p&gt;The honest reason: I wanted to actually understand the CRI boundary instead of pretending containerd is the only runtime that exists. The slightly less honest reason: it was there, and I have a problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  🦭 Podman — for when I don't need the whole orchestra
&lt;/h3&gt;

&lt;p&gt;Not everything needs to go through Kubernetes. Sometimes I just want to run one container, right now, without asking a control plane's permission first. That's &lt;strong&gt;Podman&lt;/strong&gt; — daemonless, rootless by default, and CLI-compatible enough with Docker that &lt;code&gt;alias docker=podman&lt;/code&gt; is basically a rite of passage in the container world.&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 &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; podman
podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It lives alongside the MicroK8s/CRI-O side of the house as the quick-and-dirty option, entirely separate from anything the cluster is doing.&lt;/p&gt;

&lt;h3&gt;
  
  
  📊 Prometheus — watching the chaos happen, in graphs
&lt;/h3&gt;

&lt;p&gt;With four different ways to run a container now active on one laptop, I needed to actually see what was happening instead of vibing based on fan noise. &lt;strong&gt;Prometheus&lt;/strong&gt; scrapes metrics on a schedule and stores them as time series, which beats my previous monitoring strategy of "is it hot, and did it crash."&lt;/p&gt;

&lt;p&gt;Right now it's mostly watching node-level stuff — CPU, memory, disk. Container- and cluster-level scraping is still on the list, and a dashboard on top of all this is next, because collecting metrics nobody's looking at is functionally the same as not collecting them. But that's a problem for a laptop that isn't currently on fire.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤔 Why Not Just Pick One Tool?
&lt;/h2&gt;

&lt;p&gt;Fair question. LXD, Podman, and MicroK8s-with-CRI-O all solve "run an isolated workload" in overlapping ways, and in a real production environment picking one and defending that choice in a design doc would be the entire job.&lt;/p&gt;

&lt;p&gt;This is not a production environment. This is a laptop that used to belong to an 8th-gen i5's better-funded cousin, sitting on a shelf in my apartment. The whole point of a homelab is that nobody's stopping me from running four competing container philosophies at once, just to see how they actually differ from the inside instead of from a comparison blog post. The only incident review here is me, at 1am, asking myself why I did this.&lt;/p&gt;




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

&lt;p&gt;Per the original mission list, Step 2 was supposed to be &lt;strong&gt;remote SSH access&lt;/strong&gt; — "because reaching across the room is so 2020." Should be the easy part. Generate a key, copy it over, done in ten minutes.&lt;/p&gt;

&lt;p&gt;Reader, it was not ten minutes. That's Episode 3.&lt;/p&gt;

&lt;p&gt;(Step 4, the firewall, is still sitting untouched on the list, quietly judging me from a distance. One nemesis at a time.)&lt;/p&gt;




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

&lt;p&gt;Hardware: upgraded. Arch: lost, again, exactly as the title predicted. OS: Ubuntu, minimal, unbothered. Stack: five tools deep and actively questioning my life choices. GPU: still idling, still foreshadowing.&lt;/p&gt;

&lt;p&gt;This episode was somehow both "I installed five pieces of infrastructure" and "I still haven't done the one thing that was supposed to be the easy step." That checks out. That's the HomeLab life, apparently.&lt;/p&gt;

&lt;p&gt;Stay tuned, grab popcorn 🍿, and this time wish the network luck instead of the laptop. It's going to need it.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>ubuntu</category>
      <category>kubernetes</category>
      <category>containers</category>
    </item>
    <item>
      <title>☠️ HomeLab Chronicles: Bash, Bootloaders and BooBoos</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sun, 08 Jun 2025 15:20:53 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-booty-bash-and-bootloaders-4b3i</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-booty-bash-and-bootloaders-4b3i</guid>
      <description>&lt;p&gt;Ahoy, me nerdy hearties! ⚓&lt;/p&gt;

&lt;p&gt;Today, we sail deeper into the digital abyss, where the seas are encrypted and the only map we follow is &lt;code&gt;man&lt;/code&gt; pages. Welcome to &lt;strong&gt;Part 1&lt;/strong&gt; of this chaos: &lt;strong&gt;Getting Arch Installed &amp;amp; Ready for SSH Shenanigans.&lt;/strong&gt; 🧑‍✈️💻&lt;/p&gt;

&lt;p&gt;Let the plundering begin.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪓 Step 1: Flashin’ Be Plunderin’
&lt;/h2&gt;

&lt;p&gt;Get yerself a fresh Arch ISO (ye ol’ treasure map 🗺️) and flash it onto a thumb drive. Use whatever tool ye fancy — &lt;code&gt;dd&lt;/code&gt;, &lt;code&gt;balenaEtcher&lt;/code&gt;, or magic. Doesn’t matter. Just don’t &lt;em&gt;accidentally&lt;/em&gt; nuke your primary drive or the Kraken (a.k.a. your data) shall rise.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚰️ Step 2: Boot Ye Olde Rig
&lt;/h2&gt;

&lt;p&gt;Plug in yer USB, boot that dusty Dell (or whatever sea-worn vessel ye got), and sail straight into the BIOS. Change boot order like a rebellious pirate ignoring port law, and let the Arch ISO take the wheel. 🚢&lt;/p&gt;




&lt;h2&gt;
  
  
  📡 Step 3: Cast the Wi-Fi Net
&lt;/h2&gt;

&lt;p&gt;Time to connect to the seven signals. Enter the &lt;strong&gt;&lt;code&gt;iwctl&lt;/code&gt;&lt;/strong&gt; tavern and speak these sacred incantations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iwctl
station wlan0 scan
station wlan0 get-networks
station wlan0 connect wifi_name
station wlan0 show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you're connected, exit like a ghost (&lt;code&gt;exit&lt;/code&gt;), and if you’re the suspicious type (which you &lt;em&gt;should&lt;/em&gt; be), test your signal with &lt;code&gt;ping archlinux.org -c 3&lt;/code&gt;, if the ping pings, the wind is in our sails. 🌬️🏴‍☠️&lt;/p&gt;




&lt;h2&gt;
  
  
  🔐 Step 4: SSH or Walk the Plank
&lt;/h2&gt;

&lt;p&gt;Install OpenSSH (if it’s not already on board — usually is) &lt;code&gt;pacman -S openssh&lt;/code&gt;.&lt;br&gt;
Set a password for the &lt;strong&gt;root&lt;/strong&gt; user (yes, ye be root now — absolute pirate king 🧑‍✈️). Let’s say: &lt;code&gt;passwd&lt;/code&gt;  choose something like "pass" if you're feeling lucky 🍀.&lt;/p&gt;

&lt;p&gt;Now SSH into the box from yer main ship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@&amp;lt;ip_address&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your OS barks at ye, find an equivalent command or walk the plank.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪚 Step 5: Slicing Up the Drive Like a Mango
&lt;/h2&gt;

&lt;p&gt;Time to swashbuckle that drive:  &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;/boot&lt;/code&gt; — 1GB ⚓&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EFI&lt;/code&gt; — 1GB 🧭&lt;/li&gt;
&lt;li&gt;The rest? LVM party time. (But don’t forget to save a few gigs for future chaos.) Estimated ~125GB for LVM. ⚔️&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We use the old-school blade: &lt;code&gt;fdisk&lt;/code&gt;. Because &lt;strong&gt;real pirates don’t use fancy new TUIs.&lt;/strong&gt;&lt;br&gt;
We're encrypting the &lt;em&gt;big&lt;/em&gt; LVM partition, not the little ones — because we hate pain, but not &lt;em&gt;that&lt;/em&gt; much.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧙 Step 6: Manual Install? Aye, Because Pain is the Best Teacher
&lt;/h2&gt;

&lt;p&gt;Could we run &lt;code&gt;archinstall&lt;/code&gt; and be done? Sure.&lt;br&gt;
&lt;strong&gt;Do we want to?&lt;/strong&gt; HA. No. Where’s the fun in &lt;em&gt;that&lt;/em&gt;, matey?&lt;br&gt;
We do this the hard way. &lt;strong&gt;Because learning builds character (and for once, not craving trauma).&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🔗 Important Runes (You &lt;em&gt;Will&lt;/em&gt; Forget Otherwise)
&lt;/h2&gt;

&lt;p&gt;Trust me, sailor, these are hard-earned battle scars:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🔩 &lt;strong&gt;Load Device Mapper&lt;/strong&gt; before formatting yer volumes:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  modprobe dm_mod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;🪛 &lt;strong&gt;Update mkinitcpio hooks&lt;/strong&gt; (or prepare to scream):
In &lt;code&gt;/etc/mkinitcpio.conf&lt;/code&gt;, modify:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  HOOKS=(base udev autodetect ... encrypt lvm2 filesystems)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;⚙️ &lt;strong&gt;Tweak GRUB so it speaks encryption&lt;/strong&gt;:
In &lt;code&gt;/etc/default/grub&lt;/code&gt;, update:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nv"&gt;GRUB_CMDLINE_LINUX_DEFAULT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"... cryptdevice=/dev/&amp;lt;drive&amp;gt;:&amp;lt;volgroup&amp;gt; ..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;💣 MOST IMPORTANT:
Generate that GRUB config or the boot gods shall smite you:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  grub-mkconfig &lt;span class="nt"&gt;-o&lt;/span&gt; /boot/grub/grub.cfg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you skip this, your bootloader will throw a tantrum and  “not find &lt;code&gt;vmlinuz&lt;/code&gt; or &lt;code&gt;initrd&lt;/code&gt;,” and you’ll be mounting USBs like a panicked sailor chasing his lost rum barrel.&lt;/p&gt;

&lt;p&gt;(Yes, I did that. Yes, it sucked. Yes, I survived. Barely. 🥲)&lt;/p&gt;




&lt;h2&gt;
  
  
  🐳 What’s Next?
&lt;/h2&gt;

&lt;p&gt;Now, Arch is alive and answering pings like a loyal parrot, we’ll move on to the Big Fish™: setting up Kubernetes.&lt;/p&gt;

&lt;p&gt;But for now… pour yourself a drink, pat your encrypted SSD, and take pride. You’ve sailed through the cursed waters of manual Arch install. 🍻💾&lt;/p&gt;

&lt;p&gt;Until next time, mateys.&lt;br&gt;
Keep your drives encrypted and your SSH keys close.&lt;/p&gt;

&lt;p&gt;And remember, the name is Captain Jack Sparrow 🏴‍☠️&lt;/p&gt;

&lt;h1&gt;
  
  
  EDIT (a day later)
&lt;/h1&gt;

&lt;p&gt;Today, while on the bus, I had a thought, To kubernetes I need at least two runners - 1 master and 1 worker node, and guess what, I only have 1 system to experiment with. &lt;br&gt;
I have three options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;buy another machine (Too expensive)&lt;/li&gt;
&lt;li&gt;setup a virtualisation solution to get around it (Considering Proxmox and HyperV for now, could move to another hypervisor as well)&lt;/li&gt;
&lt;li&gt;hybrid setup - with master node on my machine and workers on VMS in cloud (Azure/GCP)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am going ahead with option 2 (Hypervisor) because option 1 is expensive, and option 3 will have too many components, and if something goes wrong, there would be multiple points to check on (n/w concerns me the most). So, for now, I will start with hypervisors and then, after successful deployments and testing on my assembled cluster, probably include hybrid nodes (Azure, GCP, etc).&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>archlinux</category>
    </item>
    <item>
      <title>🏠 HomeLab Chronicles: Episode 1 - Arch-nemesis</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Sat, 07 Jun 2025 21:26:41 +0000</pubDate>
      <link>https://dev.to/snskarora/homelab-chronicles-episode-1-arch-nemesis-f7n</link>
      <guid>https://dev.to/snskarora/homelab-chronicles-episode-1-arch-nemesis-f7n</guid>
      <description>&lt;p&gt;Hey all 👋&lt;/p&gt;

&lt;p&gt;So, I was &lt;em&gt;supposed&lt;/em&gt; to be working on my garbage collector project and the very &lt;em&gt;stoppable&lt;/em&gt; &lt;strong&gt;StopLang&lt;/strong&gt;, but... you know when your brain says &lt;strong&gt;C&lt;/strong&gt;, and your heart screams &lt;strong&gt;"Sea ya later!"&lt;/strong&gt;? Yeah. That.&lt;/p&gt;

&lt;p&gt;So instead, I’m kicking off a new project and a new series I’m calling:&lt;/p&gt;

&lt;blockquote&gt;
&lt;h2&gt;
  
  
  🧪 HomeLab: Because what's better than chaos in your own home?
&lt;/h2&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  💻 The Plan: Frankensteining an Old Laptop
&lt;/h2&gt;

&lt;p&gt;The victim: a brave old &lt;strong&gt;Dell Inspiron&lt;/strong&gt; 🧟‍♂️&lt;br&gt;
Specs:   &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU:&lt;/strong&gt; 8thgen i5 processor (not the freshest fruit in the basket, but it'll do)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory:&lt;/strong&gt; 8GB of dreams&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk Space:&lt;/strong&gt; 256GB of potential&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m going to torch this machine 🔥 with a &lt;strong&gt;Linux distro (no GUI, because who needs fancy pixels?)&lt;/strong&gt; and then just vibe with it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏴‍☠️ Why Arch? Because I hate myself (but in a fun way)
&lt;/h2&gt;

&lt;p&gt;Arch Linux is my pick for this culinary disaster of a home server. Here's why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No bloatware. Just vibes.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Comes bare minimum out of the box (just like me on Mondays).&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Most importantly, &lt;strong&gt;PewDiePie&lt;/strong&gt; uses Arch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;And honestly, it bruised my tech ego that while I’m here running Ubuntu, &lt;em&gt;he’s&lt;/em&gt; flexing with Arch. Not okay.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🧠 Backstory:&lt;br&gt;
I was actually an Arch user until November 2024 (RIP). Then I got a shiny new laptop and realized I no longer had the time (or sanity) to fix bootloader issues for the 100th time or recompile audio drivers like it’s Groundhog Day. So I reluctantly joined the &lt;strong&gt;Ubuntu™ Easy Life Gang&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏗️ The Mission: Milestone 1
&lt;/h2&gt;

&lt;p&gt;Here’s what I’ll be doing in Phase 1 of this HomeLab Madness™:&lt;/p&gt;

&lt;h3&gt;
  
  
  ✅ Step 1: Install Arch (with just enough to not regret it instantly)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Barebones setup only. Think minimal. Think IKEA flat-pack with no manual.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ✅ Step 2: Remote Control, Baby 🚀
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Get remote SSH access, because reaching across the room is &lt;em&gt;so&lt;/em&gt; 2020.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ✅ Step 3: K8s or Bust 🐳
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Set up a &lt;strong&gt;Kubernetes cluster&lt;/strong&gt; from scratch.

&lt;ul&gt;
&lt;li&gt;No &lt;code&gt;kubeadm&lt;/code&gt;, no fancy tools.&lt;/li&gt;
&lt;li&gt;Just pure, uncut YAML &amp;amp; CLI pain.&lt;/li&gt;
&lt;li&gt;Because apparently I like suffering.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ✅ Step 4: 🔥 Wall Building (Digital Only)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Set up &lt;strong&gt;firewalls&lt;/strong&gt; to protect this digital potato.&lt;/li&gt;
&lt;li&gt;But I want more than just &lt;code&gt;ufw&lt;/code&gt; incantations — I’ll aim to build something &lt;em&gt;slightly more usable&lt;/em&gt;, because who wants to memorize commands when you can just push a button?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🚀 After That?
&lt;/h2&gt;

&lt;p&gt;Big plans, my friends.&lt;/p&gt;

&lt;p&gt;I’ll be building a platform where I can deploy workloads on this cluster using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Docker image URL (or a local name if I’m feeling nostalgic),&lt;/li&gt;
&lt;li&gt;A few config options to tweak runtime behaviour &amp;amp; exposure,&lt;/li&gt;
&lt;li&gt;And some classification magic to keep things sorted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Basically: &lt;strong&gt;"Give me a container, and I shall give it a home."&lt;/strong&gt;&lt;/p&gt;




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

&lt;p&gt;This series is going to be weird, wild, and probably involve more swearing at YAML than anyone should legally be allowed to do. But hey — that’s (I guess) the HomeLab life.&lt;/p&gt;

&lt;p&gt;Stay tuned, grab popcorn 🍿, and wish my Dell laptop luck. It’s going to need it.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>archlinux</category>
    </item>
    <item>
      <title>Birth of StopLang – A Useless Language With a Very Specific Purpose</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Thu, 05 Jun 2025 19:49:32 +0000</pubDate>
      <link>https://dev.to/snskarora/birth-of-stoplang-a-useless-language-with-a-very-specific-purpose-d2j</link>
      <guid>https://dev.to/snskarora/birth-of-stoplang-a-useless-language-with-a-very-specific-purpose-d2j</guid>
      <description>&lt;p&gt;So, I wanted to implement a &lt;strong&gt;garbage collector&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Cool idea, right? Memory management, pointer wizardry, existential dread—the whole buffet.&lt;/p&gt;

&lt;p&gt;But then came the &lt;em&gt;realization™️&lt;/em&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Uhhh, how the hell do I even &lt;strong&gt;test&lt;/strong&gt; this thing?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can’t really test a garbage collector in a vacuum (though that &lt;em&gt;would&lt;/em&gt; collect garbage nicely). I needed some code, some variables, some actual garbage. Something to allocate memory, throw things around, and see if the collector actually does its job—or rage quits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Option A: Plug it into an existing language
&lt;/h2&gt;

&lt;p&gt;Like a &lt;em&gt;real&lt;/em&gt; adult. Take some interpreter written in C, rip out its memory management, and bolt mine in.&lt;/p&gt;

&lt;p&gt;But nah.&lt;br&gt;&lt;br&gt;
That way lies integration hell.&lt;br&gt;&lt;br&gt;
Do I look like someone who enjoys linker errors and segfaults from code I didn’t write? (No. No, I do not.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Option B: Just build my own language
&lt;/h2&gt;

&lt;p&gt;Like an &lt;em&gt;unhinged&lt;/em&gt; adult.&lt;br&gt;&lt;br&gt;
So that’s what I did.&lt;/p&gt;




&lt;p&gt;Okay so here’s the tea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🛠️ &lt;strong&gt;It’ll be compiled&lt;/strong&gt;, because I want my errors during dev time, not as jump scares during runtime.
&lt;/li&gt;
&lt;li&gt;⚙️ &lt;strong&gt;It’s in C&lt;/strong&gt;, because:

&lt;ul&gt;
&lt;li&gt;C++ is &lt;em&gt;too&lt;/em&gt; friendly (a little too comfy, not enough pain).&lt;/li&gt;
&lt;li&gt;Assembly is &lt;em&gt;too&lt;/em&gt; sadistic (I still like my sanity).&lt;/li&gt;
&lt;li&gt;C is that spicy sweet spot of power and pain.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We're going full DIY on this one. Think of it as a language that’s just smart enough to test our GC, but dumb enough that we don’t have to spend years writing a parser.&lt;/p&gt;




&lt;p&gt;Now, we needed a name. Something iconic. Something that tells you what this language is &lt;em&gt;really&lt;/em&gt; about.&lt;/p&gt;

&lt;p&gt;And so, I give you:  &lt;/p&gt;

&lt;h3&gt;
  
  
  🛑 &lt;code&gt;StopLang&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It’s the &lt;strong&gt;opposite of Go&lt;/strong&gt;. It doesn’t want to “go” anywhere.
&lt;/li&gt;
&lt;li&gt;It’s &lt;strong&gt;useless&lt;/strong&gt;, and &lt;em&gt;proudly&lt;/em&gt; so.
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s not fast.&lt;br&gt;&lt;br&gt;
It’s not smart.&lt;br&gt;&lt;br&gt;
It’s barely a language.&lt;br&gt;&lt;br&gt;
But it’s &lt;em&gt;ours&lt;/em&gt;, and it will be &lt;em&gt;just good enough&lt;/em&gt; to build and test garbage collectors.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Bonus: It will be easier to read than Brainfuck, and more coherent than Brainrot. (Which honestly sounds like medical conditions at this point.)&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  📌 What’s Coming Next?
&lt;/h2&gt;

&lt;p&gt;This blog would turn into a research paper if I tried to cover &lt;em&gt;everything&lt;/em&gt; here. So we’re breaking it down:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;This post:&lt;/strong&gt; Why StopLang exists and how I got here (hello chaos)
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next post(s):&lt;/strong&gt; Building the core of StopLang—basic syntax, data structures, compiler setup, maybe a sad little REPL
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then:&lt;/strong&gt; Plugging in our garbage collectors and watching them either work… or work once and then segfault spectacularly&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;If you’ve read this far, bless your soul.&lt;br&gt;&lt;br&gt;
Thanks for bearing with the puns, the rambling, and the “what if I just wrote a language for no reason?” energy.&lt;/p&gt;

&lt;p&gt;Stay tuned, fellow code gremlins.&lt;br&gt;&lt;br&gt;
More nonsense incoming soon. 😈🧹&lt;/p&gt;

</description>
      <category>c</category>
      <category>newlanguage</category>
      <category>garbagecollector</category>
      <category>reinventthewheel</category>
    </item>
    <item>
      <title>Building a Garbage Collector in C: Because Who Needs Sleep Anyway?</title>
      <dc:creator>sanskar arora</dc:creator>
      <pubDate>Mon, 02 Jun 2025 22:12:34 +0000</pubDate>
      <link>https://dev.to/snskarora/building-a-garbage-collector-in-c-because-who-needs-sleep-anyway-mni</link>
      <guid>https://dev.to/snskarora/building-a-garbage-collector-in-c-because-who-needs-sleep-anyway-mni</guid>
      <description>&lt;p&gt;Let’s be honest: most people’s idea of a good time doesn’t involve poking around in the underbelly of C, trying to make memory management less… well, terrifying. But here I am, rolling up my sleeves and diving into the world of garbage collectors. Why? Because understanding how the engine works under the bonnet is way more fun than actually driving the car. (And also, I’m only a little cracked—not batshit crazy, so no assembly for now.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why C? Why Not?
&lt;/h2&gt;

&lt;p&gt;You might ask, “Why C? Why not something friendlier, like Python, or at least C++ with its fancy destructors?” Well, I want to keep things low-level. Real low. Like, “I can see the bits and bytes from here” low. Plus, C gives you the delightful opportunity to shoot yourself in the foot with malloc and free, which is basically a rite of passage for any programmer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Accept Your Fate
&lt;/h2&gt;

&lt;p&gt;Before you even write a line of code, accept that you will spend hours debugging segfaults, chasing dangling pointers, and questioning your life choices. This is normal. This is growth. This is C.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Plan Your Collector
&lt;/h2&gt;

&lt;p&gt;There are many flavours of garbage collectors—reference counting, mark-and-sweep, generational… I’ll probably start with something simple, like reference counting and end with mark-and-sweep, because I like the idea of pretending to be a janitor for my code. (“Sweep the heap, Daniel-san!”) And yes, I am only going to do 2 types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Write Some Code, Break Some Code
&lt;/h2&gt;

&lt;p&gt;Here’s where the real fun begins. You’ll write a function to allocate memory, another to mark what’s still in use, and a third to sweep away the rest. Inevitably, you’ll forget to mark something, and your program will crash spectacularly. Don’t worry—it’s all part of the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Document the Madness
&lt;/h2&gt;

&lt;p&gt;As I go, I’ll document every step, every bug, and every existential crisis. Because if you can’t laugh at your own memory leaks, what can you laugh at?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Journey Continues…
&lt;/h2&gt;

&lt;p&gt;So, join me as I attempt to tame the wild beast that is manual memory management in C. Will I succeed? Will I lose my mind? Will I finally understand how the engine works beneath the bonnet? Only time—and a lot of printf debugging—will tell.&lt;/p&gt;

&lt;p&gt;Stay tuned, and remember: in C, the real garbage collector… is you.&lt;/p&gt;

</description>
      <category>c</category>
      <category>garbagecollector</category>
      <category>reinventthewheel</category>
    </item>
  </channel>
</rss>
