<?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: david</title>
    <description>The latest articles on DEV Community by david (@dwoitzik).</description>
    <link>https://dev.to/dwoitzik</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%2F3933869%2F1fb8aa5b-2239-46a7-bf78-b5352809883c.png</url>
      <title>DEV Community: david</title>
      <link>https://dev.to/dwoitzik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dwoitzik"/>
    <language>en</language>
    <item>
      <title>Velero Backs Up to the Cluster It's Backing Up: Breaking the Backup Circularity</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 19 Oct 2026 12:38:54 +0000</pubDate>
      <link>https://dev.to/dwoitzik/velero-backs-up-to-the-cluster-its-backing-up-breaking-the-backup-circularity-4f35</link>
      <guid>https://dev.to/dwoitzik/velero-backs-up-to-the-cluster-its-backing-up-breaking-the-backup-circularity-4f35</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/velero-backup-circularity-garage-r2-offsite/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The circularity: &lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Velero&lt;/a&gt; backs up into Garage S3, which runs inside the Kubernetes cluster it's backing up. Cluster lost → Garage PVC lost → Velero backup bucket gone → nothing to restore from.&lt;/p&gt;

&lt;p&gt;This is the backup equivalent of storing your offsite backup key in the safe you're backing up. The offsite target exists in config but isn't actually reachable — and you don't realize the gap until you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Problem Stack
&lt;/h2&gt;

&lt;p&gt;Two issues compound into structural fragility:&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 1: Backup Circularity
&lt;/h3&gt;

&lt;p&gt;Velero's backup storage location points to Garage, an S3-compatible object store running as a StatefulSet inside the cluster. Every Velero backup — daily snapshots of Vault, Authelia, databases, ArgoCD configs — lands in a bucket that depends on the cluster surviving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cluster lost → Garage PVC lost → Velero backup bucket gone → nothing to restore from
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PBS backs up the VM/LXC disks to a &lt;a href="https://dev.to/go/4vxwLpY"&gt;Seagate 2TB External HDD*&lt;/a&gt;, but PBS runs on the same physical host — a &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt;. Simultaneous loss of host + USB HDD means total unrecoverability at the VM layer. Velero was the last safety net — except it was circular.&lt;/p&gt;

&lt;h3&gt;
  
  
  Problem 2: Torn-State Hazard
&lt;/h3&gt;

&lt;p&gt;Two PVCs with different storage backends, different failure modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PVC&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;garage-meta&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;local-path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;k3s node&lt;/td&gt;
&lt;td&gt;Node-pinned, survives if node survives&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;garage-data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NFS (&lt;code&gt;nfs-client&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;NFS LXC&lt;/td&gt;
&lt;td&gt;Survives if NFS LXC survives&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 2026-07-12, an unclean Garage shutdown (OOM kill) left &lt;code&gt;db.sqlite&lt;/code&gt; corrupted — &lt;a href="https://dev.to/blog/nfs-vs-local-path-sqlite-trap/"&gt;the same NFS/SQLite locking incompatibility&lt;/a&gt; that eventually forced every embedded database off NFS. The metadata — SQLite, bucket/key ownership, access grants — and the actual object data were on different volumes with different rollback fates. A partial restore or node loss would leave one without the other, with no clean way to reconcile them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Options
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Option A: Consolidate meta + data on one volume
&lt;/h3&gt;

&lt;p&gt;Move &lt;code&gt;garage-meta&lt;/code&gt; from &lt;code&gt;local-path&lt;/code&gt; to NFS (same backend as &lt;code&gt;garage-data&lt;/code&gt;). Both PVCs on the same storage class → same snapshot/restore fate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict&lt;/strong&gt;: Fixes torn-state but not circularity. NFS for SQLite is proven fragile — the 07-12 corruption happened on NFS-backed data. Moving data to &lt;code&gt;local-path&lt;/code&gt; requires hardware you don't have yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B: External off-cluster target (Cloudflare R2)
&lt;/h3&gt;

&lt;p&gt;Activate the existing &lt;code&gt;r2-offsite&lt;/code&gt; backup storage location. Velero dual-targets: in-cluster Garage for speed + off-cluster Cloudflare R2 for bootstrap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cluster lost → Garage gone → Velero still has offsite copy on R2
Bootstrap: rebuild cluster → ArgoCD → Velero → restore from R2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Cost&lt;/strong&gt;: ~$0.015/GB/month on R2 (egress free). At current backup size (~5-10Gi), negligible. &lt;strong&gt;Risk&lt;/strong&gt;: Near-zero — additive only, no existing backup path changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict&lt;/strong&gt;: Directly solves circularity. The existing R2 config is ready — needs credentials and a "go".&lt;/p&gt;

&lt;h3&gt;
  
  
  Option C: PBS as the primary recovery layer
&lt;/h3&gt;

&lt;p&gt;Lean into PBS as the from-scratch recovery path. PBS already backs up all VMs/LXCs (including the k3s VMs that carry Garage's data). Velero becomes a convenience layer for single-app restore, not the bootstrap path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict&lt;/strong&gt;: Already the real bootstrap path (DR Tier 1-4). Velero is Tier 5, only if Garage survived. PBS is the actual safety net. The circularity is a Velero-level problem, not a PBS-level one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option D: Combine B + C (what I chose)
&lt;/h3&gt;

&lt;p&gt;Activate R2 offsite for Velero (breaks circularity) AND acknowledge PBS as the primary VM-level recovery layer. Velero becomes the single-app/namespace convenience tool with a real offsite target.&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;What it covers&lt;/th&gt;
&lt;th&gt;Recovery path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PBS&lt;/td&gt;
&lt;td&gt;VM/LXC disks, full host recovery&lt;/td&gt;
&lt;td&gt;Restore VMs from PBS → cluster up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Velero + R2&lt;/td&gt;
&lt;td&gt;App-level config, secrets, databases&lt;/td&gt;
&lt;td&gt;ArgoCD → Velero → restore from R2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Garage rebuild&lt;/td&gt;
&lt;td&gt;If PVCs are lost entirely&lt;/td&gt;
&lt;td&gt;Terraform re-creates buckets, ESO re-seeds keys&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Verdict&lt;/strong&gt;: Covers both problems. R2 breaks circularity (zero risk, additive). PBS handles VM-level recovery (already there). Garage rebuild from scratch is the fallback if PVCs are lost — better than a partial restore that could leave metadata/data inconsistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Recovery Model
&lt;/h2&gt;

&lt;p&gt;Three independent layers, each covering a different failure scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scenario                          → Recovery path
─────────────────────────────────────────────────────
Single app crash                  → Kubernetes restart (self-heals)
Single PVC lost                   → Velero restore from R2 offsite
Full cluster lost (host survives) → PBS restores k3s VMs → Velero from R2
Host + USB HDD lost               → Insurance (nothing saves this)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: no single layer has to be perfect. Each layer covers the failure modes the layer below misses. Garage's torn-state problem becomes irrelevant when the recovery procedure is "rebuild from scratch" rather than "restore from backup."&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation (Zero Downtime)
&lt;/h2&gt;

&lt;p&gt;The R2 activation was purely additive — no existing backup path changed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create Cloudflare R2 bucket (free tier)&lt;/li&gt;
&lt;li&gt;Populate &lt;code&gt;r2-backuplocation.yml&lt;/code&gt; with real R2 credentials via External Secrets Operator&lt;/li&gt;
&lt;li&gt;Apply the &lt;code&gt;daily-offsite&lt;/code&gt; Velero Schedule (already committed)&lt;/li&gt;
&lt;li&gt;Run one test backup, verify with &lt;code&gt;velero backup describe&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Confirm &lt;code&gt;PodVolumeBackup&lt;/code&gt; entries present for PVCs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The in-cluster Garage target stays. Velero now writes to both: Garage for fast local restores, R2 for bootstrap-from-scratch. If Garage is gone, R2 has everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Change
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Implement R2 sooner.&lt;/strong&gt; The config existed for weeks before activation. The circularity was a known risk — the "I'll do it later" mindset is what creates disaster recovery gaps.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add a Velero backup age alert.&lt;/strong&gt; Alert when the most recent successful backup is older than 48 hours. If backups silently stop, you want to know before you need one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test the full recovery path.&lt;/strong&gt; PBS restore of k3s VMs → cluster boot → ArgoCD reconciliation → Velero restore from R2. An untested recovery path is a theory, not a plan.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Backup circularity is the most common structural flaw in self-hosted Kubernetes: the tool that protects you depends on the thing you're protecting. The fix is always the same — add an independent offsite layer. Cloudflare R2 costs nothing at homelab scale, and the R2 egress-free model means you're not penalized for actually using the restore. The hardest part isn't the implementation. It's admitting your current backup doesn't survive the scenario you built it for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; For understanding the full recovery architecture, &lt;a href="https://dev.to/go/4aUtkCs"&gt;Designing Data-Intensive Applications by Martin Kleppmann*&lt;/a&gt; covers replication, backup strategies, and the failure modes that make torn-state hazards so dangerous. The Velero circularity is a specific case of the general problem Kleppmann describes: systems that depend on their own recovery path.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>backup</category>
      <category>velero</category>
      <category>homelab</category>
    </item>
    <item>
      <title>The DNS Drift That Wasn't DHCP: How Proxmox Cloud-Init Silently Injects Search Domains</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 16 Oct 2026 09:41:18 +0000</pubDate>
      <link>https://dev.to/dwoitzik/the-dns-drift-that-wasnt-dhcp-how-proxmox-cloud-init-silently-injects-search-domains-1496</link>
      <guid>https://dev.to/dwoitzik/the-dns-drift-that-wasnt-dhcp-how-proxmox-cloud-init-silently-injects-search-domains-1496</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/proxmox-cloudinit-dns-search-domain-drift/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every DNS lookup from every pod in my k3s cluster was appending &lt;code&gt;home.lan&lt;/code&gt; to the query. External lookups like &lt;code&gt;registry-1.docker.io&lt;/code&gt; became &lt;code&gt;registry-1.docker.io.home.lan&lt;/code&gt;, failed locally, recursed all the way out, and added ~341ms of latency to every image pull, every API call, every webhook — traffic that should have gone straight to Unbound for recursive resolution, not round-tripped through a phantom search domain. I assumed DHCP was the source, wrote a Terraform fix, and watched the plan show zero diff against live state.&lt;/p&gt;

&lt;p&gt;The real source was Proxmox's cloud-init generator — three layers above the DHCP server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;Prometheus showed p99 DNS latency at 341ms for external queries. Local queries (services within the cluster) resolved in under 1ms. The gap was consistent across all three k3s nodes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="c"&gt;# PromQL showing the split&lt;/span&gt;
&lt;span class="nb"&gt;quantile_over_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns_request_duration_seconds&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;!~&lt;/span&gt;&lt;span class="s2"&gt;".*cluster.local"&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="mi"&gt;5m&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c"&gt;# → ~341ms&lt;/span&gt;

&lt;span class="nb"&gt;quantile_over_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dns_request_duration_seconds&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;=~&lt;/span&gt;&lt;span class="s2"&gt;".*cluster.local"&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="mi"&gt;5m&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c"&gt;# → ~0.8ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kubelet's DNS policy appends the node's search domain to every pod's &lt;code&gt;/etc/resolv.conf&lt;/code&gt;. If the node has &lt;code&gt;search home.lan&lt;/code&gt;, every pod inherits it. With the default &lt;code&gt;ndots:5&lt;/code&gt;, any name with fewer than 5 dots gets the search domain appended first — meaning almost everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong Hypothesis: DHCP
&lt;/h2&gt;

&lt;p&gt;My first guess was the RouterOS DHCP server. The network was configured via Terraform, but I'd never explicitly set the domain field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before fix — no domain attribute at all&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ip_dhcp_server_network"&lt;/span&gt; &lt;span class="s2"&gt;"vlan_networks"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;homelab_vlans&lt;/span&gt;
  &lt;span class="nx"&gt;address&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.${each.value}.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;gateway&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.${each.value}.1"&lt;/span&gt;
  &lt;span class="nx"&gt;dns_server&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.20.5"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding &lt;code&gt;domain = ""&lt;/code&gt; should force Terraform to detect and correct any drift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ip_dhcp_server_network"&lt;/span&gt; &lt;span class="s2"&gt;"vlan_networks"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;homelab_vlans&lt;/span&gt;
  &lt;span class="nx"&gt;address&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.${each.value}.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;gateway&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.${each.value}.1"&lt;/span&gt;
  &lt;span class="nx"&gt;dns_server&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.20.5"&lt;/span&gt;
  &lt;span class="nx"&gt;domain&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;  &lt;span class="c1"&gt;# explicitly empty — should fix drift&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan showed &lt;strong&gt;zero changes&lt;/strong&gt;. Live state already matched &lt;code&gt;domain = ""&lt;/code&gt;. DHCP was never the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live Verification
&lt;/h2&gt;

&lt;p&gt;Confirmed via the RouterOS REST API:&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="c"&gt;# Check all DHCP server config paths&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://router.lan/rest/ip/dhcp-server"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[].domain'&lt;/span&gt;
&lt;span class="c"&gt;# → all null&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://router.lan/rest/ip/dhcp-server/network"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[].domain'&lt;/span&gt;
&lt;span class="c"&gt;# → all null&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://router.lan/rest/ip/dns"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.domain'&lt;/span&gt;
&lt;span class="c"&gt;# → null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The RouterOS DHCP server had no domain/search config anywhere. &lt;code&gt;domain = ""&lt;/code&gt; matched live state because the live state genuinely was empty. DHCP was never handing out &lt;code&gt;home.lan&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Source: Proxmox Cloud-Init
&lt;/h2&gt;

&lt;p&gt;On the k3s nodes themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;networkctl status &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="s1"&gt;'.DNS'&lt;/span&gt;
&lt;span class="c"&gt;# → {"SearchDomains": ["home.lan"], "ConfigSource": "static"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;ConfigSource: static&lt;/strong&gt; — not DHCP. The search domain was baked into the node's netplan config, generated by Proxmox's cloud-init writer.&lt;/p&gt;

&lt;p&gt;When you create a VM in Proxmox with Terraform and leave &lt;code&gt;initialization.dns.domain&lt;/code&gt; unset, Proxmox falls back to the PVE node's own configured search domain:&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="c"&gt;# On the Proxmox host&lt;/span&gt;
pvesh get /nodes/pve-mgmt-01/dns
&lt;span class="c"&gt;# → search: home.lan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Proxmox's cloud-init generator reads the PVE node's DNS config and writes it into the VM's &lt;code&gt;/etc/netplan/50-cloud-init.yaml&lt;/code&gt;. Every k3s VM got &lt;code&gt;home.lan&lt;/code&gt; as its search domain — not from DHCP, but from the hypervisor itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is a Trap
&lt;/h2&gt;

&lt;p&gt;The trap is the layering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Proxmox PVE node DNS config
    ↓ (cloud-init fallback)
VM netplan search domain
    ↓ (kubelet DNS policy)
Pod /etc/resolv.conf search domain
    ↓ (ndots:5)
Every DNS lookup appends .home.lan
    ↓
~341ms latency on every external query
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the debugging trap: &lt;code&gt;terraform plan&lt;/code&gt; showed zero diff on the DHCP resource because DHCP wasn't the source. A Terraform-native investigation would have concluded "no drift detected" and moved on. The real fix required:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clearing the PVE node's default: &lt;code&gt;pvesh set /nodes/pve-mgmt-01/dns -search ""&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Regenerating cloud-init on each k3s VM&lt;/li&gt;
&lt;li&gt;Declaring &lt;code&gt;domain = ""&lt;/code&gt; in the Proxmox VM's &lt;code&gt;initialization.dns&lt;/code&gt; block&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1 and 2 are live changes to etcd-member VMs — no Terraform resource models this. Step 3 declares intent for future VMs but has no effect on already-provisioned ones (the &lt;code&gt;dns&lt;/code&gt; block is in &lt;code&gt;lifecycle.ignore_changes&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Terraform Fix (Future VMs)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/stacks/proxmox/vm.tf&lt;/span&gt;
&lt;span class="nx"&gt;initialization&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dns&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;domain&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;  &lt;span class="c1"&gt;# explicitly unset — prevents PVE node fallback&lt;/span&gt;
    &lt;span class="nx"&gt;servers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.20.5"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ensures new VMs don't inherit the PVE node's search domain. Existing VMs need the manual cloud-init regen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. DNS config has multiple sources.&lt;/strong&gt; DHCP is the obvious one, but cloud-init, node-level config, and kubelet policy all contribute. Check all layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;terraform plan&lt;/code&gt; zero-diff doesn't mean no drift.&lt;/strong&gt; It means the specific resource you checked matches state. The drift might be in a different layer entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;networkctl status --json&lt;/code&gt; is your friend.&lt;/strong&gt; The &lt;code&gt;ConfigSource&lt;/code&gt; field tells you whether the search domain came from DHCP, static config, or another source. If it says "static", stop looking at DHCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Proxmox cloud-init is a hidden config source.&lt;/strong&gt; If you leave &lt;code&gt;initialization.dns.domain&lt;/code&gt; unset, Proxmox fills it from the PVE node. Always declare it explicitly. It's the same category of hidden-layer surprise as &lt;a href="https://dev.to/blog/staggered-vm-boot-load-average-147/"&gt;Proxmox's &lt;code&gt;onboot&lt;/code&gt; attribute silently not round-tripping through Terraform&lt;/a&gt; — the provider abstraction has gaps that only show up once you go looking for drift that isn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; DNS debugging keeps coming back to the same fundamentals — search domains, ndots, resolution order. &lt;a href="https://dev.to/go/4f3kTar"&gt;Netzwerke: Verstehen, Einrichten, Administrieren*&lt;/a&gt; covers those fundamentals in more depth than this Proxmox-specific drift case gets into.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;DNS latency from inherited search domains is invisible in most monitoring setups. You'll see high p99 latency on external queries but no obvious cause. If your pods have &lt;code&gt;ndots:5&lt;/code&gt; (the default) and your nodes have a search domain you didn't explicitly set, you're paying the tax on every lookup.&lt;/em&gt;&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>dns</category>
      <category>proxmox</category>
      <category>debugging</category>
    </item>
    <item>
      <title>k3s vs Docker Swarm vs Nomad in 2026</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 16 Oct 2026 09:40:43 +0000</pubDate>
      <link>https://dev.to/dwoitzik/k3s-vs-docker-swarm-vs-nomad-in-2026-4c86</link>
      <guid>https://dev.to/dwoitzik/k3s-vs-docker-swarm-vs-nomad-in-2026-4c86</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/k3s-vs-docker-swarm-vs-nomad/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Choosing between k3s, Docker Swarm, and Nomad stopped being a features question some time ago. It is now a &lt;strong&gt;project-trajectory&lt;/strong&gt; question, and the three projects are on genuinely different trajectories. k3s tracks upstream Kubernetes within weeks and ships three supported release lines at once. Docker Swarm still works but its vendor has redirected development toward Kubernetes, and the Docker Engine v29 API floor broke parts of it badly. Nomad is the technically lightest option and the only one of the three that is not open source at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR — The Picks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Orchestrator&lt;/th&gt;
&lt;th&gt;Pick it if&lt;/th&gt;
&lt;th&gt;Main risk in 2026&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;k3s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You want Kubernetes, or your tooling already assumes it&lt;/td&gt;
&lt;td&gt;You inherit the Kubernetes upgrade treadmill&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Docker Swarm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You already run it and need cluster mode with no extra daemon&lt;/td&gt;
&lt;td&gt;Docker Engine v29 dropped client and plugin compatibility&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nomad&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You want one scheduler for mixed container and non-container workloads&lt;/td&gt;
&lt;td&gt;BUSL source-available, not open source&lt;/td&gt;
&lt;td&gt;BUSL → MPL-2.0 after 4 years&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are starting fresh in 2026, the honest default is k3s. Not because it is the most capable — Nomad is lighter and Docker Swarm is simpler to learn — but because it is the only one of the three with a release cadence you can plan against, and because the surrounding ecosystem has consolidated around Kubernetes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three facts that decide it
&lt;/h2&gt;

&lt;p&gt;Before features: these are the three things that actually constrain the decision, and all three are documented rather than opinion.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Docker Engine v29 broke Swarm compatibility
&lt;/h3&gt;

&lt;p&gt;Docker Engine v29 raised the &lt;strong&gt;minimum supported daemon API version to 1.44&lt;/strong&gt;, dropping all compatibility with clients built against Docker Engine older than v25. There is no fallback and no negotiation: tooling compiled against an older API receives a hard rejection.&lt;/p&gt;

&lt;p&gt;The damage is not evenly distributed, and this is the part that matters if you run Swarm. &lt;strong&gt;Legacy (V1) Docker volume plugins are rejected by the v29 daemon.&lt;/strong&gt; Because Swarm requires cluster-scoped persistent storage, Swarm operators hit this disproportionately. The failure mode is nasty: Docker keeps the volume metadata but cannot reach the plugin needed to mount the volume, so &lt;strong&gt;your data appears to be gone while it is still intact on the storage target&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;There is a second trap in the same release. Upgrading &lt;em&gt;some&lt;/em&gt; cluster nodes to v29 while others stay older — a staged rollout, the obvious upgrade strategy — causes &lt;strong&gt;encrypted overlay traffic to silently stop&lt;/strong&gt; between v29.0.0 and v29.2.0. Silent is the operative word. If you use encrypted overlay networks, you must move all swarm hosts to v29.1.2 or later in one step; rolling upgrades are not available to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Docker has redirected Swarm development toward Kubernetes
&lt;/h3&gt;

&lt;p&gt;Docker's own retired-products page is unusually direct about SwarmKit, the project that powers Swarm mode:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;While Swarm mode remains functional, development has slowed in favor of Kubernetes-based solutions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the vendor stating the trajectory. Swarm is not deprecated in the sense of being scheduled for removal — &lt;code&gt;docker swarm init&lt;/code&gt; still works and the mode is still documented as built into the Engine — but it is a maintenance posture, not an investment posture. Portainer, the most visible Swarm management platform, published an advisory on 2026-03-10 recommending caution with continuing to run Swarm and planning a gradual move to Kubernetes.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Nomad is not open source
&lt;/h3&gt;

&lt;p&gt;This one gets repeated wrong. Nomad Community Edition is licensed under the &lt;strong&gt;Business Source License&lt;/strong&gt;. The change license is MPL 2.0, but it applies &lt;em&gt;four years after the version is published&lt;/em&gt;. So a Nomad release from today becomes MPL 2.0 in 2030 — meaning that today, in 2026, if you need a genuinely open orchestrator, Nomad is not one.&lt;/p&gt;

&lt;p&gt;The licensing situation is now stable but newly complicated. Since April 2026 HashiCorp's self-managed products follow IBM's enterprise software lifecycle, following the IBM acquisition. Nomad's support windows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Base support ends&lt;/th&gt;
&lt;th&gt;Extended support ends&lt;/th&gt;
&lt;th&gt;Ongoing extended ends&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.8.x (LTS)&lt;/td&gt;
&lt;td&gt;2026-04-30&lt;/td&gt;
&lt;td&gt;2026-04-30&lt;/td&gt;
&lt;td&gt;2026-04-30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.10.x (LTS)&lt;/td&gt;
&lt;td&gt;2027-04-30&lt;/td&gt;
&lt;td&gt;2027-04-30&lt;/td&gt;
&lt;td&gt;2027-04-30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.11.x&lt;/td&gt;
&lt;td&gt;2026-10-31&lt;/td&gt;
&lt;td&gt;2026-10-31&lt;/td&gt;
&lt;td&gt;2026-10-31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.0.x&lt;/td&gt;
&lt;td&gt;2028-04-30&lt;/td&gt;
&lt;td&gt;2029-04-30&lt;/td&gt;
&lt;td&gt;2032-04-30&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note that 1.8.x is already fully out of support as of this writing, and 1.11.x ends 2026-10-31 — within weeks. &lt;strong&gt;Nomad v1.10.x is the last LTS line.&lt;/strong&gt; From the 2.0.0 release onward, Nomad also drops semantic versioning for IBM's Version-Modification-Fix model, so tooling that parses Nomad version strings needs updating.&lt;/p&gt;

&lt;p&gt;One operational warning worth knowing before you plan a mixed estate: a Nomad Enterprise cluster &lt;strong&gt;cannot be downgraded&lt;/strong&gt;, and servers running open-source Nomad will &lt;em&gt;panic&lt;/em&gt; if joined to an Enterprise cluster, because the Raft entries for Enterprise features are not parseable by the open-source build. Test that boundary before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  k3s — the default for a reason
&lt;/h2&gt;

&lt;p&gt;k3s is a conformant Kubernetes distribution packaged as a single binary. As of October 2026 it maintains &lt;strong&gt;three release lines concurrently&lt;/strong&gt;: v1.36.4+k3s1 is current, with v1.35.8 and v1.34.11 still receiving backports. Each build pins its whole dependency set — for v1.34.11+k3s1 that is Kubernetes v1.34.11, Kine v0.16.4, SQLite 3.53.4, etcd v3.6.14-k3s1, containerd v2.2.7-k3s1, runc v1.4.2, Flannel v0.28.4, metrics-server v0.9.0, Traefik v3.7.13, CoreDNS v1.14.7, helm-controller v0.16.27 and local-path-provisioner v0.0.37.&lt;/p&gt;

&lt;p&gt;That table matters more than it looks. When something breaks at 02:00 you can read which exact version of which component is running instead of discovering it. My own homelab runs k3s across several nodes, and that pinned-component view is what makes problems like &lt;a href="https://dev.to/blog/argocd-cache-staleness-shared-resource-warning/"&gt;ArgoCD cache staleness on a shared resource&lt;/a&gt; or &lt;a href="https://dev.to/blog/nfs-vs-local-path-sqlite-trap/"&gt;picking the wrong storage backend for cluster state&lt;/a&gt; diagnosable at all.&lt;/p&gt;

&lt;p&gt;The cost of k3s is that you inherit Kubernetes' upgrade treadmill. Upgrading three majors is not a hobby, and every Helm chart in your stack is a chance for the chart to be behind the API version you just moved to. One concrete example from the 1.34 line: the bundled Traefik chart moved to v40.x, which renamed the ingress-nginx migration provider from &lt;code&gt;kubernetesIngressNginx&lt;/code&gt; to &lt;code&gt;kubernetesIngressNGINX&lt;/code&gt;. That is a breaking change inside a patch-level chart bump.&lt;/p&gt;

&lt;p&gt;I have written about the parts that bite in practice — the &lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Velero and Garage S3 backup chain&lt;/a&gt; and &lt;a href="https://dev.to/blog/unbound-adguard-keepalived-homelab/"&gt;Unbound with AdGuard and keepalived&lt;/a&gt; are both k3s deployments that took real iteration to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Swarm — simpler, and now on borrowed time
&lt;/h2&gt;

&lt;p&gt;Swarm's genuine advantage is that it is &lt;em&gt;already there&lt;/em&gt;. No second daemon, no new control plane, no new YAML dialect. If your Docker workflow is a &lt;code&gt;compose&lt;/code&gt; file and a handful of services, &lt;code&gt;docker stack deploy&lt;/code&gt; is a very small step up, and the service model with rolling updates and health checks is more capable than plain Compose.&lt;/p&gt;

&lt;p&gt;The case against it in 2026 is the two items above: the v29 compatibility break with its silent overlay-traffic failure mode, and a vendor that has said in writing that development has slowed. Neither makes Swarm unusable tomorrow. Both mean every future upgrade is a risk you cannot get support for.&lt;/p&gt;

&lt;p&gt;If you are on Swarm, the sensible 2026 posture is: pin the Engine version deliberately, verify you are past v29.1.2, audit for V1 volume plugins &lt;strong&gt;before&lt;/strong&gt; you upgrade rather than after, and start introducing Kubernetes for new workloads so you are not migrating everything at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nomad — the best scheduler, the weakest license
&lt;/h2&gt;

&lt;p&gt;Setting licensing aside, Nomad is genuinely well-designed for its niche. It schedules not just containers but binaries, VMs and batch jobs through one job specification, it is built around a single deliberately small binary rather than a multi-component control plane, and its UI is easier to reason about than anything in the Kubernetes ecosystem. For a mixed estate — a few containers alongside a scheduled data job, an ETL step, a Windows task — that unification is the whole point and Kubernetes does not do it without extra machinery.&lt;/p&gt;

&lt;p&gt;Against that: BUSL rather than an OSI license, a support matrix reshuffled by the IBM acquisition, a semantic-versioning break at 2.0, and an ecosystem that assumes Kubernetes. If your team already lives in Kubernetes YAML, Nomad's job syntax is a second language to maintain for a saving you have to measure yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Head-to-Head
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;k3s&lt;/th&gt;
&lt;th&gt;Docker Swarm&lt;/th&gt;
&lt;th&gt;Nomad&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scheduling unit&lt;/td&gt;
&lt;td&gt;Pod&lt;/td&gt;
&lt;td&gt;Service (task)&lt;/td&gt;
&lt;td&gt;Job (group, task, service)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-container workloads&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes — binaries, VMs, batch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control plane footprint&lt;/td&gt;
&lt;td&gt;Single binary per node&lt;/td&gt;
&lt;td&gt;Built into the Docker Engine, no extra daemon&lt;/td&gt;
&lt;td&gt;Single binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;BUSL, becomes MPL-2.0 after 4 years&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026 release cadence&lt;/td&gt;
&lt;td&gt;Three concurrent K8s lines&lt;/td&gt;
&lt;td&gt;Slowed, vendor-prioritised elsewhere&lt;/td&gt;
&lt;td&gt;Spring/fall features, monthly as needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ecosystem direction&lt;/td&gt;
&lt;td&gt;Consolidating around it&lt;/td&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;Stable, narrower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrading is&lt;/td&gt;
&lt;td&gt;Hard (K8s treadmill)&lt;/td&gt;
&lt;td&gt;Easy&lt;/td&gt;
&lt;td&gt;Moderate (mind the VMF versioning break)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which one should you run?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run k3s&lt;/strong&gt; if your workloads are containers and you want the Kubernetes ecosystem — Helm, ArgoCD, Prometheus operators, the CNCF ecosystem at large. This is the default in 2026 and the choice I would make for anything new.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stay on Docker Swarm&lt;/strong&gt; only if you already run it in production and the migration cost is real. Treat it as a managed liability: pin versions, avoid the v29 trap, and plan the exit. Do not adopt it new in 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run Nomad&lt;/strong&gt; if scheduling heterogeneous workload types on one control plane is worth more to you than an OSI license and a Kubernetes-shaped ecosystem. Just make that trade knowingly — you are choosing source-available software with a four-year conversion clock, not open source.&lt;/p&gt;

&lt;p&gt;If you genuinely cannot decide between two, run both on the same hardware for a month with one real workload. The theoretical comparison above is the least interesting part; how each behaves with your actual data on your actual disks is what decides it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Docker Swarm being deprecated?
&lt;/h3&gt;

&lt;p&gt;Not formally in the sense of a scheduled removal — &lt;code&gt;docker swarm init&lt;/code&gt; still works and Swarm mode is still part of Docker Engine. But Docker's own documentation states that SwarmKit development "has slowed in favor of Kubernetes-based solutions," so treat it as maintenance-only. The practical risk arrived with Docker Engine v29, which broke client and plugin compatibility for Swarm disproportionately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Nomad open source?
&lt;/h3&gt;

&lt;p&gt;No. Nomad Community Edition is licensed under the Business Source License. Versions become MPL 2.0 four years after publication, so a release from 2026 becomes genuinely open source in 2030. If you require an OSI-approved license today, that rules Nomad out. k3s and Docker Swarm are both Apache-2.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is k3s just "Kubernetes but worse"?
&lt;/h3&gt;

&lt;p&gt;No. It is a conformant Kubernetes distribution — same API server, same controllers, same ecosystem — delivered as one binary with sensible defaults. The compromise is operational, not functional: you give up control over the component versions to get a maintainable install, which is why the pinned component table is worth reading before you debug anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run Docker Swarm and Kubernetes on the same host?
&lt;/h3&gt;

&lt;p&gt;Technically, yes, but you should not. Both want to own the container runtime and the network stack, and the interaction between a Swarm overlay network and a CNI is a debugging experience you do not need to have. Run them on separate machines.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the safest way to upgrade a Swarm cluster to Docker v29?
&lt;/h3&gt;

&lt;p&gt;Get every host to v29.1.2 or later in a single step. Staged rollouts break encrypted overlay traffic silently in v29.0.0 through v29.2.0. Before you start, inventory your volume plugins — any V1 plugin will be rejected by the v29 daemon, and the resulting "missing" data is usually still on the storage target. Export your data first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Nomad support Kubernetes workloads?
&lt;/h3&gt;

&lt;p&gt;Not natively. Nomad is not a Kubernetes scheduler and does not run Kubernetes manifests. There is no compatibility path if you need the Kubernetes API; pick k3s instead. Nomad's own strength — scheduling binaries and batch jobs alongside containers — is precisely what Kubernetes does not offer.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>docker</category>
      <category>devops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>How a YAML List vs Map Difference Crash-Looped My Kyverno Fix</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:54:50 +0000</pubDate>
      <link>https://dev.to/dwoitzik/how-a-yaml-list-vs-map-difference-crash-looped-my-kyverno-fix-4ifl</link>
      <guid>https://dev.to/dwoitzik/how-a-yaml-list-vs-map-difference-crash-looped-my-kyverno-fix-4ifl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/kyverno-crash-loop-helm-extraargs-map/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Kyverno's cleanup-controller was restarting every few hours. The leader-election timeout was expiring under load — the same class of leader-election instability that &lt;a href="https://dev.to/blog/cpu-scheduling-etcd-proxmox-units/"&gt;CPU contention causes for etcd&lt;/a&gt; — the controller lost its lock, and the new leader took over, causing a brief gap in policy enforcement. The fix looked simple: increase &lt;code&gt;leaderElectionRetryPeriod&lt;/code&gt;. The fix crash-looped the very pod it was meant to stabilize.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cleanup-controller-7f8b9c4d6-xk2p4   0/1     CrashLoopBackOff   3 (42s ago)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod started, immediately crashed, and Kubernetes restarted it. Three crashes in under two minutes. The Kyverno dashboard showed the cleanup controller as unavailable, and policy reports stopped updating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Investigation
&lt;/h2&gt;

&lt;p&gt;The crash-loop meant the binary was rejecting its own flags. Checking the pod's logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: unknown flag: --0=--leaderElectionRetryPeriod=10s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binary received &lt;code&gt;--0=--leaderElectionRetryPeriod=10s&lt;/code&gt; as a single flag. That's not a valid argument — it's a rendered artifact of wrong YAML structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause: List vs Map
&lt;/h2&gt;

&lt;p&gt;The Helm chart's &lt;code&gt;cleanupController.extraArgs&lt;/code&gt; expects a &lt;strong&gt;map&lt;/strong&gt;, not a list:&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="c1"&gt;# WRONG — list syntax, renders as --0=--leaderElectionRetryPeriod=10s&lt;/span&gt;
&lt;span class="na"&gt;cleanupController&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--leaderElectionRetryPeriod=10s"&lt;/span&gt;

&lt;span class="c1"&gt;# CORRECT — map syntax, renders as --leaderElectionRetryPeriod=10s&lt;/span&gt;
&lt;span class="na"&gt;cleanupController&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;leaderElectionRetryPeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Helm's &lt;code&gt;range&lt;/code&gt; function iterates over maps as &lt;code&gt;$key, $value&lt;/code&gt; pairs. When you pass a list, Helm treats each list item as a map entry with an integer index as the key. The rendered result:&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="c"&gt;# What the binary actually receives:&lt;/span&gt;
&lt;span class="nt"&gt;--0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;--leaderElectionRetryPeriod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10s

&lt;span class="c"&gt;# What it expected:&lt;/span&gt;
&lt;span class="nt"&gt;--leaderElectionRetryPeriod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binary sees &lt;code&gt;--0&lt;/code&gt; as a flag name, doesn't recognize it, and exits with a non-zero code. Kubernetes restarts the pod. Crash-loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is a Production Risk
&lt;/h2&gt;

&lt;p&gt;This wasn't a staging environment. The Kyverno cleanup-controller handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Policy report cleanup&lt;/strong&gt; — old reports pile up without it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespace selector caching&lt;/strong&gt; — stale cache entries cause policy enforcement delays&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leader election&lt;/strong&gt; — the restart cycle itself destabilizes the leader lock&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The irony: the fix for leader-election instability &lt;em&gt;caused&lt;/em&gt; a worse instability. The cleanup-controller restarted 3 times before I caught it — and during those restarts, policy reports accumulated without cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Catch This Before It Hits Production
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Dry-run the Helm template:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm template kyverno kyverno/kyverno &lt;span class="nt"&gt;-n&lt;/span&gt; kyverno &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; cleanupController.extraArgs.leaderElectionRetryPeriod&lt;span class="o"&gt;=&lt;/span&gt;10s &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A5&lt;/span&gt; cleanup-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the rendered YAML shows &lt;code&gt;--0=...&lt;/code&gt; in the container args, the structure is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Check the chart's values.yaml:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm show values kyverno/kyverno | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A10&lt;/span&gt; extraArgs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chart documents whether &lt;code&gt;extraArgs&lt;/code&gt; is a map or list. Trust the chart, not your muscle memory from other Helm charts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use Kyverno's policy reports to validate:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After deploying, check the cleanup controller's pod status immediately:&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 pods &lt;span class="nt"&gt;-n&lt;/span&gt; kyverno &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;kyverno-cleanup-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it shows &lt;code&gt;CrashLoopBackOff&lt;/code&gt; within the first minute, the flags are wrong.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/kyverno/application.yml&lt;/span&gt;
&lt;span class="na"&gt;cleanupController&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;leaderElectionRetryPeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line change. The pod started cleanly, the crash-loop stopped, and policy reports resumed normal cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;This is a common Helm gotcha across many charts, not just Kyverno:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chart&lt;/th&gt;
&lt;th&gt;Flag field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Gotcha&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kyverno&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extraArgs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;List syntax renders &lt;code&gt;--0=&lt;/code&gt; prefix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/blog/cert-manager-wildcard-k3s-cloudflare/"&gt;Cert-Manager&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extraArgs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;Same pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extraArgs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;Same pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArgoCD&lt;/td&gt;
&lt;td&gt;&lt;code&gt;extraArgs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;map&lt;/td&gt;
&lt;td&gt;Same pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix is always the same: if the chart expects a map, use map syntax. If you're unsure, &lt;code&gt;helm template&lt;/code&gt; will show you exactly what the binary receives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; For a deeper dive into distributed systems failure modes, &lt;a href="https://dev.to/go/4aUtkCs"&gt;Designing Data-Intensive Applications by Martin Kleppmann*&lt;/a&gt; explains why leader-election timeouts behave the way they do under I/O pressure.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Kyverno policies are only effective when the controller is running. A crash-looping cleanup controller means policy reports accumulate without cleanup, and namespace selector caches go stale. Pin your Helm values correctly, and dry-run before deploying to production.&lt;/em&gt;&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Three Fixes Were Merged. None of Them Had Taken Effect.</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 12 Oct 2026 14:54:14 +0000</pubDate>
      <link>https://dev.to/dwoitzik/three-fixes-were-merged-none-of-them-had-taken-effect-pb6</link>
      <guid>https://dev.to/dwoitzik/three-fixes-were-merged-none-of-them-had-taken-effect-pb6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/gitops-merged-not-applied-argocd-drift/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;GitOps makes a specific promise: the cluster matches git. When a change is merged, it deploys. When a resource is deleted from git, it's deleted from the cluster. Merge = deploy is the core guarantee the entire workflow rests on.&lt;/p&gt;

&lt;p&gt;It's not always true. This is a story about finding three cases where it wasn't - two discovered during a drift audit, one that would have been inert even if ArgoCD had synced it correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Gap Opens: ArgoCD's App-of-Apps Bootstrap Problem
&lt;/h2&gt;

&lt;p&gt;The cluster uses ArgoCD's app-of-apps pattern: a root Application watches a directory, creates child Applications for each manifest it finds, and those child Applications sync the actual workloads. The root Application uses a &lt;code&gt;directory.include&lt;/code&gt; glob to decide which files to track.&lt;/p&gt;

&lt;p&gt;The problem: that glob is set once, at bootstrap time, and files added to the directory later are only tracked if they match the existing glob. A file that doesn't match is invisible to ArgoCD - it won't create an Application for it, won't sync it, and won't alert about it. From ArgoCD's perspective, the file doesn't exist.&lt;/p&gt;

&lt;p&gt;The monitoring stack had this problem. Two files - &lt;code&gt;application.yml&lt;/code&gt; and &lt;code&gt;manifests-application.yml&lt;/code&gt; - had been added to the &lt;code&gt;kubernetes/system/monitoring/&lt;/code&gt; directory but were missing from the parent Application's &lt;code&gt;directory.include&lt;/code&gt; glob:&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="c1"&gt;# monitoring-bootstrap/application.yml (the root)&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;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alertmanager-config.yml"&lt;/span&gt;  &lt;span class="c1"&gt;# ← only this file tracked&lt;/span&gt;
      &lt;span class="c1"&gt;# application.yml and manifests-application.yml: invisible&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fix for a broken &lt;code&gt;configSecret&lt;/code&gt; reference had been merged to &lt;code&gt;application.yml&lt;/code&gt;. It was valid YAML, it would have worked - but ArgoCD never picked it up. The live cluster was still running the broken version from initial bootstrap, indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Gap: What the Drift Audit Actually Checks
&lt;/h2&gt;

&lt;p&gt;The standard ArgoCD health check (&lt;code&gt;kubectl get applications -n argocd&lt;/code&gt;) doesn't catch this. All Applications show &lt;code&gt;Synced/Healthy&lt;/code&gt;. The bootstrapped-but-untracked files don't generate an Application at all, so there's nothing to show as out-of-sync.&lt;/p&gt;

&lt;p&gt;The audit that found it was manual: compare the contents of each &lt;code&gt;kubernetes/system/*/&lt;/code&gt; directory against the tracked files in the corresponding bootstrap Application, and flag any file not in the glob:&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="c"&gt;# For each subdirectory, list tracked files vs actual files&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;dir &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;kubernetes/system/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== &lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt; ==="&lt;/span&gt;
  &lt;span class="c"&gt;# What ArgoCD tracks (from the bootstrap Application's directory.include)&lt;/span&gt;
  &lt;span class="c"&gt;# vs what's actually in the directory&lt;/span&gt;
  &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.yml 2&amp;gt;/dev/null
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit found approximately 14 directories with the same untracked-after-bootstrap gap - no current drift in most of them, but nothing preventing drift from accumulating silently if files were added there in the future.&lt;/p&gt;

&lt;p&gt;This is the same class of gap that a GitHub Actions Terraform-plan-to-Slack alert is designed to close on the Terraform side — a green pipeline or a &lt;code&gt;Synced&lt;/code&gt; badge tells you the command ran, not that the running state matches intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Live Finding: Traefik allowCrossNamespace
&lt;/h2&gt;

&lt;p&gt;Traefik's &lt;code&gt;allowCrossNamespace&lt;/code&gt; fix had been merged to git on 2026-07-04. The change allowed IngressRoutes in one namespace to reference Services in another - without it, &lt;code&gt;argo.woitzik.dev&lt;/code&gt;, &lt;code&gt;monitoring.woitzik.dev&lt;/code&gt;, and &lt;code&gt;status.woitzik.dev&lt;/code&gt; returned bare 404s.&lt;/p&gt;

&lt;p&gt;The fix was in git. ArgoCD showed the Traefik Application as &lt;code&gt;Synced&lt;/code&gt;. But the endpoints were still 404ing.&lt;/p&gt;

&lt;p&gt;The cause: the Traefik Application's last successful sync was before the fix merged. ArgoCD's sync status reflects the last successful reconciliation, not a continuous comparison. If the sync was clean before the change and nothing triggered a re-sync after (no new commit to the watched path, no manual refresh), the Application can show &lt;code&gt;Synced&lt;/code&gt; while being hours or days behind.&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="c"&gt;# Force ArgoCD to re-compare against current git HEAD&lt;/span&gt;
argocd app &lt;span class="nb"&gt;sync &lt;/span&gt;traefik &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;span class="c"&gt;# or via annotation:&lt;/span&gt;
kubectl annotate application traefik &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="se"&gt;\&lt;/span&gt;
  argocd.argoproj.io/refresh&lt;span class="o"&gt;=&lt;/span&gt;hard &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After manual sync, all three endpoints came back to 302 within seconds of the rollout completing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Finding: Velero's Fix That Couldn't Have Worked
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Velero&lt;/a&gt; had a merged fix for repository maintenance frequency - too-frequent Kopia maintenance jobs were running during backup windows and causing timeouts. The fix set a custom maintenance frequency via Helm values.&lt;/p&gt;

&lt;p&gt;The fix was synced correctly. It also did nothing, for two independent reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 1: Wrong Helm values structure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;extraArgs&lt;/code&gt; key was placed at the top level of the Helm values instead of nested under &lt;code&gt;configuration&lt;/code&gt;:&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="c1"&gt;# What was merged (broken)&lt;/span&gt;
&lt;span class="na"&gt;velero&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--default-repo-maintain-frequency=168h"&lt;/span&gt;

&lt;span class="c1"&gt;# What Helm actually needs&lt;/span&gt;
&lt;span class="na"&gt;velero&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;configuration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--default-repo-maintain-frequency=168h"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Helm silently drops unknown top-level keys. No error, no warning - the extra args simply weren't passed to the binary. Velero started with its defaults.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason 2: Wrong flag name.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The flag &lt;code&gt;--default-repo-maintenance-frequency&lt;/code&gt; doesn't exist in Velero. The real flag is &lt;code&gt;--default-repo-maintain-frequency&lt;/code&gt; (no "ance"). Even if the values structure had been correct, the binary would have printed an unknown flag error and ignored it.&lt;/p&gt;

&lt;p&gt;Two independently-wrong things, each of which would have prevented the fix from taking effect on its own. Caught during the drift audit by checking the running Velero container's actual args:&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 pod &lt;span class="nt"&gt;-n&lt;/span&gt; velero &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;velero &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'{.items[0].spec.containers[0].args}'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt;
&lt;span class="c"&gt;# → no --default-repo-maintain-frequency flag present&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct fix, both issues resolved:&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;velero&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;configuration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--default-repo-maintain-frequency=168h"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Three-Part Pattern
&lt;/h2&gt;

&lt;p&gt;All three failures share the same shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Traefik allowCrossNamespace&lt;/th&gt;
&lt;th&gt;Velero maintenance frequency&lt;/th&gt;
&lt;th&gt;Monitoring bootstrap files&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Git state&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;td&gt;Correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live state&lt;/td&gt;
&lt;td&gt;Unsynced&lt;/td&gt;
&lt;td&gt;Synced but inert&lt;/td&gt;
&lt;td&gt;Never tracked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArgoCD showed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Synced/Healthy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Synced/Healthy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No Application (invisible)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Found by&lt;/td&gt;
&lt;td&gt;Manual sync + endpoint check&lt;/td&gt;
&lt;td&gt;Checking running container args&lt;/td&gt;
&lt;td&gt;Directory vs. glob comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In each case, the developer closed the PR with the reasonable belief that the fix was live. In each case, it wasn't. The merge was the easy part; the verification that the change actually took effect in the running cluster is the part that was skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verification Step That Prevents It
&lt;/h2&gt;

&lt;p&gt;For any change where "merged" isn't sufficient proof of "deployed," there's one check that works:&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="c"&gt;# For a Helm values change: verify the flag is in the running container&lt;/span&gt;
kubectl get pod &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt;label-selector&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].spec.containers[0].args}'&lt;/span&gt;

&lt;span class="c"&gt;# For a Kubernetes manifest change: verify the field is live&lt;/span&gt;
kubectl get &amp;lt;resource&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; &amp;lt;name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.&amp;lt;changed-field&amp;gt;}'&lt;/span&gt;

&lt;span class="c"&gt;# For an ArgoCD Application bootstrap: verify the file is tracked&lt;/span&gt;
argocd app get &amp;lt;bootstrap-app&amp;gt; &lt;span class="nt"&gt;--output&lt;/span&gt; json | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="s1"&gt;'.spec.source.directory.include'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these are expensive. They're all one-liners. The cost of skipping them - in this case, Traefik returning 404 for three production subdomains, Velero running backup maintenance at the wrong frequency indefinitely, and monitoring fixes sitting inert - was substantially higher.&lt;/p&gt;




&lt;p&gt;The same GitOps verification gap exists in Azure DevOps pipelines and GitHub Actions that deploy Terraform or Helm. A pipeline that reports "succeeded" proves that the deployment command ran without error - it doesn't prove that the cluster's running state matches the manifest. Checking the running state directly, after deployment, is the only check that closes the gap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4f4RHQf"&gt;The Phoenix Project*&lt;/a&gt; is still the sharpest account I know of exactly this gap between "the change was made" and "the change took effect" - fiction, but painfully accurate about how it actually happens.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Postgres 16 18: Why You Can't Just Swap the Image</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 05 Oct 2026 12:44:42 +0000</pubDate>
      <link>https://dev.to/dwoitzik/postgres-16-18-why-you-cant-just-swap-the-image-1j4j</link>
      <guid>https://dev.to/dwoitzik/postgres-16-18-why-you-cant-just-swap-the-image-1j4j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/postgres-16-18-migration-fresh-pvc/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL major version upgrades are not swap-the-image operations. You can't change &lt;code&gt;postgres:16&lt;/code&gt; to &lt;code&gt;postgres:18&lt;/code&gt; in a Deployment and expect it to work. The data directory format changes, system catalogs are incompatible, and extensions need to be rebuilt.&lt;/p&gt;

&lt;p&gt;When CNPG (CloudNativePG) upgraded from PG16 to PG18, the procedure required a fresh PVC, a full dump and restore, and a role discovery bug that nearly locked me out of the &lt;a href="https://dev.to/blog/k3s-authelia-proxmox-homelab/"&gt;Authelia&lt;/a&gt; database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Can't Swap the Image
&lt;/h2&gt;

&lt;p&gt;PostgreSQL stores data in a format specific to its major version. The &lt;code&gt;PG_VERSION&lt;/code&gt; file in the data directory tells PostgreSQL which version created 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;cat&lt;/span&gt; /var/lib/postgresql/data/PG_VERSION
&lt;span class="c"&gt;# → 16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When PostgreSQL 18 starts and finds &lt;code&gt;PG_VERSION = 16&lt;/code&gt;, it refuses to start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: data directory has wrong ownership
HINT: The data directory was initialized by PostgreSQL 16. 
      Upgrade by running pg_upgrade.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_upgrade&lt;/code&gt; is the official tool for in-place major version upgrades. It copies data files from the old format to the new format, rewriting system catalogs and tuple headers. It works well on bare-metal PostgreSQL where you have direct filesystem access.&lt;/p&gt;

&lt;p&gt;On Kubernetes with CNPG, &lt;code&gt;pg_upgrade&lt;/code&gt; is not the right approach because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CNPG manages the data directory through its operator — manual modifications are reverted&lt;/li&gt;
&lt;li&gt;The PVC is bound to the cluster definition — changing the PostgreSQL version in the CRD doesn't automatically upgrade the data&lt;/li&gt;
&lt;li&gt;CNPG's recommended migration path is dump-and-restore, not in-place upgrade&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Migration Procedure
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Dump from PG16
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Exec into the CNPG pod&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; database &lt;span class="nt"&gt;-it&lt;/span&gt; postgres-authelia-0 &lt;span class="nt"&gt;--&lt;/span&gt; bash

&lt;span class="c"&gt;# Dump the database&lt;/span&gt;
pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-Fc&lt;/span&gt; authelia &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/authelia.dump

&lt;span class="c"&gt;# Copy the dump to a temporary location&lt;/span&gt;
kubectl &lt;span class="nb"&gt;cp &lt;/span&gt;database/postgres-authelia-0:/tmp/authelia.dump ./authelia.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-Fc&lt;/code&gt; flag produces a custom-format dump that's compressed and can be restored with &lt;code&gt;pg_restore&lt;/code&gt;. The dump includes all data, schemas, roles, and extensions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create Fresh PG18 Cluster
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/postgres/cluster.yml&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;postgresql.cnpg.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;Cluster&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;postgres-authelia&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;database&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;imageName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/cloudnative-pg/postgresql:16.4&lt;/span&gt;  &lt;span class="c1"&gt;# temporary — will be updated&lt;/span&gt;
  &lt;span class="na"&gt;instances&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;storage&lt;/span&gt;&lt;span class="pi"&gt;:&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;2Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local-path&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait — the image is still PG16. That's intentional. CNPG creates the cluster with PG16 first, then upgrades the image to PG18 after the data is restored. This ensures the PVC and the operator agree on the initial state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Restore into PG18
&lt;/h3&gt;

&lt;p&gt;After the cluster is running with PG16, update the image to PG18:&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;imageName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/cloudnative-pg/postgresql:18.4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CNPG detects the image change, creates a new pod with PG18, and the old PG16 pod is terminated. The data directory is still PG16 format, so the new pod fails to start — which is expected.&lt;/p&gt;

&lt;p&gt;Now restore the dump into the fresh PG18 data directory:&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="c"&gt;# Port-forward to the CNPG service&lt;/span&gt;
kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; database svc/postgres-authelia 5432:5432 &amp;amp;

&lt;span class="c"&gt;# Restore the dump&lt;/span&gt;
pg_restore &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; authelia &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="nt"&gt;--if-exists&lt;/span&gt; ./authelia.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_restore&lt;/code&gt; handles the format conversion — it reads PG16-format data and writes it in PG18 format. The &lt;code&gt;--clean --if-exists&lt;/code&gt; flags drop existing objects before restoring, ensuring a clean state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Update Authelia
&lt;/h3&gt;

&lt;p&gt;Update the Authelia deployment to use the new Postgres 18 connection string (same host, same port, same database — the connection string doesn't change):&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="c1"&gt;# Verify Authelia connects to the new database&lt;/span&gt;
&lt;span class="s"&gt;kubectl logs -n apps -l app=authelia --tail=20&lt;/span&gt;
&lt;span class="c1"&gt;# → "Successfully connected to PostgreSQL 18.4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Role Discovery Bug
&lt;/h2&gt;

&lt;p&gt;During the restore, &lt;code&gt;pg_restore&lt;/code&gt; reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pg_restore: error: could not open input file "/tmp/authelia.dump": No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dump file was at a different path than expected. The real problem: the restore was running from a pod that had a different filesystem layout than the dump pod.&lt;/p&gt;

&lt;p&gt;After fixing the path, the restore completed but Authelia failed to start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: role "oc_dw" does not exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;oc_dw&lt;/code&gt; role was a leftover from the original CNPG cluster initialization — CNPG creates a default operator role that isn't visible in &lt;code&gt;pg_dump&lt;/code&gt; output because it's a replication role, not a regular database role.&lt;/p&gt;

&lt;p&gt;The fix: create the missing role before restoring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;oc_dw&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;REPLICATION&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson: CNPG creates internal roles (&lt;code&gt;oc_dw&lt;/code&gt;, &lt;code&gt;streaming_replica&lt;/code&gt;) that aren't included in &lt;code&gt;pg_dump&lt;/code&gt; output. After a dump-and-restore migration, these roles must be recreated manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fresh PVC Requirement
&lt;/h2&gt;

&lt;p&gt;The critical step that most tutorials skip: &lt;strong&gt;you need a fresh PVC.&lt;/strong&gt; You can't restore a PG16 dump into a PG16 data directory and then expect PG18 to read it. The data directory must be empty — PG18 creates its own data directory format on first start, and &lt;code&gt;pg_restore&lt;/code&gt; populates it.&lt;/p&gt;

&lt;p&gt;In CNPG, this means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Delete the existing PVC (data loss — you need the dump)&lt;/li&gt;
&lt;li&gt;Let CNPG create a new PVC with the PG18 image&lt;/li&gt;
&lt;li&gt;Restore the dump into the fresh cluster&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The PVC deletion is the scary part. If the dump is corrupted or incomplete, the data is gone. The verification before deletion:&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="c"&gt;# Verify dump is complete&lt;/span&gt;
pg_restore &lt;span class="nt"&gt;--list&lt;/span&gt; ./authelia.dump | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# → Should show hundreds of objects (tables, sequences, functions)&lt;/span&gt;

&lt;span class="c"&gt;# Verify dump integrity&lt;/span&gt;
pg_restore &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-privileges&lt;/span&gt; &lt;span class="nt"&gt;--dry-run&lt;/span&gt; ./authelia.dump
&lt;span class="c"&gt;# → Should complete without errors&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd Change
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use CNPG's Backup/Restore instead of manual dump.&lt;/strong&gt; CNPG supports &lt;code&gt;pg_basebackup&lt;/code&gt; and WAL archiving to S3. A CNPG backup includes the operator roles and can be restored directly without the &lt;code&gt;oc_dw&lt;/code&gt; gotcha. The manual dump approach was chosen because the existing cluster wasn't configured for CNPG backups at the time of migration.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test the migration on a non-production cluster first.&lt;/strong&gt; The &lt;code&gt;oc_dw&lt;/code&gt; role discovery happened during the Authelia migration — the only SSO for every service. If the restore had failed, every service would be unreachable.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;PostgreSQL major version upgrades on Kubernetes are the same challenge as Azure Database for PostgreSQL Flexible Server upgrades: Azure handles the in-place upgrade automatically, but the same data directory format incompatibility exists. The difference is that Azure abstracts the dump-and-restore behind a API call, while Kubernetes requires you to do it manually. The underlying PostgreSQL constraint is identical: major versions are not backward-compatible at the storage layer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4gEAhv9"&gt;The Linux Command Line*&lt;/a&gt; is worth having on the shelf for exactly this kind of migration - &lt;code&gt;pg_dump&lt;/code&gt;, &lt;code&gt;pg_restore&lt;/code&gt;, &lt;code&gt;kubectl cp&lt;/code&gt;, and a dozen other shell tools chained together under time pressure go a lot smoother when the shell itself isn't also something you're learning in the moment.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>postgres</category>
      <category>homelab</category>
      <category>migration</category>
    </item>
    <item>
      <title>Renovate in Kubernetes: OOM, Hashicorp Downloads, and the 6GB Container</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:30:39 +0000</pubDate>
      <link>https://dev.to/dwoitzik/renovate-in-kubernetes-oom-hashicorp-downloads-and-the-6gb-container-56n8</link>
      <guid>https://dev.to/dwoitzik/renovate-in-kubernetes-oom-hashicorp-downloads-and-the-6gb-container-56n8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/renovate-kubernetes-oom-6gb-container/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Renovate is the dependency update bot that keeps my k3s cluster current, and it's the "auto-update" half of &lt;a href="https://dev.to/blog/operating-model-auto-update-human/"&gt;the operating model&lt;/a&gt; that decides what gets merged automatically vs. what waits for a human. It runs as a Kubernetes CronJob every 2 hours, checks for new container images, Helm chart versions, and Terraform provider releases, and opens PRs on GitHub.&lt;/p&gt;

&lt;p&gt;For two weeks, it was failing silently on every run. The CronJob reported "Completed." The GitHub commits showed no new PRs. The logs showed nothing — because Renovate exits cleanly on config validation errors, and the CronJob's &lt;code&gt;successfulJobsHistoryLimit&lt;/code&gt; kept only the last successful run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: Invalid Preset
&lt;/h2&gt;

&lt;p&gt;The first failure was a config error. The &lt;code&gt;renovate.json&lt;/code&gt; file referenced an invalid preset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extends"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"config:base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;":enableHelpfulPre-commit"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:enableHelpfulPre-commit&lt;/code&gt; is not a real Renovate preset. Renovate's config validation caught the error and exited with a zero exit code — because config validation errors are treated as "nothing to do," not "something broke."&lt;/p&gt;

&lt;p&gt;The CronJob's exit code was 0. Kubernetes considered the job successful. No alert fired. Renovate did nothing on every run.&lt;/p&gt;

&lt;p&gt;The fix: remove the invalid preset and replace the deprecated &lt;code&gt;config:base&lt;/code&gt; with &lt;code&gt;config:recommended&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extends"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"config:recommended"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Failure 2: OOM at 512Mi
&lt;/h2&gt;

&lt;p&gt;After fixing the config, Renovate started running — and immediately OOM'd.&lt;/p&gt;

&lt;p&gt;The container limit was 512Mi. Renovate's Node.js runtime, plus the GitHub API client, plus the Terraform provider registry client, plus the container image metadata parser, consumed more than 512Mi during a full scan.&lt;/p&gt;

&lt;p&gt;The symptom: the Pod restarted with &lt;code&gt;OOMKilled&lt;/code&gt; status. Kubernetes restarted it. The next run OOM'd again. After 3 failures, the CronJob marked the job as failed — but &lt;code&gt;failedJobsHistoryLimit: 3&lt;/code&gt; meant only the last 3 failures were visible, and they were being garbage-collected faster than I checked.&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 &lt;span class="nb"&gt;jobs&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;renovate
&lt;span class="c"&gt;# NAME              COMPLETIONS   DURATION   AGE&lt;/span&gt;
&lt;span class="c"&gt;# renovate-28197    0/1           ...        2m&lt;/span&gt;
&lt;span class="c"&gt;# renovate-28196    0/1           ...        2h&lt;/span&gt;
&lt;span class="c"&gt;# renovate-28195    0/1           ...        4h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: raise the memory limit to 1Gi and set &lt;code&gt;NODE_OPTIONS&lt;/code&gt; to limit the V8 heap:&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;NODE_OPTIONS&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-old-space-size=896"&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6Gi&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NODE_OPTIONS&lt;/code&gt; limit of 896MB (leaving ~100MB for native code and runtime overhead) prevents V8 from consuming the entire container memory during garbage collection cycles. Without it, V8 allocates memory up to the container limit and gets OOMKilled before the next GC cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 3: Terraform Provider Downloads
&lt;/h2&gt;

&lt;p&gt;The third failure was the most subtle. Renovate was consuming 6GB of memory during Terraform provider update checks. The reason: Renovate downloads Terraform provider binaries to verify version compatibility.&lt;/p&gt;

&lt;p&gt;For each Terraform provider in the repo (&lt;code&gt;routeros&lt;/code&gt;, &lt;code&gt;proxmox&lt;/code&gt;, &lt;code&gt;cloudflare&lt;/code&gt;, &lt;code&gt;garage&lt;/code&gt;), Renovate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Queries the Terraform Registry API for available versions&lt;/li&gt;
&lt;li&gt;Downloads the provider binary for the current and latest versions&lt;/li&gt;
&lt;li&gt;Compares binary compatibility (provider schema, API version)&lt;/li&gt;
&lt;li&gt;Opens a PR if a newer version is available&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 is the memory problem. Terraform provider binaries are 50-200MB compressed. With 4 providers, plus their dependencies, Renovate downloads ~500MB of provider binaries during a full scan. The decompressed binaries and their metadata consume 2-3x that in memory.&lt;/p&gt;

&lt;p&gt;The fix was two-part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Raise the memory limit to 6Gi&lt;/strong&gt; — enough for the full scan including provider downloads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limit concurrent requests&lt;/strong&gt; to the Terraform Registry:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"terraform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"concurrentRequestLimit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;concurrentRequestLimit: 2&lt;/code&gt;, Renovate downloads at most 2 provider binaries simultaneously, reducing peak memory usage from ~6GB to ~3GB. The scan takes longer, but it completes within the 6Gi limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent Failure Pattern
&lt;/h2&gt;

&lt;p&gt;The common thread across all three failures: Renovate exited cleanly. The CronJob reported success. No alert fired. The only evidence of failure was the absence of new PRs on GitHub.&lt;/p&gt;

&lt;p&gt;This is the silent failure pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The application fails internally but exits with code 0&lt;/li&gt;
&lt;li&gt;Kubernetes considers the job successful&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;successfulJobsHistoryLimit&lt;/code&gt; preserves the "successful" job&lt;/li&gt;
&lt;li&gt;Nobody checks because the job "succeeded"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix: add a post-run check that verifies Renovate actually did something:&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="c1"&gt;# Post-run check: verify PRs were opened&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;Verify Renovate activity&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;PR_COUNT=$(gh pr list --repo dwoitzik/homelab-infrastructure \&lt;/span&gt;
      &lt;span class="s"&gt;--author "app/renovate" --state open --json number --jq 'length')&lt;/span&gt;
    &lt;span class="s"&gt;if [ "$PR_COUNT" -eq 0 ] &amp;amp;&amp;amp; [ "$(date +%u)" -le 5 ]; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "WARNING: No open Renovate PRs on a weekday"&lt;/span&gt;
      &lt;span class="s"&gt;# Alert via Discord webhook&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Current Configuration
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/apps/renovate/renovate.yml&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;batch/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;CronJob&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;renovate&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;apps&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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*/2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;  &lt;span class="c1"&gt;# every 2 hours&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&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;template&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;containers&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;renovate&lt;/span&gt;
              &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/renovatebot/renovate:43.245.0&lt;/span&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;RENOVATE_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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;renovate-token&lt;/span&gt;
                      &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-pat&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;NODE_OPTIONS&lt;/span&gt;
                  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--max-old-space-size=896"&lt;/span&gt;
              &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6Gi&lt;/span&gt;
                  &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2000m&lt;/span&gt;
                &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
                  &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100m&lt;/span&gt;
          &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
      &lt;span class="na"&gt;backoffLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;restartPolicy: Never&lt;/code&gt; with &lt;code&gt;backoffLimit: 2&lt;/code&gt; means the CronJob retries twice on failure, then gives up. Combined with the memory limit and heap size cap, Renovate completes its scan within resource bounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lesson
&lt;/h2&gt;

&lt;p&gt;Silent failures in CronJobs are the most dangerous failure mode in a Kubernetes cluster — the same "reports success but did nothing real" pattern as ArgoCD showing &lt;code&gt;Synced/Healthy&lt;/code&gt; on an Application that never actually applied. The job "succeeds," the operator doesn't check, and the dependency update pipeline silently stops working. Weeks pass without updates, security patches don't apply, and nobody notices until a vulnerability is disclosed for a package that Renovate would have updated.&lt;/p&gt;

&lt;p&gt;The fix isn't just more memory — it's observability on the pipeline itself. Every CronJob that performs a critical function should have a post-run verification that confirms it actually did its job, not just that it exited cleanly.&lt;/p&gt;




&lt;p&gt;CronJob observability is the same problem in Azure DevOps: a pipeline that succeeds but produces no artifact is indistinguishable from a pipeline that didn't run. Azure Monitor's Pipeline Analytics tracks success rate, not "did the pipeline produce meaningful output." The fix in both cases is the same: add a verification step that checks for the expected outcome (PRs opened, artifacts published, deployments completed) and alerts on absence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4f4RHQf"&gt;The Phoenix Project*&lt;/a&gt; tells the same story from the human side of this exact failure mode - the automated system that everyone assumes is working, until someone finally checks.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Longhorn to NFS: Why Distributed Storage Didn't Make Sense Here</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 28 Sep 2026 13:44:58 +0000</pubDate>
      <link>https://dev.to/dwoitzik/longhorn-to-nfs-why-distributed-storage-didnt-make-sense-here-5530</link>
      <guid>https://dev.to/dwoitzik/longhorn-to-nfs-why-distributed-storage-didnt-make-sense-here-5530</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/longhorn-to-nfs-distributed-storage/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Longhorn is designed for multi-node Kubernetes clusters. It replicates PersistentVolume data across nodes, so if one node dies, the data survives on the other two. It's a great distributed storage system.&lt;/p&gt;

&lt;p&gt;I run three k3s nodes on a single physical host. All three nodes share the same NVMe. When Longhorn replicates a PVC across three nodes, all three replicas live on the same physical disk. There is no distribution. There is no redundancy. And when a node failed, Longhorn's own high-availability logic created a problem that wouldn't exist without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Multi-Attach Error
&lt;/h2&gt;

&lt;p&gt;When a k3s node becomes unreachable (OomKill, kubelet crash, network blip), Longhorn marks its replicas as "rebuilding" and tries to attach the volume to a healthy node. But Longhorn's RWO (ReadWriteOnce) volumes can only be attached to one node at a time.&lt;/p&gt;

&lt;p&gt;The sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;k3s-12 becomes unreachable (kubelet lost lease)&lt;/li&gt;
&lt;li&gt;Longhorn detaches the PVC from k3s-12&lt;/li&gt;
&lt;li&gt;Longhorn tries to attach the PVC to k3s-13&lt;/li&gt;
&lt;li&gt;k3s-12 recovers and comes back online&lt;/li&gt;
&lt;li&gt;Longhorn sees both nodes claiming the volume&lt;/li&gt;
&lt;li&gt;Multi-Attach error: the volume is "attached" to two nodes simultaneously&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pod that needs the volume (Postgres, for example) can't start on either node because Longhorn refuses to mount a volume that's in a Multi-Attach state. The fix requires manually detaching the volume from both nodes and letting Longhorn re-attach it cleanly — a manual step during an outage, exactly when automation should be working.&lt;/p&gt;

&lt;p&gt;On a multi-host cluster, this is a real HA scenario — the volume legitimately needs to failover to a different physical disk. On a single-host cluster, the "failover" is to the same NVMe, and the Multi-Attach error is pure overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NFS Alternative
&lt;/h2&gt;

&lt;p&gt;NFS (Network File System) doesn't have a Multi-Attach problem because it's not a block storage system. NFS exports a directory over the network. Any number of clients can mount it simultaneously. There's no "attachment" state to conflict.&lt;/p&gt;

&lt;p&gt;The NFS server runs on a dedicated LXC (&lt;code&gt;ct-srv-nfs-01&lt;/code&gt;) with a ZFS-backed dataset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/stacks/proxmox/lxc.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"ct_srv_nfs_01"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vm_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;220&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ct-srv-nfs-01"&lt;/span&gt;
  &lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;dedicated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The k3s NFS provisioner creates PVCs on this NFS server:&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="c1"&gt;# kubernetes/system/nfs-provisioner/application.yml&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;argoproj.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;Application&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;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;nfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.20.100&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/archive&lt;/span&gt;
        &lt;span class="na"&gt;storageClass&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;nfs-client&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When k3s-12 goes down, the pods reschedule to k3s-11 or k3s-13, mount the same NFS export, and continue where they left off. No Multi-Attach error, no manual detachment, no volume attachment state machine. NFS handles concurrent mounts natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-offs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What NFS Gives Up
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Longhorn&lt;/th&gt;
&lt;th&gt;NFS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Replication across nodes&lt;/td&gt;
&lt;td&gt;Yes (3x by default)&lt;/td&gt;
&lt;td&gt;No (single server)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snapshots&lt;/td&gt;
&lt;td&gt;Yes (per-volume)&lt;/td&gt;
&lt;td&gt;Yes (ZFS snapshots)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReadWriteMany&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSI driver&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (nfs-subdir-external-provisioner)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance&lt;/td&gt;
&lt;td&gt;Block-level (faster)&lt;/td&gt;
&lt;td&gt;Network-mounted (slower)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node failure tolerance&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (single server)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;NFS gives up replication and node-failure tolerance. If the NFS server dies, every PVC it serves is unavailable. On a single physical host, this is the same risk as Longhorn — both storage systems live on the same NVMe. Longhorn's replication doesn't help when the underlying disk is the single point of failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  What NFS Gives Back
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No Multi-Attach errors.&lt;/strong&gt; Pods reschedule freely without volume attachment conflicts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simpler failure modes.&lt;/strong&gt; NFS is either up or down. No "rebuilding," "degraded," or "reverted" states.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ZFS snapshots for backup.&lt;/strong&gt; PBS backs up the NFS server's ZFS dataset, giving point-in-time recovery without Longhorn's snapshot overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower resource usage.&lt;/strong&gt; Longhorn runs a per-node process (manager + driver) consuming ~200MB RAM per node. NFS is a single process on one LXC.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Storage Decision Matrix
&lt;/h2&gt;

&lt;p&gt;After migrating from Longhorn to NFS, I defined the rule:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Application&lt;/th&gt;
&lt;th&gt;Storage Class&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL (CNPG)&lt;/td&gt;
&lt;td&gt;local-path&lt;/td&gt;
&lt;td&gt;CNPG manages its own replication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Garage S3 metadata&lt;/td&gt;
&lt;td&gt;local-path&lt;/td&gt;
&lt;td&gt;&lt;a href="https://dev.to/blog/nfs-vs-local-path-sqlite-trap/"&gt;SQLite file-locking (the NFS trap)&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mealie, Home Assistant&lt;/td&gt;
&lt;td&gt;local-path&lt;/td&gt;
&lt;td&gt;SQLite databases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything else&lt;/td&gt;
&lt;td&gt;nfs-client&lt;/td&gt;
&lt;td&gt;Default, survives pod rescheduling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The principle: &lt;strong&gt;embedded databases go on local-path (file-locking), everything else goes on NFS (survivability).&lt;/strong&gt; PostgreSQL doesn't fit either category because CNPG manages its own PV independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration
&lt;/h2&gt;

&lt;p&gt;Moving PVCs from Longhorn to NFS required:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy the NFS server LXC with the correct ZFS dataset&lt;/li&gt;
&lt;li&gt;Install the &lt;code&gt;nfs-subdir-external-provisioner&lt;/code&gt; Helm chart&lt;/li&gt;
&lt;li&gt;Copy data from Longhorn volumes to NFS (using &lt;code&gt;kubectl cp&lt;/code&gt; or a temporary pod)&lt;/li&gt;
&lt;li&gt;Update each application's &lt;code&gt;storageClassName&lt;/code&gt; from &lt;code&gt;longhorn&lt;/code&gt; to &lt;code&gt;nfs-client&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Delete the old Longhorn PVC (data is already on NFS)&lt;/li&gt;
&lt;li&gt;Remove Longhorn from the cluster&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is the nerve-wracking part — deleting a PVC that contains production data. The verification before deletion:&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="c"&gt;# Verify data exists on NFS&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; temp-pod &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /mnt/nfs/authelia-data/
&lt;span class="c"&gt;# → Confirm database files, config, etc.&lt;/span&gt;

&lt;span class="c"&gt;# Verify application starts with NFS PVC&lt;/span&gt;
kubectl delete pod authelia-xxxxx  &lt;span class="c"&gt;# force reschedule&lt;/span&gt;
&lt;span class="c"&gt;# → Pod starts, mounts NFS, passes health checks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the migration, Longhorn was uninstalled. The cluster went from three storage replicas on one disk to a single NFS server on the same disk — simpler, more predictable, and without the Multi-Attach false alarm.&lt;/p&gt;




&lt;p&gt;Distributed storage on a single host is the same anti-pattern as Azure Zone-Redundant Storage across availability zones that share the same power source. If the underlying infrastructure isn't actually distributed, the replication layer adds complexity without adding resilience. The fix in both cases: match the storage topology to the actual infrastructure topology. Single host = single NFS server. Multi-host = distributed storage.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4aUtkCs"&gt;Designing Data-Intensive Applications*&lt;/a&gt; has the clearest explanation I've read of why replication only buys you resilience when the replicas are actually independent - which is the whole lesson of this migration in one sentence.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>storage</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Operating Model: What Should Auto-Update vs. What Needs a Human</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 25 Sep 2026 11:00:27 +0000</pubDate>
      <link>https://dev.to/dwoitzik/the-operating-model-what-should-auto-update-vs-what-needs-a-human-1md6</link>
      <guid>https://dev.to/dwoitzik/the-operating-model-what-should-auto-update-vs-what-needs-a-human-1md6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/operating-model-auto-update-human/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A homelab that requires daily attention isn't a homelab — it's a job. The goal: a cluster that runs for weeks without intervention, alerts when something breaks, self-heals what it can, and waits for a human only when a human is actually needed.&lt;/p&gt;

&lt;p&gt;This is the operating model that makes that work. Not aspirational — documented from what actually runs in production, including the deliberate gaps and the non-goals that keep the system honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Auto-Updates
&lt;/h2&gt;

&lt;p&gt;Renovate runs every 2 hours via CronJob, tracking container images, Helm charts, and Terraform providers. But not everything gets the same treatment:&lt;/p&gt;

&lt;h3&gt;
  
  
  Auto-Merge (Patch/Minor/Digest, 3-Day Soak)
&lt;/h3&gt;

&lt;p&gt;Stateless workloads that can be rolled back by ArgoCD's &lt;code&gt;selfHeal&lt;/code&gt; if they break:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dashboard widgets (Homepage, Uptime Kuma display)&lt;/li&gt;
&lt;li&gt;Development tools (pre-commit hooks, linters)&lt;/li&gt;
&lt;li&gt;Non-critical utilities (SearXNG, Mealie, MySpeed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Renovate opens a PR, CI runs, and after 3 days of green the PR auto-merges. If the new version breaks, ArgoCD's &lt;code&gt;syncPolicy.automated.selfHeal: true&lt;/code&gt; reverts to the previous version on the next sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  PR-Only, Always (Manual Review Required)
&lt;/h3&gt;

&lt;p&gt;Stateful and critical services where a bad upgrade can cause data loss or auth outage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Databases:&lt;/strong&gt; CNPG Postgres, Redis/Valkey&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth:&lt;/strong&gt; &lt;a href="https://dev.to/blog/k3s-authelia-proxmox-homelab/"&gt;Authelia&lt;/a&gt;, &lt;a href="https://dev.to/blog/vault-auto-unseal-polling-sidecar/"&gt;Vault&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage:&lt;/strong&gt; &lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Garage S3&lt;/a&gt;, Velero&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apps with data migrations:&lt;/strong&gt; Nextcloud, Paperless, Gitea, Immich&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every update — patch, minor, or digest — gets a PR that requires manual review. The failure mode for these isn't "pod restarts" — it's "database schema mismatch" or "OIDC keys rotated."&lt;/p&gt;

&lt;h3&gt;
  
  
  PR-Only, Always (Major Version Bumps)
&lt;/h3&gt;

&lt;p&gt;Every major version bump, any package, regardless of tier. Major versions break APIs, change configuration formats, and introduce migration steps that no automated tool can reliably handle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terraform: Never Auto-Applied
&lt;/h3&gt;

&lt;p&gt;Every Terraform change goes through Atlantis: PR → plan → &lt;code&gt;atlantis apply&lt;/code&gt; comment → apply. No exceptions. Terraform changes physical and VM state on a single-point-of-failure host — that always gets a human in the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Alerts (and Where)
&lt;/h2&gt;

&lt;p&gt;Prometheus + Alertmanager route to Discord via webhook. ArgoCD's notifications-controller sends app-state events through a separate path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Critical Alerts (Always Discord)
&lt;/h3&gt;

&lt;p&gt;Everything with &lt;code&gt;severity: critical&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Proxmox host temperature&lt;/li&gt;
&lt;li&gt;k3s control-plane down&lt;/li&gt;
&lt;li&gt;Storage &amp;gt;93% full&lt;/li&gt;
&lt;li&gt;Postgres replication broken&lt;/li&gt;
&lt;li&gt;Certificate expiry soon&lt;/li&gt;
&lt;li&gt;Velero backup failed&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Warning Alerts (Selective)
&lt;/h3&gt;

&lt;p&gt;Only explicitly named warnings that matter at steady state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KubePodCrashLooping&lt;/code&gt; — a pod is crash-looping&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KubeJobFailed&lt;/code&gt; — a CronJob failed (covers Renovate itself)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KubeNodeNotReady&lt;/code&gt; — a node is unreachable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VeleroBackupPartialFailure&lt;/code&gt; — backup completed with warnings&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Deliberately NOT Alerted
&lt;/h3&gt;

&lt;p&gt;Routine warnings that would make Discord noisy without being actionable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KubeMemoryQuotaOvercommit&lt;/code&gt; — LXC soft ceilings, not real pressure&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KubeCPUThrottling&lt;/code&gt; — normal behavior for bursty workloads&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KubePodNotReady&lt;/code&gt; during rolling updates — temporary, self-resolving&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The principle: &lt;strong&gt;an alert nobody acts on trains people to ignore Discord.&lt;/strong&gt; Every alert must have a corresponding action in the runbook. If there's no action, there's no alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Self-Heals
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ArgoCD: Automatic Sync + Self-Heal
&lt;/h3&gt;

&lt;p&gt;All 41 live Applications have &lt;code&gt;syncPolicy.automated.selfHeal: true&lt;/code&gt;. A merged manifest change deploys itself. Manual &lt;code&gt;kubectl&lt;/code&gt; drift on anything ArgoCD tracks gets reverted automatically within minutes.&lt;/p&gt;

&lt;p&gt;This is the primary self-healing mechanism. If someone manually patches a Deployment (during debugging, for example), ArgoCD reverts it on the next sync cycle. The cluster always converges to the git state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker Containers: restart: unless-stopped
&lt;/h3&gt;

&lt;p&gt;Media stack, Minecraft, AdGuard, Unbound — all run with &lt;code&gt;restart: unless-stopped&lt;/code&gt; in Docker Compose. Crashes and host reboots self-recover the process. The data recovery is handled by NFS/ZFS, not Docker.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kyverno: Audit-Only (Deliberately)
&lt;/h3&gt;

&lt;p&gt;Kyverno runs in Audit mode — it reports policy violations in PolicyReports but does not block or auto-remediate. This is deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A bad policy in Enforce mode silently blocks legitimate deploys&lt;/li&gt;
&lt;li&gt;On a single-operator homelab, there's nobody to notice the block quickly&lt;/li&gt;
&lt;li&gt;Audit mode provides the visibility without the blast radius&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The policies exist (&lt;code&gt;require-resource-limits&lt;/code&gt;, &lt;code&gt;disallow-latest-tag&lt;/code&gt;, &lt;code&gt;disallow-privileged-containers&lt;/code&gt;) but they're watching, not enforcing. When I'm confident they won't cause false positives, they'll flip to Enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stays Manual (On Purpose)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Terraform Apply
&lt;/h3&gt;

&lt;p&gt;Always through Atlantis. A human comments &lt;code&gt;atlantis apply&lt;/code&gt;. Never auto-applied, regardless of what changed. This is the one rule with no exceptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stateful Service Bumps
&lt;/h3&gt;

&lt;p&gt;Authelia, Vault, CNPG, Garage — reviewed before merge. The failure mode is data loss or auth outage, not "reroll the pod."&lt;/p&gt;

&lt;h3&gt;
  
  
  Snapshots Before State-Affecting Changes
&lt;/h3&gt;

&lt;p&gt;Proxmox VM/CT snapshot or Velero PV snapshot, taken manually before applying anything that touches running state. The judgment of "is this change state-affecting" doesn't belong to a script.&lt;/p&gt;

&lt;h3&gt;
  
  
  Velero Restore Testing
&lt;/h3&gt;

&lt;p&gt;Running, tested successfully at least once per namespace. But not yet exercised across every service. A backup that's only partly restore-tested is closer to hope than guarantee.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full Cluster Rebuild
&lt;/h3&gt;

&lt;p&gt;Documented in &lt;code&gt;DISASTER-RECOVERY.md&lt;/code&gt;. Not automated. The target is fast, well-documented recovery — not zero-touch failover, because true HA isn't achievable on one physical host.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Non-Goals
&lt;/h2&gt;

&lt;p&gt;These are explicitly &lt;strong&gt;not&lt;/strong&gt; targets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-downtime HA.&lt;/strong&gt; Not achievable with one physical host. Not attempted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully unattended major-version upgrades.&lt;/strong&gt; Stateful service upgrades have repeatedly needed human judgment mid-migration (Postgres mount-point gotcha, capability-drop regression). Automating past that trades a 10-minute manual step for a much longer unattended-failure cleanup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerting on everything.&lt;/strong&gt; Deliberately tuned to steady-state-relevant signals only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Contract
&lt;/h2&gt;

&lt;p&gt;This document is the contract between the infrastructure and the operator. Everything not listed here is assumed to be working. If it's not working and it's not in this document, it's a gap — not an expected manual task.&lt;/p&gt;

&lt;p&gt;The operating model is a living document. As the cluster evolves (Cilium CNI, R2 offsite backup, Kyverno enforcement), the categories shift. But the principle stays: &lt;strong&gt;automate what's safe, alert what's important, and leave everything else to a human with context.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;This operating model maps directly to enterprise SRE practices: SLO-based alerting replaces threshold alerting, runbooks define the human response for each alert type, and change management gates (like Atlantis's &lt;code&gt;apply&lt;/code&gt; requirement) prevent unreviewed infrastructure changes. The difference is that enterprise environments have teams; a homelab has one person who needs to sleep through the night without Discord notifications.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4f4RHQf"&gt;The Phoenix Project*&lt;/a&gt; is the book that most shaped this document - it's fiction, but the underlying argument (know exactly what's automated, what's gated, and why) is the same one this operating model tries to make explicit instead of leaving implicit.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>gitops</category>
      <category>operations</category>
    </item>
    <item>
      <title>CPU Scheduling for etcd: Why Proxmox cpu.units Matters</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:35:09 +0000</pubDate>
      <link>https://dev.to/dwoitzik/cpu-scheduling-for-etcd-why-proxmox-cpuunits-matters-5fe9</link>
      <guid>https://dev.to/dwoitzik/cpu-scheduling-for-etcd-why-proxmox-cpuunits-matters-5fe9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/cpu-scheduling-etcd-proxmox-units/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; this cluster has since moved off etcd entirely — a single-server k3s setup uses the&lt;br&gt;
embedded SQLite datastore by default, and the multi-server HA path that would have used etcd&lt;br&gt;
was never turned on here. The CPU-contention problem and the &lt;code&gt;cpu.units&lt;/code&gt; fix below were real&lt;br&gt;
while etcd was in play, and the underlying lesson (latency-sensitive, write-heavy workloads need&lt;br&gt;
scheduling priority when they share a host with bursty ones like LLM inference or game servers)&lt;br&gt;
still holds for anyone running real multi-server etcd today. Left as a historical/general&lt;br&gt;
reference rather than a description of this cluster's current architecture.&lt;/p&gt;

&lt;p&gt;etcd is the brain of Kubernetes. Every API call, every configmap update, every pod scheduling decision goes through etcd. When etcd is slow, everything is slow. When etcd times out, the API server becomes unreachable.&lt;/p&gt;

&lt;p&gt;On a single Proxmox host running k3s VMs alongside Ollama LLM inference, Minecraft game servers, and Docker media workloads, etcd shares CPU with everything else. Without scheduling priority, a Minecraft player join and an Ollama model load can delay etcd's fdatasync calls enough to trigger leader-election timeouts.&lt;/p&gt;

&lt;p&gt;The fix: &lt;code&gt;cpu.units = 2048&lt;/code&gt; on k3s VMs, giving them 2x the CPU scheduling priority over every other workload on the host — the same VMs that get &lt;a href="https://dev.to/blog/staggered-vm-boot-load-average-147/"&gt;staggered boot ordering&lt;/a&gt; to prevent I/O storms in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  How Proxmox CPU Scheduling Works
&lt;/h2&gt;

&lt;p&gt;Proxmox uses the Linux CFS (Completely Fair Scheduler) with added weight controls. Each VM gets a &lt;code&gt;cpu.units&lt;/code&gt; value that determines its share of CPU time when multiple VMs compete for the same physical cores.&lt;/p&gt;

&lt;p&gt;The default is &lt;code&gt;units = 1024&lt;/code&gt;. When two VMs with equal units compete for a single core, they each get 50% of the CPU time. When one VM has &lt;code&gt;units = 2048&lt;/code&gt; and another has &lt;code&gt;units = 1024&lt;/code&gt;, the first gets 2/3 and the second gets 1/3.&lt;/p&gt;

&lt;p&gt;This is not CPU pinning (which restricts a VM to specific cores). It's CPU weighting (which determines priority when cores are shared). On a host with 16 threads and 12+ VMs/LXCs, most cores are shared between multiple workloads.&lt;/p&gt;
&lt;h2&gt;
  
  
  The etcd Problem
&lt;/h2&gt;

&lt;p&gt;etcd's performance depends on write latency. Every key-value operation (lease renewal, configmap update, secret sync) requires an fdatasync to the WAL (Write-Ahead Log) on disk. etcd's leader-election timeout is 5 seconds — if the leader can't renew its lease within that window, controller-runtime terminates the process.&lt;/p&gt;

&lt;p&gt;On a single NVMe shared by all VMs, etcd's fdatasync competes with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ollama reading model weights from disk (14GB qwen2.5 model)&lt;/li&gt;
&lt;li&gt;Minecraft world writes from the DMZ game server&lt;/li&gt;
&lt;li&gt;NFS serving PVC data to k3s pods&lt;/li&gt;
&lt;li&gt;ZFS txg commits flushing dirty data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under normal load, this is fine — NVMe IOPS are high enough to handle all of them. But when Ollama starts loading a 26B model (Gemma 4) and Minecraft generates terrain simultaneously, the NVMe queue depth spikes, fdatasync latency increases, and etcd's lease renewal window shrinks.&lt;/p&gt;

&lt;p&gt;Without CPU priority, etcd's fdatasync also competes for CPU time with these workloads. The kernel's CFS scheduler doesn't know that etcd's fdatasync is more important than Ollama's matrix multiplication — it just sees two processes requesting CPU time and allocates it equally.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/stacks/proxmox/vm.tf&lt;/span&gt;

&lt;span class="c1"&gt;# k3s VMs — 2x scheduling priority&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"vm_srv_k3s_11"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cpu&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;  &lt;span class="c1"&gt;# 2x default&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Ollama LXC — default priority&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"ct_srv_ai_01"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cpu&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;
    &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;  &lt;span class="c1"&gt;# default&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Minecraft LXC — default priority&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"ct_dmz_games_01"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cpu&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;  &lt;span class="c1"&gt;# default&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The k3s VMs get &lt;code&gt;units = 2048&lt;/code&gt;. Everything else stays at the default &lt;code&gt;1024&lt;/code&gt;. When etcd and Ollama compete for the same CPU cycle, etcd gets 2/3 of the time and Ollama gets 1/3.&lt;/p&gt;

&lt;p&gt;This doesn't reduce Ollama's throughput under normal conditions — when there's no contention, Ollama still gets 100% of the CPU it requests. The priority only kicks in when multiple workloads compete for the same cores simultaneously.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Evidence
&lt;/h2&gt;

&lt;p&gt;Before &lt;code&gt;cpu.units = 2048&lt;/code&gt;, the CNPG operator was restarting due to leader-election timeouts:&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 pods &lt;span class="nt"&gt;-n&lt;/span&gt; cnpg-system
&lt;span class="c"&gt;# NAME                                    RESTARTS&lt;/span&gt;
&lt;span class="c"&gt;# cnpg-cloudnative-pg-5f8b9c4d6-xk2p4   310&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;310 restarts in 21 days. Each restart's log showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Leader election retry deadline exceeded
context deadline exceeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After applying &lt;code&gt;cpu.units = 2048&lt;/code&gt; and the corresponding leader-election timeout increase (&lt;code&gt;--leader-renew-deadline=50&lt;/code&gt;), restarts dropped to zero.&lt;/p&gt;

&lt;p&gt;The CPU priority alone didn't fix it — the leader-election timeout increase was also necessary. But the CPU priority reduced the frequency of fdatasync delays enough that the 50-second deadline is never challenged under normal load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RAM Priority Interaction
&lt;/h2&gt;

&lt;p&gt;CPU priority and memory priority are separate in Proxmox. &lt;code&gt;cpu.units&lt;/code&gt; affects CPU scheduling; &lt;code&gt;memory.dedicated&lt;/code&gt; and &lt;code&gt;memory.floating&lt;/code&gt; affect RAM allocation.&lt;/p&gt;

&lt;p&gt;On this host, both matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;etcd needs low-latency CPU for fdatasync (solved by &lt;code&gt;cpu.units&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;k3s control-plane needs guaranteed RAM for API server and scheduler (solved by &lt;code&gt;memory.dedicated = 12284&lt;/code&gt;, the same VM-vs-LXC memory model covered in &lt;a href="https://dev.to/blog/overcommit-guard-python-host-freeze/"&gt;the overcommit guard writeup&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The RAM allocation is a hard reservation — 12 GB is always available for the k3s-11 VM. The CPU priority is a soft weighting — it only matters when cores are shared. Both are necessary: without RAM priority, the k3s VM could be ballooned down to 4 GB under pressure; without CPU priority, etcd could lose the fdatasync race to Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Change
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pin etcd to specific cores.&lt;/strong&gt; CPU pinning would guarantee etcd always has CPU available, rather than just having priority. But pinning reduces overall CPU utilization — a pinned core can't be used by other VMs even when etcd is idle. On a 16-thread host with 12+ workloads, the utilization loss isn't worth it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Monitor etcd fdatasync latency directly.&lt;/strong&gt; Prometheus can scrape etcd's &lt;code&gt;etcd_disk_wal_fsync_duration_seconds&lt;/code&gt; metric. An alert on p99 &amp;gt; 100ms would catch CPU contention before it triggers leader-election timeouts.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;CPU scheduling priority is the same concept as Azure VM series selection: E-series VMs are memory-optimized, F-series are compute-optimized, and Dv5-series offer balanced resources. Choosing the wrong series for etcd (a latency-sensitive, write-heavy workload) produces the same performance degradation as running it without &lt;code&gt;cpu.units&lt;/code&gt; on Proxmox. The difference is that Azure makes the choice at VM creation time, while Proxmox lets you adjust it dynamically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4aUtkCs"&gt;Designing Data-Intensive Applications*&lt;/a&gt; is the best resource I know for understanding why a consensus system like etcd is so much more latency-sensitive than an ordinary stateless workload in the first place.&lt;/p&gt;



</description>
      <category>proxmox</category>
      <category>kubernetes</category>
      <category>performance</category>
      <category>homelab</category>
    </item>
    <item>
      <title>3-2-1 Backup in Practice: Velero + PBS + Offsite That Doesn't Work Yet</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:24:26 +0000</pubDate>
      <link>https://dev.to/dwoitzik/3-2-1-backup-in-practice-velero-pbs-offsite-that-doesnt-work-yet-52nc</link>
      <guid>https://dev.to/dwoitzik/3-2-1-backup-in-practice-velero-pbs-offsite-that-doesnt-work-yet-52nc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/321-backup-velero-pbs-offsite/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The 3-2-1 backup rule is simple: 3 copies of your data, on 2 different media types, with 1 copy offsite. My homelab implements all three layers. In theory. In practice, each layer has its own failure mode that I discovered only when I needed it — starting with &lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;the Garage S3 target Velero backs into&lt;/a&gt;, which is where layer 1 lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Layers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layer 1: Velero → Garage S3 (in-cluster, daily 05:00 UTC)
    ↓
Layer 2: PBS → External HDD (local, daily 03:00 UTC)
    ↓
Layer 3: rclone → Google Drive (offsite, DISABLED)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Layer 1: Velero → Garage S3
&lt;/h3&gt;

&lt;p&gt;Velero backs up k3s namespaces (apps, vault, database, argocd) to Garage S3 — a self-hosted S3-compatible object store running inside the same cluster.&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="c1"&gt;# kubernetes/system/velero/schedule.yml&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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;defaultVolumesToFsBackup&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;includedNamespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vault"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;database"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;argocd"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;excludedResources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events.events.k8s.io"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;720h&lt;/span&gt;  &lt;span class="c1"&gt;# 30 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;defaultVolumesToFsBackup: true&lt;/code&gt; was the critical fix. Without it, Velero only captured Kubernetes manifests — not the actual PVC data. The backups "completed" for weeks with zero data.&lt;/p&gt;

&lt;p&gt;After the fix, Kopia sidecars run alongside each pod, copying PVC contents to the Garage S3 &lt;code&gt;velero&lt;/code&gt; bucket. Daily backups, 30-day retention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catch:&lt;/strong&gt; Garage runs inside the cluster. If the cluster dies, both Velero and Garage are gone. Layer 1 is useful for recovering individual PVCs or namespaces within a running cluster — not for full cluster recovery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: PBS → External HDD
&lt;/h3&gt;

&lt;p&gt;Proxmox Backup Server (PBS) backs up VM and LXC disk images to a &lt;a href="https://dev.to/go/4vxwLpY"&gt;Seagate 2TB External HDD*&lt;/a&gt; connected via USB.&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="c"&gt;# PBS backup job — runs daily at 03:00 UTC&lt;/span&gt;
&lt;span class="c"&gt;# Backs up all VMs and LXCs to /mnt/backup (USB HDD)&lt;/span&gt;
proxmox-backup-client backup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository&lt;/span&gt; &lt;span class="nb"&gt;local&lt;/span&gt;:/mnt/backup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ns&lt;/span&gt; homelab &lt;span class="se"&gt;\&lt;/span&gt;
  vm/110/pct/200/pct/210/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PBS handles deduplication, compression, and incremental backups. The USB HDD provides local, offline backup that survives cluster failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gotcha:&lt;/strong&gt; PBS and the k3s VMs share the same physical host. A host-level failure (PSU, NVMe death) takes out both the primary data and the PBS backup. Layer 2 is protection against software failure (corruption, accidental deletion), not hardware failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: rclone → Google Drive (DISABLED)
&lt;/h3&gt;

&lt;p&gt;The offsite layer was supposed to be rclone syncing from Garage S3 to Google Drive. In practice, Google's API throttled the sync to 1.6 KiB/s:&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="go"&gt;Transferred:   1.6 KiB / 4.2 GiB,  0.00%
Elapsed time:  2h 30m
Transfer rate: 1.6 KiB/s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google Drive's API rate limiting for server-to-server transfers (no user interaction) is aggressive. For 4 GB of data, the sync would take days. Combined with Google's 750 GB/day upload limit for personal accounts, and the fact that the sync would need to run continuously to keep up with daily backups, the offsite layer was disabled.&lt;/p&gt;

&lt;p&gt;The replacement (Cloudflare R2) is scaffolded but not active:&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="c1"&gt;# kubernetes/system/velero/offsite-schedule.yml&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;velero.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;Schedule&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;daily-offsite&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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;includedNamespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vault"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;database"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;argocd"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;storageLocation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;r2-offsite&lt;/span&gt;
    &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;168h&lt;/span&gt;  &lt;span class="c1"&gt;# 7 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Waiting on a Cloudflare R2 account with real API credentials. R2 has no egress fees and no API rate limiting for the volume this backup produces (~4 GB/day). Activating it also fixes a second problem beyond throttling: Garage runs inside the same cluster Velero is protecting, so an in-cluster-only backup target is circular regardless of how fast it uploads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Restore Gotcha
&lt;/h2&gt;

&lt;p&gt;The first time I tested a full restore, PBS hit an IP/MAC conflict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: VM 211 is running on a different node (10.0.20.11)
Proxmox cannot start the VM because the MAC address is already in use
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue: PBS restores the VM's network configuration exactly as it was, including the MAC address. If the original VM is still running (or its MAC is cached in the bridge), the restored VM can't start because Proxmox detects a MAC conflict on the virtual bridge.&lt;/p&gt;

&lt;p&gt;The fix: restore to a temporary VMID, verify the restore is complete, then shut down the original and rename the restored VM. This is a manual multi-step process — not something you want to do under time pressure during a real disaster.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Works
&lt;/h2&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;Protects Against&lt;/th&gt;
&lt;th&gt;Doesn't Protect Against&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Velero → Garage&lt;/td&gt;
&lt;td&gt;PVC corruption, namespace deletion, accidental kubectl delete&lt;/td&gt;
&lt;td&gt;Cluster-wide failure (Garage is in-cluster)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PBS → USB HDD&lt;/td&gt;
&lt;td&gt;Software corruption, accidental VM deletion, ZFS pool issues&lt;/td&gt;
&lt;td&gt;Host hardware failure (PBS is on the same host)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rclone → Google Drive&lt;/td&gt;
&lt;td&gt;Host hardware failure, theft, fire&lt;/td&gt;
&lt;td&gt;Nothing yet — disabled due to throttling&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest assessment: I have two functional backup layers, both on the same physical host. True offsite backup doesn't exist yet. The 3-2-1 rule is aspirational, not achieved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verification Gap
&lt;/h2&gt;

&lt;p&gt;The most important lesson: &lt;strong&gt;a backup you haven't restored is not a backup.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Velero backups run daily, PBS runs daily, and until recently neither had been fully restored. The Velero &lt;code&gt;defaultVolumesToFsBackup&lt;/code&gt; gap existed for weeks because nobody tested a restore. The PBS IP conflict was discovered during the first full restore test.&lt;/p&gt;

&lt;p&gt;After these discoveries, I added:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Monthly Velero restore test&lt;/strong&gt; — restore the &lt;code&gt;database&lt;/code&gt; namespace to a temporary namespace, verify Postgres starts and contains expected data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarterly PBS restore test&lt;/strong&gt; — restore one VM to a temporary VMID, verify it boots and services are functional&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-backup verification&lt;/strong&gt; — &lt;code&gt;velero backup describe --details | grep "Pod Volume Backups"&lt;/code&gt; after every scheduled backup&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Backup verification is the same compliance requirement in Azure: ISO 27001 and NIS2 both require documented, tested restore procedures. Azure Backup reports "Completed" for VM snapshots, but the snapshot might not include the data disk if the backup policy was misconfigured. The only way to verify is to actually restore a test VM and confirm its contents — the same monthly drill I now run against Velero and PBS.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>backup</category>
      <category>homelab</category>
      <category>disasterrecovery</category>
    </item>
    <item>
      <title>NFS vs local-path: The SQLite Trap That Corrupted My S3 Metadata</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:43:37 +0000</pubDate>
      <link>https://dev.to/dwoitzik/nfs-vs-local-path-the-sqlite-trap-that-corrupted-my-s3-metadata-h26</link>
      <guid>https://dev.to/dwoitzik/nfs-vs-local-path-the-sqlite-trap-that-corrupted-my-s3-metadata-h26</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/nfs-vs-local-path-sqlite-trap/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Garage S3&lt;/a&gt;, my self-hosted S3-compatible object store, uses SQLite for its metadata database. One morning, Terraform state operations started failing with &lt;code&gt;SQLITE_CORRUPT&lt;/code&gt;. The bucket metadata was gone. The &lt;code&gt;terraform-state&lt;/code&gt; bucket, the Atlantis lock table, the Velero backup index — all of it stored in a SQLite file that was now corrupted.&lt;/p&gt;

&lt;p&gt;The root cause: Garage was running on an NFS-backed PersistentVolume. NFS doesn't support the file-locking primitives that SQLite's WAL (Write-Ahead Logging) mode requires. Under concurrent access — Garage's metadata writer and a Velero backup reading the same database — the NFS lock delegation failed silently, and SQLite wrote to overlapping pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/homelab-infrastructure" rel="noopener noreferrer"&gt;View the complete homelab infrastructure source on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Storage Classes
&lt;/h2&gt;

&lt;p&gt;My k3s cluster has two StorageClasses:&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="c1"&gt;# NFS — for most workloads&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;storage.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;StorageClass&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;nfs-client&lt;/span&gt;
&lt;span class="na"&gt;provisioner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-subdir-external-provisioner&lt;/span&gt;
&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.20.100&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/archive&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;Retain&lt;/span&gt;

&lt;span class="c1"&gt;# Local-path — for embedded databases&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;storage.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;StorageClass&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;local-path&lt;/span&gt;
&lt;span class="na"&gt;provisioner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rancher.io/local-path&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;Retain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NFS (&lt;code&gt;nfs-client&lt;/code&gt;) is the default. It's backed by a dedicated LXC running an NFS server on ZFS, providing storage that survives pod rescheduling — a pod on k3s-12 can access the same PVC as a pod on k3s-13 because the NFS server is independent of any specific node.&lt;/p&gt;

&lt;p&gt;Local-path (&lt;code&gt;local-path&lt;/code&gt;) pins the PV to whichever node created it. If the pod reschedules to a different node, the PVC is inaccessible until the pod returns to the original node. This is a limitation, but it's the right trade-off for certain workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SQLite and NFS Don't Work
&lt;/h2&gt;

&lt;p&gt;SQLite's WAL mode requires &lt;code&gt;fcntl()&lt;/code&gt; file locks — specifically, &lt;code&gt;F_SETLK&lt;/code&gt; (non-blocking lock) and &lt;code&gt;F_SETLKW&lt;/code&gt; (blocking lock). These locks coordinate access between concurrent processes writing to the same database file.&lt;/p&gt;

&lt;p&gt;NFS handles file locks differently:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;NFSv3&lt;/strong&gt;: No native lock support. &lt;code&gt;fcntl()&lt;/code&gt; calls return success but locks are local to the client — two NFS clients can both acquire an "exclusive" lock on the same file simultaneously.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;NFSv4&lt;/strong&gt;: Has &lt;code&gt;LOCK&lt;/code&gt; operations, but the lock delegation model introduces latency and failure modes that SQLite's tight locking loop doesn't tolerate. If the NFS server is slow to respond to a lock request, SQLite's default 5-second busy timeout can expire, causing the application to retry — and the retry can conflict with the lock held by another client.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kubernetes NFS provisioner&lt;/strong&gt;: The &lt;code&gt;nfs-subdir-external-provisioner&lt;/code&gt; uses NFSv4, but the lock delegation is handled by the NFS server's &lt;code&gt;rpc.lockd&lt;/code&gt; daemon, which runs in a separate process space. Under concurrent load, &lt;code&gt;lockd&lt;/code&gt; can lose track of which client holds which lock.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: SQLite thinks it has an exclusive lock, but another process (or the same process on a different connection) also has a lock. Both write to the database file. Pages overlap. The database corrupts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Garage Incident
&lt;/h2&gt;

&lt;p&gt;Garage runs with two storage mounts:&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="c1"&gt;# kubernetes/apps/garage/garage.yml&lt;/span&gt;
&lt;span class="na"&gt;volumes&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;data&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;garage-data&lt;/span&gt;      &lt;span class="c1"&gt;# NFS — bucket objects&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;meta&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;garage-meta&lt;/span&gt;      &lt;span class="c1"&gt;# local-path — SQLite metadata&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;data&lt;/code&gt; volume (bucket objects) is on NFS — fine, because S3 object storage doesn't use file locks for individual files. The &lt;code&gt;meta&lt;/code&gt; volume (SQLite database) was &lt;em&gt;also&lt;/em&gt; on NFS initially. This worked until a Velero backup and a Terraform state write happened simultaneously.&lt;/p&gt;

&lt;p&gt;Velero reads Garage's S3 API to enumerate backup objects. Terraform reads the same database to verify state file existence. Both hit SQLite through Garage's metadata layer. Under NFS, the concurrent reads triggered the lock delegation failure, and SQLite corrupted pages 169–184 of &lt;code&gt;db.sqlite&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Recovery
&lt;/h2&gt;

&lt;p&gt;SQLite has a &lt;code&gt;.recover&lt;/code&gt; command that can extract data from a corrupted database:&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="c"&gt;# Dump recoverable data from corrupted SQLite&lt;/span&gt;
sqlite3 db.sqlite &lt;span class="s2"&gt;".recover"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; recovered.sql

&lt;span class="c"&gt;# Recreate the database from the dump&lt;/span&gt;
sqlite3 db_clean.sqlite &amp;lt; recovered.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.recover&lt;/code&gt; command scanned every page of the corrupted file and extracted whatever data it could read. For pages 169–184 (the corrupted range), it found partial data — enough to reconstruct the bucket and key metadata, but not enough to guarantee referential integrity.&lt;/p&gt;

&lt;p&gt;After recovery, the missing objects (terraform-state bucket and Atlantis lock key) had to be re-inserted manually via Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;msgpack&lt;/span&gt;

&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db_clean.sqlite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Garage uses msgpack-encoded metadata with 'G2key'/'G2bkt' prefixes
# Re-insert the terraform-state bucket
&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO buckets (name, ...) VALUES (...)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data was restored, but the trust was gone. The database could corrupt again under the same conditions.&lt;/p&gt;

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

&lt;p&gt;Move every embedded database to &lt;code&gt;local-path&lt;/code&gt;:&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="c1"&gt;# Garage — meta volume moved to local-path&lt;/span&gt;
&lt;span class="na"&gt;volumes&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;meta&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;garage-meta&lt;/span&gt;      &lt;span class="c1"&gt;# NOW: local-path (was: nfs-client)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The apps that need &lt;code&gt;local-path&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Why local-path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Garage S3&lt;/td&gt;
&lt;td&gt;SQLite (metadata)&lt;/td&gt;
&lt;td&gt;File-locking requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mealie&lt;/td&gt;
&lt;td&gt;SQLite (recipes)&lt;/td&gt;
&lt;td&gt;WAL mode + concurrent access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Home Assistant&lt;/td&gt;
&lt;td&gt;SQLite (state)&lt;/td&gt;
&lt;td&gt;Inotify-based DB writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authelia&lt;/td&gt;
&lt;td&gt;PostgreSQL (CNPG)&lt;/td&gt;
&lt;td&gt;CNPG manages its own PV&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PostgreSQL (via CNPG) doesn't have the SQLite lock problem because it uses its own file locking, but CNPG requires &lt;code&gt;local-path&lt;/code&gt; or a CSI driver that supports &lt;code&gt;ReadWriteOnce&lt;/code&gt; — NFS's &lt;code&gt;ReadWriteMany&lt;/code&gt; semantics can confuse CNPG's WAL archiving.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trade-off
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;local-path&lt;/code&gt; means the PVC is pinned to one node. If the pod reschedules, it loses access to the data. For Garage, this is acceptable: Garage runs on a single node, and if that node goes down, the S3 data is unavailable regardless (it's on the same host).&lt;/p&gt;

&lt;p&gt;For databases that need HA (Postgres, Redis), the solution isn't &lt;code&gt;local-path&lt;/code&gt; or NFS — it's a managed operator (CNPG for Postgres) that handles replication and failover independently of the storage layer.&lt;/p&gt;

&lt;p&gt;The principle: &lt;strong&gt;if the application uses file-level locking (SQLite, BoltDB, LMDB), it goes on local-path. If it uses network-level locking (PostgreSQL, MySQL), it goes on NFS or a managed operator.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;SQLite on NFS is the same failure mode as running SQLite on an SMB share in a Windows domain: the file-locking semantics are fundamentally incompatible. In Azure, this maps to Azure Files (SMB-backed) vs. Azure Disk (block storage). Azure Files supports SMB locks but has the same delegation latency issues under concurrent access — any application that needs tight file-level locking should use Azure Disks, not Azure Files. The principle is identical: embedded databases need local, low-latency storage with native file-locking support.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4aUtkCs"&gt;Designing Data-Intensive Applications*&lt;/a&gt; covers exactly this class of correctness assumption — what a storage layer actually guarantees about concurrent access versus what an application silently assumes it guarantees — in far more depth than a corrupted &lt;code&gt;db.sqlite&lt;/code&gt; file teaches you in the moment.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>storage</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
