<?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>Vault Auto-Unseal Without Cloud KMS: The Polling Sidecar Pattern</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 24 Aug 2026 07:34:21 +0000</pubDate>
      <link>https://dev.to/dwoitzik/vault-auto-unseal-without-cloud-kms-the-polling-sidecar-pattern-3f70</link>
      <guid>https://dev.to/dwoitzik/vault-auto-unseal-without-cloud-kms-the-polling-sidecar-pattern-3f70</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/vault-auto-unseal-polling-sidecar/" 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;HashiCorp Vault Enterprise has auto-unseal: a sealed Vault automatically unseals using a cloud KMS (AWS KMS, Azure Key Vault, GCP Cloud KMS). Vault OSS doesn't. Every time a Vault pod restarts — node failure, OOMKill, Kubernetes rescheduling — someone has to manually unseal it with &lt;code&gt;vault operator unseal&lt;/code&gt; using the unseal keys.&lt;/p&gt;

&lt;p&gt;On a homelab cluster where Vault backs ExternalSecrets for 25+ services, a sealed Vault means every ExternalSecret refresh fails. &lt;a href="https://dev.to/blog/k3s-authelia-proxmox-homelab/"&gt;Authelia&lt;/a&gt; can't start (no hmac-secret), Open WebUI can't start (no WEBUI_SECRET_KEY), and half the cluster sits in init-container loops waiting for secrets that Vault can't provide.&lt;/p&gt;

&lt;p&gt;The fix: a polling sidecar that auto-unseals Vault OSS without a KMS. The trade-off is a collapsed security boundary — but for a homelab, it's the right trade-off.&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 Sidecar
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;vault-unseal&lt;/code&gt; Deployment runs as a separate pod in the &lt;code&gt;vault&lt;/code&gt; namespace, polling every 5 seconds:&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/vault/unseal.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;apps/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;Deployment&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;vault-unseal&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;vault&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;replicas&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;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault-unseal&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;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault-unseal&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;vault-unseal&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;hashicorp/vault:1.21.4&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/sh&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;while true; do&lt;/span&gt;
                &lt;span class="s"&gt;# Check if Vault is sealed&lt;/span&gt;
                &lt;span class="s"&gt;SEALED=$(vault status -format=json | jq -r .sealed)&lt;/span&gt;
                &lt;span class="s"&gt;if [ "$SEALED" = "true" ]; then&lt;/span&gt;
                  &lt;span class="s"&gt;# Read unseal keys from the Kubernetes Secret&lt;/span&gt;
                  &lt;span class="s"&gt;for i in 1 2 3; do&lt;/span&gt;
                    &lt;span class="s"&gt;KEY=$(kubectl get secret vault-unseal-keys \&lt;/span&gt;
                      &lt;span class="s"&gt;-n vault -o jsonpath="{.data.key$i}" | base64 -d)&lt;/span&gt;
                    &lt;span class="s"&gt;vault operator unseal "$KEY"&lt;/span&gt;
                  &lt;span class="s"&gt;done&lt;/span&gt;
                  &lt;span class="s"&gt;echo "Vault unsealed at $(date)"&lt;/span&gt;
                &lt;span class="s"&gt;fi&lt;/span&gt;
                &lt;span class="s"&gt;sleep 5&lt;/span&gt;
              &lt;span class="s"&gt;done&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;VAULT_ADDR&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;http://vault.vault.svc.cluster.local:8200"&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;requests&lt;/span&gt;&lt;span class="pi"&gt;:&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;10m&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;32Mi&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;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;50m&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;64Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod needs &lt;code&gt;kubectl&lt;/code&gt; access to read the unseal keys from a Kubernetes Secret. The &lt;code&gt;vault-unseal&lt;/code&gt; ServiceAccount has a Role that grants &lt;code&gt;get&lt;/code&gt; on the &lt;code&gt;vault-unseal-keys&lt;/code&gt; Secret only.&lt;/p&gt;

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

&lt;p&gt;In a KMS-based auto-unseal setup, the unseal keys are never stored anywhere accessible to the cluster. The KMS holds the master key, and Vault uses it to decrypt the master key that seals the storage. An attacker who compromises the cluster can't unseal Vault because the KMS key is outside the cluster boundary.&lt;/p&gt;

&lt;p&gt;In the polling sidecar pattern, the unseal keys are stored in a Kubernetes Secret (&lt;code&gt;vault-unseal-keys&lt;/code&gt;). Anyone who can read that Secret — through &lt;code&gt;kubectl&lt;/code&gt;, through a compromised pod with the right ServiceAccount, through an etcd backup — can unseal Vault.&lt;/p&gt;

&lt;p&gt;This collapses the security boundary: Vault's unseal protection becomes "Kubernetes RBAC on one Secret" instead of "cloud KMS with its own IAM policy." For a homelab, this is acceptable because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The cluster is not exposed to untrusted users&lt;/li&gt;
&lt;li&gt;The threat model is "protect against accidental unseal, not nation-state attacker"&lt;/li&gt;
&lt;li&gt;Manual unseal after every restart is operationally unsustainable&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For production, use KMS-based auto-unseal. For a homelab where the alternative is "Vault stays sealed until I notice," the sidecar is the pragmatic choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 5 Seconds
&lt;/h2&gt;

&lt;p&gt;The initial implementation polled every 30 seconds. This created a window where Vault was sealed but the sidecar hadn't tried to unseal it yet. During that window:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ExternalSecret refresh requests failed&lt;/li&gt;
&lt;li&gt;Authelia's init container couldn't read hmac-secret&lt;/li&gt;
&lt;li&gt;Any service that depends on Vault-backed secrets was stuck&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;30 seconds of cluster-wide secret unavailability on every Vault restart. Tightening to 5 seconds reduced the seal window to an acceptable range — most Vault restarts complete unseal within 5 seconds, and the downstream impact is minimal.&lt;/p&gt;

&lt;p&gt;The cost: the sidecar makes one &lt;code&gt;vault status&lt;/code&gt; call and potentially three &lt;code&gt;vault operator unseal&lt;/code&gt; calls every 5 seconds. On Vault's API, this is negligible — it's health-check-level traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ExternalSecret Dependency Chain
&lt;/h2&gt;

&lt;p&gt;The real reason Vault auto-unseal matters: every ExternalSecret in the cluster depends on Vault being unsealed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vault sealed
  → ExternalSecret refresh fails
    → Kubernetes Secrets not updated
      → Pods using those secrets start with stale/missing data
        → Authelia can't start (no hmac-secret)
        → Open WebUI can't start (no WEBUI_SECRET_KEY)
        → Paperless can't start (no database password)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chain reaction is invisible until you check pod logs and see &lt;code&gt;secret "authelia-secrets" not found&lt;/code&gt; or &lt;code&gt;connection refused&lt;/code&gt; to Postgres (because the password never synced from Vault).&lt;/p&gt;

&lt;p&gt;Before the sidecar, I'd come back to a sealed Vault after a node restart and spend 15 minutes manually unsealing with three key shares while half the cluster sat in CrashLoopBackOff. The sidecar turned a 15-minute manual operation into a 5-second automatic one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Network Policy
&lt;/h2&gt;

&lt;p&gt;Vault's ingress is locked down:&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/vault/network-policies.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;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&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;vault-allow-intra-namespace&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;vault&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;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&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;Ingress"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
          &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
          &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-secrets&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default-deny ingress on the &lt;code&gt;vault&lt;/code&gt; namespace. Only two sources can reach Vault's API port 8200: other pods in the &lt;code&gt;vault&lt;/code&gt; namespace (the unseal sidecar), and External Secrets Operator pods. Everything else is blocked.&lt;/p&gt;

&lt;p&gt;This is the minimum viable network segmentation for Vault: it needs to be reachable by ESO for secret syncing and by the unseal sidecar for auto-unseal, but nothing else needs direct Vault API access.&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;Use Vault's built-in auto-unseal with a cloud KMS if available.&lt;/strong&gt; The sidecar is a workaround for Vault OSS limitations. If you're running Vault Enterprise or can tolerate the cost of a cloud KMS, use it. The sidecar exists because my homelab doesn't have a KMS. Vault's &lt;em&gt;configuration&lt;/em&gt; (policies, auth roles) is a separate concern from unsealing — see &lt;a href="https://dev.to/blog/vault-terraform-config-staged-migration/"&gt;the staged Terraform migration&lt;/a&gt; for how that part is managed without touching unseal keys.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Store unseal keys in a more secure backend.&lt;/strong&gt; The Kubernetes Secret is the weakest link. An improvement would be to store the keys in an HSM or a separate, more restricted secret backend. But at that point, you've basically built KMS-based auto-unseal from scratch.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add monitoring on the sidecar.&lt;/strong&gt; An alert when Vault transitions from sealed to unseal would provide visibility into restart frequency and sidecar health.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Vault auto-unseal without KMS is the same problem as managing encryption keys in environments without HSMs: you're trading security boundary strength for operational practicality. Azure Key Vault Managed HSM provides FIPS 140-2 Level 3 key protection — but it costs money and adds complexity. For non-production environments, the polling sidecar gives you 90% of the operational benefit at 10% of the security cost. The key is knowing which trade-off you're making.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>vault</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Chaos Mesh in a Homelab: Weekly Pod-Kills on a Single-Host Cluster</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 24 Aug 2026 07:13:35 +0000</pubDate>
      <link>https://dev.to/dwoitzik/chaos-mesh-in-a-homelab-weekly-pod-kills-on-a-single-host-cluster-9nj</link>
      <guid>https://dev.to/dwoitzik/chaos-mesh-in-a-homelab-weekly-pod-kills-on-a-single-host-cluster-9nj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/chaos-mesh-homelab-single-host/" 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;Chaos engineering sounds like a production-only practice — inject failures, measure impact, improve resilience. But the biggest value of chaos testing isn't catching production failures. It's finding the problems you didn't know existed, in infrastructure you thought was solid, before they find you.&lt;/p&gt;

&lt;p&gt;I run two weekly chaos experiments on my single-host k3s cluster: a pod-kill every Sunday at 03:00 UTC, and a 100ms network latency injection at 03:30 UTC. Both are scoped to pods labeled &lt;code&gt;chaos-kill: enabled&lt;/code&gt; in the &lt;code&gt;apps&lt;/code&gt; namespace. Here's what they've taught me.&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 Setup
&lt;/h2&gt;

&lt;p&gt;Chaos Mesh runs in the &lt;code&gt;chaos-mesh&lt;/code&gt; namespace, deployed via Helm chart v2.8.3:&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/chaos-mesh/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;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;chaos-mesh&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;argocd&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;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://charts.chaos-mesh.org&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chaos-mesh&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2.8.3&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;chaosDaemon&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;containerd&lt;/span&gt;
          &lt;span class="na"&gt;socketPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/run/k3s/containerd/containerd.sock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;socketPath&lt;/code&gt; is critical: k3s uses its own containerd socket, not the standard Docker or containerd paths. If you point Chaos Mesh at the wrong socket, pod-kill experiments silently fail — the controller reports success but no pods are actually killed.&lt;/p&gt;

&lt;p&gt;The two Schedule resources:&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;# Weekly pod-kill — Sunday 03:00 UTC&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;chaos-mesh.org/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;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;weekly-pod-kill&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;chaos-mesh&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;3&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;0"&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PodChaos&lt;/span&gt;
  &lt;span class="na"&gt;podChaos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod-kill&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;one&lt;/span&gt;
    &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labelSelectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;chaos-kill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enabled"&lt;/span&gt;
      &lt;span class="na"&gt;namespaces&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="na"&gt;gracePeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;# Weekly network latency — Sunday 03:30 UTC&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;chaos-mesh.org/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;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;weekly-network-latency&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;chaos-mesh&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;30&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&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;0"&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkChaos&lt;/span&gt;
  &lt;span class="na"&gt;networkChaos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;delay&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;
    &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labelSelectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;chaos-network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enabled"&lt;/span&gt;
      &lt;span class="na"&gt;namespaces&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="na"&gt;delay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;latency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100ms"&lt;/span&gt;
      &lt;span class="na"&gt;jitter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10ms"&lt;/span&gt;
    &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5m"&lt;/span&gt;
    &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;to&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;mode: one&lt;/code&gt; on the pod-kill means exactly one pod matching the label is killed per experiment. &lt;code&gt;mode: all&lt;/code&gt; on the network latency applies to all pods with the label. The &lt;code&gt;gracePeriod: 0&lt;/code&gt; on pod-kill means immediate termination — no graceful shutdown, which is the realistic failure mode (a kernel panic, a power loss, a node crash).&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke: First Week
&lt;/h2&gt;

&lt;p&gt;The first pod-kill Sunday killed one Authelia pod. Expected behavior: Kubernetes reschedules it within seconds. What actually happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pod killed at 03:00:00 UTC&lt;/li&gt;
&lt;li&gt;Kubernetes schedules replacement at 03:00:02 UTC&lt;/li&gt;
&lt;li&gt;Replacement starts &lt;code&gt;wait-for-vault-secret&lt;/code&gt; init container&lt;/li&gt;
&lt;li&gt;Init container polls Vault for ExternalSecret sync at 30s intervals&lt;/li&gt;
&lt;li&gt;Authelia fully ready at 03:01:30 UTC — &lt;strong&gt;90 seconds of downtime&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ninety seconds for Authelia to recover from a pod-kill. The init container is the bottleneck: it waits for Vault to provide the hmac-secret, OIDC keys, and session secrets before the main container starts. During those 90 seconds, any service that checks Authelia for authentication returns 501 — the Traefik middleware can't reach Authelia's &lt;code&gt;/api/verify&lt;/code&gt; endpoint.&lt;/p&gt;

&lt;p&gt;The fix wasn't to make Authelia faster. The fix was to recognize that 90 seconds of Authelia downtime is acceptable for a single-replica-killed scenario, but unacceptable for a two-replica scenario where both pods are killed simultaneously. The PDB (&lt;code&gt;minAvailable: 1&lt;/code&gt;) prevents simultaneous kills — Chaos Mesh respects PodDisruptionBudgets. Without the PDB, both pods could be killed in the same experiment window.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke: Network Latency
&lt;/h2&gt;

&lt;p&gt;The 100ms latency injection was more insidious. It didn't break anything immediately. Instead, it exposed timing-dependent behavior that was invisible under normal network conditions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Velero backup duration increased by 40%.&lt;/strong&gt; &lt;a href="https://dev.to/blog/velero-garage-k3s-backup/"&gt;Velero's Kopia sidecar&lt;/a&gt; communicates with the Garage S3 endpoint over the cluster network. Adding 100ms per request multiplied across thousands of file operations extended the backup window from ~12 minutes to ~20 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ArgoCD sync operations became sluggish.&lt;/strong&gt; ArgoCD's repo-server fetches manifests from the git repo, applies diffs, and syncs. Each step involves network calls that now had 100ms added. Syncs that normally took 5 seconds took 15-20 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uptime Kuma monitors flickered.&lt;/strong&gt; Uptime Kuma's HTTP monitors expect sub-second response times. The 100ms added by Chaos Mesh pushed some monitors past their threshold, generating false-positive "service down" alerts.&lt;/p&gt;

&lt;p&gt;None of these are failures. They're performance degradation under adverse conditions. But they reveal the hidden assumption in every service's timeout and retry configuration: "the network is fast." When it isn't — because of a real network issue, a noisy neighbor, a congested switch — services that work fine under normal conditions start failing in unexpected ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Taught
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. PDBs aren't optional
&lt;/h3&gt;

&lt;p&gt;Before Chaos Mesh, the PodDisruptionBudgets for Authelia and cloudflared were theoretical — "we have them because best practices say we should." After the first pod-kill confirmed that the PDB actually prevented simultaneous kills, they became load-bearing infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Init containers are single points of failure
&lt;/h3&gt;

&lt;p&gt;Every ExternalSecret-backed deployment has an init container that waits for Vault. If Vault is slow, sealed, or unreachable, the init container blocks the entire pod startup. The 90-second Authelia recovery time is entirely dominated by this init container. A faster health check or a cached secret fallback would reduce recovery time.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Latency exposes timeout assumptions
&lt;/h3&gt;

&lt;p&gt;Every service has implicit assumptions about network latency. When those assumptions are violated, services don't crash — they degrade. The degradation is harder to debug than a crash because everything looks healthy in the logs. The only signal is slower response times and increased error rates that don't quite reach alerting thresholds.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Chaos testing on a homelab isn't overkill
&lt;/h3&gt;

&lt;p&gt;The cluster has no SLA. Nobody is paying for uptime. But the same workloads (Postgres, Vault, Authelia) run in production environments everywhere. Finding that a pod-kill takes 90 seconds to recover — on a homelab where the consequence is "I can't log in for a minute and a half" — is infinitely better than finding it in production where the consequence is a customer-facing outage. It's the same reasoning behind &lt;a href="https://dev.to/blog/k3s-cascading-failure-oomkill-dns-storm/"&gt;treating a cascading OOM-kill/DNS-storm failure&lt;/a&gt; as worth a full root-cause writeup even though nobody was paged.&lt;/p&gt;




&lt;p&gt;Chaos engineering at enterprise scale uses the same tools: Azure Chaos Studio for VM and AKS fault injection, Azure Load Testing for performance baseline, and Azure Monitor for measuring blast radius. The principle is identical — inject realistic failures in a controlled environment, measure the impact, fix what breaks. The only difference is that Azure Chaos Studio charges per experiment, so you want your homelab practice run to be thorough.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>chaosengineering</category>
      <category>homelab</category>
      <category>observability</category>
    </item>
    <item>
      <title>Cloudflare Tunnel Without Opening a Single Firewall Port</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 17 Aug 2026 07:10:16 +0000</pubDate>
      <link>https://dev.to/dwoitzik/cloudflare-tunnel-without-opening-a-single-firewall-port-487m</link>
      <guid>https://dev.to/dwoitzik/cloudflare-tunnel-without-opening-a-single-firewall-port-487m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/cloudflare-tunnel-zero-inbound-ports/" 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;Three services in my homelab need external access: Immich for family photo backup, Atlantis for Terraform webhooks, and Jellyfin for media. None of them have a single inbound firewall rule — the &lt;a href="https://dev.to/blog/mikrotik-zero-trust-firewall-terraform/"&gt;MikroTik firewall stays default-deny&lt;/a&gt; with no WAN-facing accept rules at all. Cloudflare Tunnel handles all external traffic through an outbound-only connection, and split-DNS on AdGuard ensures LAN clients reach services directly without leaving the network.&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 Architecture
&lt;/h2&gt;

&lt;p&gt;Cloudflare Tunnel works by running a &lt;code&gt;cloudflared&lt;/code&gt; daemon inside the cluster that maintains an outbound-only connection to Cloudflare's edge. External requests hit Cloudflare, get routed through the tunnel to the origin service, and back. No inbound ports, no port forwarding, no attack surface on the WAN side.&lt;/p&gt;

&lt;p&gt;The Terraform configuration defines the tunnel ingress rules:&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/cloudflare/main.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"cloudflare_zero_trust_tunnel_cloudflared_config"&lt;/span&gt; &lt;span class="s2"&gt;"homelab"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tunnel_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tunnel_id&lt;/span&gt;
  &lt;span class="nx"&gt;account_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;account_id&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"atlantis.woitzik.dev"&lt;/span&gt;
      &lt;span class="nx"&gt;service&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://traefik.apps.svc.cluster.local:443"&lt;/span&gt;
      &lt;span class="nx"&gt;origin_request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;origin_server_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"atlantis.woitzik.dev"&lt;/span&gt;
        &lt;span class="nx"&gt;no_tls_verify&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"photos.woitzik.dev"&lt;/span&gt;
      &lt;span class="nx"&gt;service&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://immich-server.apps.svc.cluster.local:2283"&lt;/span&gt;
      &lt;span class="nx"&gt;origin_request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;no_tls_verify&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="nx"&gt;chunked_encoding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"media.woitzik.dev"&lt;/span&gt;
      &lt;span class="nx"&gt;service&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://ct-srv-jellyfin-01.dmz.woitzik.dev:8096"&lt;/span&gt;
      &lt;span class="nx"&gt;origin_request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;no_tls_verify&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http_status:404"&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;The catch-all &lt;code&gt;http_status:404&lt;/code&gt; is mandatory — Cloudflare Tunnel requires a final rule that catches unmatched hostnames.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split-DNS on AdGuard
&lt;/h2&gt;

&lt;p&gt;The tunnel creates a split-brain DNS problem. External requests go through Cloudflare's anycast IPs. Internal requests (LAN) should go directly to the Traefik VIP, bypassing Cloudflare entirely. This means &lt;code&gt;photos.woitzik.dev&lt;/code&gt; needs to resolve to different IPs depending on where the client is.&lt;/p&gt;

&lt;p&gt;AdGuard Home handles this with wildcard overrides:&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;# AdGuard rewrites&lt;/span&gt;
&lt;span class="c1"&gt;# Default: *.woitzik.dev → 10.0.20.200 (Traefik VIP)&lt;/span&gt;
&lt;span class="c1"&gt;# Override: photos.woitzik.dev → Cloudflare anycast (172.67.137.91, 104.21.38.184)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;*.woitzik.dev → 10.0.20.200&lt;/code&gt; wildcard covers all internal services. The specific override for &lt;code&gt;photos.woitzik.dev&lt;/code&gt; points to Cloudflare's anycast IPs, so mobile clients on cellular networks get routed through the tunnel while LAN clients go direct.&lt;/p&gt;

&lt;p&gt;The gotcha: AdGuard's wildcard applies to the search domain too. If the Proxmox host's search domain is &lt;code&gt;woitzik.dev&lt;/code&gt;, every internal hostname query goes through the wildcard — including &lt;code&gt;pve.woitzik.dev&lt;/code&gt;, &lt;code&gt;pbs.woitzik.dev&lt;/code&gt;, and other services that should never leave the LAN. This was caught during initial setup: PTR queries for k3s pod IPs were being forwarded to the FritzBox because AdGuard's search domain configuration leaked DNS traffic outward.&lt;/p&gt;

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

&lt;p&gt;Large photo and video uploads to Immich through Cloudflare Tunnel were failing with &lt;code&gt;ECONNRESET&lt;/code&gt;. The uploads would complete 60-80% then drop the connection.&lt;/p&gt;

&lt;p&gt;The root cause: Cloudflare's proxy buffers the entire request body before forwarding to the origin. For multi-GB video uploads, this exceeds Cloudflare's buffer limit and the connection resets.&lt;/p&gt;

&lt;p&gt;The fix in the Terraform tunnel config:&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;origin_request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;chunked_encoding&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;write_timeout&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;  &lt;span class="c1"&gt;# seconds&lt;/span&gt;
  &lt;span class="nx"&gt;read_timeout&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;  &lt;span class="c1"&gt;# seconds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chunked_encoding = true&lt;/code&gt; tells &lt;code&gt;cloudflared&lt;/code&gt; to stream the request body to the origin in chunks instead of buffering the entire payload. &lt;code&gt;write_timeout = 600s&lt;/code&gt; gives large uploads up to 10 minutes to complete.&lt;/p&gt;

&lt;p&gt;This was the Immich-specific fix documented in CHANGELOG v0.8.0. The same issue can affect any service behind Cloudflare Tunnel that accepts large file uploads — Nextcloud, Paperless, or any service accepting multipart form data above ~100MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Atlantis Through Traefik
&lt;/h2&gt;

&lt;p&gt;Atlantis needs to receive GitHub webhooks for Terraform PR events. The naive approach: expose Atlantis directly via Cloudflare Tunnel. The problem: Atlantis has no built-in authentication — anyone who knows the URL can trigger plans and applies.&lt;/p&gt;

&lt;p&gt;The fix: route Atlantis through Traefik first, which applies the &lt;a href="https://dev.to/blog/k3s-authelia-proxmox-homelab/"&gt;Authelia&lt;/a&gt; ForwardAuth middleware before the request reaches Atlantis:&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;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"atlantis.woitzik.dev"&lt;/span&gt;
  &lt;span class="nx"&gt;service&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://traefik.apps.svc.cluster.local:443"&lt;/span&gt;
  &lt;span class="nx"&gt;origin_request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;origin_server_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"atlantis.woitzik.dev"&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 Cloudflare Tunnel points at Traefik, not Atlantis. Traefik's IngressRoute for Atlantis includes the Authelia middleware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.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;IngressRoute&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;atlantis&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;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;websecure&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`atlantis.woitzik.dev`)&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;Rule&lt;/span&gt;
      &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;authelia&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
      &lt;span class="na"&gt;services&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;atlantis&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4141&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wildcard-woitzik-dev-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub webhooks bypass Authelia because the webhook source IPs are known and can be allowlisted separately. But the Atlantis web UI — the plan output, the apply buttons — requires Authelia authentication.&lt;/p&gt;

&lt;p&gt;This pattern (Cloudflare Tunnel → Traefik → Authelia → service) is the same for every external service. The tunnel handles ingress, Traefik handles routing and TLS, Authelia handles authentication. Each layer does one thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS Record Management
&lt;/h2&gt;

&lt;p&gt;Cloudflare DNS records for the tunnel CNAMEs are Terraform-managed:&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;"cloudflare_dns_record"&lt;/span&gt; &lt;span class="s2"&gt;"tunnel_photos"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zone_id&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;"photos"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CNAME"&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"abc123.cfargotunnel.com"&lt;/span&gt;
  &lt;span class="nx"&gt;proxied&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;proxied = true&lt;/code&gt; means traffic goes through Cloudflare's proxy, which provides DDoS protection, rate limiting, and WAF rules. &lt;code&gt;ttl = 1&lt;/code&gt; is automatic for proxied records.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;auth&lt;/code&gt; DNS record (CNAME to &lt;code&gt;home.woitzik.dev&lt;/code&gt;) is &lt;code&gt;proxied = false&lt;/code&gt; — it's an internal-only record that doesn't need Cloudflare's proxy layer. Same for the Minecraft &lt;code&gt;playit.gg&lt;/code&gt; record.&lt;/p&gt;




&lt;p&gt;Cloudflare Tunnel's zero-inbound-port model is the same pattern as Azure Private Link and Azure Front Door: external traffic enters through a managed proxy, never touches your firewall directly, and the origin service only needs outbound connectivity. The split-DNS complexity is the same as Azure DNS Private Zones — internal resolution goes through private endpoints, external resolution through public DNS. Different tools, identical architecture.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>networking</category>
      <category>cloudflare</category>
      <category>security</category>
    </item>
    <item>
      <title>K3s on Raspberry Pi: Why I Said No (And You Probably Should Too)</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sat, 15 Aug 2026 10:03:33 +0000</pubDate>
      <link>https://dev.to/dwoitzik/k3s-on-raspberry-pi-why-i-said-no-and-you-probably-should-too-2l38</link>
      <guid>https://dev.to/dwoitzik/k3s-on-raspberry-pi-why-i-said-no-and-you-probably-should-too-2l38</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/k3s-raspberry-pi-why-i-said-no/" 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 idea was elegant: three Raspberry Pi 5 nodes running k3s control-plane + embedded etcd, replacing the three Proxmox VMs. Lower power consumption, physical separation from the main host, and a genuine HA cluster spread across real hardware.&lt;/p&gt;

&lt;p&gt;It didn't work. The failure wasn't dramatic — no kernel panic, no cluster death. It was a slow accumulation of fragility that made the cluster less reliable than the single-node setup it replaced.&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 Attempt
&lt;/h2&gt;

&lt;p&gt;The plan (ADR-014, Option B): run k3s embedded etcd on three &lt;a href="https://amzn.to/4wETNfv" rel="noopener noreferrer"&gt;Raspberry Pi 5 (8GB)*&lt;/a&gt; nodes. The Pis already handle DNS (AdGuard + Unbound) and Keepalived VIP. Adding k3s control-plane seemed like a natural extension.&lt;/p&gt;

&lt;p&gt;The k3s cluster spec:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3x Raspberry Pi 5 (8GB)&lt;/li&gt;
&lt;li&gt;256GB microSD cards (A2 rated)&lt;/li&gt;
&lt;li&gt;Gigabit Ethernet via USB 3.0 adapter (Pi 5's native Ethernet is limited)&lt;/li&gt;
&lt;li&gt;k3s v1.31 with embedded etcd&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why It Failed: SD Card Fragility
&lt;/h2&gt;

&lt;p&gt;etcd is write-heavy. Every Kubernetes API operation — pod scheduling, configmap updates, secret rotations — generates etcd writes. The k3s embedded etcd writes continuously to the local filesystem.&lt;/p&gt;

&lt;p&gt;SD cards have limited write endurance. The A2-rated cards I used are rated for ~150 MB/s sequential write, but the random 4K write IOPS that etcd generates are a different story. Under sustained write load, the SD card's write amplification factor increases, the garbage collection cycle can't keep up, and write latency spikes.&lt;/p&gt;

&lt;p&gt;The symptom: etcd response times would occasionally jump from 10ms to 500ms+ for no apparent reason. No CPU load, no network congestion, no memory pressure. Just slow disk writes. On a NVMe-backed VM, etcd writes complete in microseconds. On an SD card, they're orders of magnitude slower.&lt;/p&gt;

&lt;p&gt;etcd has a built-in leader election timeout (default 5s). When etcd write latency exceeds the election timeout, the leader steps down and a new election starts. If the new leader is also on an SD card with the same write latency problem, the election can fail too. The result: brief periods where the Kubernetes API server is unavailable, even though all nodes are "healthy."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Failed: Network Latency
&lt;/h2&gt;

&lt;p&gt;The three Pis communicate over Gigabit Ethernet via USB 3.0 adapters. The adapter adds ~0.5ms of latency per hop compared to native Gigabit. For etcd consensus, which requires a majority of nodes to acknowledge each write, the added latency compounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write to leader: 0.5ms&lt;/li&gt;
&lt;li&gt;Leader replicates to 2 followers: 0.5ms × 2 = 1ms&lt;/li&gt;
&lt;li&gt;Followers acknowledge: 0.5ms × 2 = 1ms&lt;/li&gt;
&lt;li&gt;Total round-trip: ~2.5ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compare to three VMs on the same NVMe-backed host: ~0.1ms round-trip. The 25x latency increase doesn't matter for normal API operations, but it matters during high-write periods or leader elections, exactly when low latency is most critical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Failed: Resource Contention
&lt;/h2&gt;

&lt;p&gt;The Pis were already running AdGuard + Unbound for network DNS, Keepalived for the VIP, and node_exporter for monitoring. Adding k3s control-plane + etcd meant four services competing for the same CPU, memory, and — critically — the same SD card.&lt;/p&gt;

&lt;p&gt;When AdGuard's DNS cache expired and refreshed simultaneously with an etcd compaction cycle, both hit the SD card at once. The resulting I/O contention produced the same pattern as the Proxmox host freeze: etcd write latency spikes → leader election → temporary API unavailability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Deliberate Reversion
&lt;/h2&gt;

&lt;p&gt;After three weeks of monitoring, the k3s-on-Pi cluster was less reliable than the single-node setup it replaced. The decision (ADR-014): revert to a single k3s control-plane on the Proxmox VM (&lt;code&gt;vm-srv-k3s-11&lt;/code&gt;), with two agent-only worker nodes.&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;# vm-srv-k3s-11: control-plane + etcd (sole server)&lt;/span&gt;
&lt;span class="c1"&gt;# vm-srv-k3s-12: agent only&lt;/span&gt;
&lt;span class="c1"&gt;# vm-srv-k3s-13: agent only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single-server setup has a known limitation: if &lt;code&gt;k3s-11&lt;/code&gt; goes down, the entire cluster is down. No failover, no HA. But "down" in a homelab context means "inaccessible for a few minutes while I restart the VM" — not "data center outage affecting thousands of users."&lt;/p&gt;

&lt;p&gt;The trade-off: reliability (single NVMe-backed VM) over availability (three SD-card-backed Pis). For a homelab, reliability is the right priority.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Pis Do Instead
&lt;/h2&gt;

&lt;p&gt;The Raspberry Pis remain in the rack, running DNS and Keepalived — exactly what they're good at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AdGuard Home&lt;/strong&gt;: DNS filtering for the entire network, low write volume, well within SD card endurance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unbound&lt;/strong&gt;: Recursive DNS resolver, almost entirely read operations after cache warm-up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keepalived&lt;/strong&gt;: Active/passive VIP failover, heartbeat-only, negligible I/O&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;node_exporter&lt;/strong&gt;: System metrics, read-only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These workloads are low-write, low-latency-sensitive, and can tolerate brief interruptions without affecting the cluster. The Keepalived VIP failing over to the other Pi is a 1-second blip. The k3s API server going down for 30 seconds during an etcd election breaks every kubectl command and ArgoCD sync.&lt;/p&gt;

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

&lt;p&gt;etcd's requirements are specific: low-latency, high-endurance storage with consistent write performance. NVMe provides this. SATA SSDs provide this (mostly). SD cards and USB-attached storage do not, because their write latency is unpredictable under sustained load.&lt;/p&gt;

&lt;p&gt;If you're running k3s on Raspberry Pis, the stable configuration is: Pis as agent-only workers, control-plane on a VM or dedicated x86 box with proper storage. The Pis handle DNS, monitoring, and lightweight workloads — things where an SD card's write endurance is adequate and a brief interruption doesn't cascade into a cluster-wide event.&lt;/p&gt;

&lt;p&gt;The same logic applies to etcd on cloud VMs: don't put etcd on Standard HDD. The IOPS and latency guarantees of Premium SSD or Ultra Disk exist specifically because etcd's consensus protocol requires consistent, low-latency writes. The Pi's SD card is the cloud equivalent of a Standard HDD — it works until you need consistent performance under load.&lt;/p&gt;




&lt;p&gt;K3s on ARM is production-ready for worker nodes. The control-plane decision is about storage performance and write consistency, not CPU architecture. Azure's AKS control plane runs on the same principle: the control-plane nodes use Premium SSD managed disks precisely because etcd needs consistent, low-latency writes. The underlying hardware doesn't matter — the I/O guarantees do.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>networking</category>
      <category>raspberrypi</category>
    </item>
    <item>
      <title>Azure Logic App Standard: Private Storage Needs Four Private Endpoints</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:51:09 +0000</pubDate>
      <link>https://dev.to/dwoitzik/azure-logic-app-standard-private-storage-needs-four-private-endpoints-11il</link>
      <guid>https://dev.to/dwoitzik/azure-logic-app-standard-private-storage-needs-four-private-endpoints-11il</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/azure-logic-app-standard-four-private-endpoints/" 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;Logic App Standard is the go-to hosting model when your workflows need VNet integration and a predictable App Service Plan. In a hardened Azure landing zone, that means one thing: a Storage Account that is not reachable from the public internet, fronted by Private Endpoints.&lt;/p&gt;

&lt;p&gt;The deployment succeeded. The Resource Group, the Storage Account, the private endpoints, the DNS zones - everything planned cleanly and applied without a single error. Then the workflow host refused to start. Every single boot ended in the same &lt;code&gt;403 Forbidden&lt;/code&gt;, roughly 600 milliseconds after the host registered its blob webhook endpoint.&lt;/p&gt;

&lt;p&gt;This is the postmortem: why it happened, why four plausible fixes did not help, and how the generic rebuild now bakes the correct configuration in by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/azure-logic-app-standard-network" rel="noopener noreferrer"&gt;View the base source code on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;The portal designer showed a generic failure - &lt;code&gt;renderComponentIntoRoot&lt;/code&gt;, &lt;code&gt;Workflow validation failed&lt;/code&gt; - none of which pointed anywhere useful. A direct API call against the host runtime revealed the real error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Encountered an error (ServiceUnavailable) from host runtime.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kudu host status endpoint (&lt;code&gt;/hostruntime/admin/host/status&lt;/code&gt;) confirmed the host was not just having a bad moment:&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;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"errors"&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="s2"&gt;"Microsoft.WindowsAzure.ResourceStack: Unexpected HTTP status code 'Forbidden'. The remote server returned an error: (403) Forbidden."&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;The &lt;code&gt;Forbidden&lt;/code&gt; came from Storage itself, and it came back fast. That timing was the first real clue: no timeout, no firewall blackhole, no DNS hang - a prompt, deliberate rejection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause
&lt;/h2&gt;

&lt;p&gt;A Logic App Standard is a Functions host under the hood - a &lt;code&gt;functions&lt;/code&gt; app hosting &lt;code&gt;workflow&lt;/code&gt; apps. Its internal host runtime does not just need Storage for your workflow content. It consumes &lt;strong&gt;four&lt;/strong&gt; Storage subresources during normal operation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subresource&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;File&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Content share - &lt;code&gt;host.json&lt;/code&gt;, &lt;code&gt;connections.json&lt;/code&gt;, workflow definitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Blob&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Extension-bundle cache, application logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Internal WebJobs coordination - scale controller, trigger bookkeeping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Table&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Internal WebJobs metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Our wrapper built private endpoints for &lt;strong&gt;Blob&lt;/strong&gt; and &lt;strong&gt;File&lt;/strong&gt; - the two that look obviously necessary, because the File share literally holds the content and Blob holds the bundle cache. Queue and Table were forgotten.&lt;/p&gt;

&lt;p&gt;The consequence is subtle and nasty. DNS for &lt;code&gt;*.queue.core.windows.net&lt;/code&gt; and &lt;code&gt;*.table.core.windows.net&lt;/code&gt; resolved to the &lt;strong&gt;public&lt;/strong&gt; IP - there is no private endpoint, so no private DNS record. But the Storage Account had &lt;code&gt;publicNetworkAccess&lt;/code&gt; disabled. Every request the host made to Queue or Table over the public IP was rejected by Storage itself with &lt;code&gt;403 Forbidden&lt;/code&gt; - instantly. That is why the failure appeared exactly ~600 ms after boot: Storage denies fast, it does not hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Four Plausible Fixes Failed
&lt;/h2&gt;

&lt;p&gt;Each attempt was reasonable, and each targeted the wrong layer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;RG Contributor for the Managed Identity.&lt;/strong&gt; RBAC was never the problem. The Storage network firewall does not consult RBAC roles - it only evaluates network origin plus keys/auth. Adding permissions changed nothing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;vnet_route_all_enabled = false&lt;/code&gt;.&lt;/strong&gt; This flag only controls whether outbound internet traffic is routed through the VNet. Queue/Table calls never went through the VNet in the first place - they were direct public calls caused by wrong DNS resolution. The flag was for a different layer of the problem.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Storage data-plane RBAC (Blob/Queue/Table Data Contributor).&lt;/strong&gt; Permission is useless when the request is rejected at the network layer before RBAC is even evaluated.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Finally, Application Insights.&lt;/strong&gt; The Kudu logs showed only the short error with no stack trace. Attaching App Insights temporarily revealed the full exception stack and the exact failing method (&lt;code&gt;WorkflowExtensionProvider.Initialize&lt;/code&gt;). Only then was the real dependency visible.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lesson from the four failed fixes: when a private Storage Account rejects a request with &lt;code&gt;403&lt;/code&gt;, the problem is almost never identity. It is reachability - specifically, which DNS name resolves to which IP.&lt;/p&gt;

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

&lt;p&gt;Two additional Private Endpoints on the same Storage Account, plus their Private DNS zone integration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pe-st-queue&lt;/code&gt; → &lt;code&gt;privatelink.queue.core.windows.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pe-st-table&lt;/code&gt; → &lt;code&gt;privatelink.table.core.windows.net&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The private DNS zones already existed centrally; they just were not attached to this Storage Account. Once Queue and Table resolved to private IPs, the host booted cleanly on the next restart.&lt;/p&gt;

&lt;p&gt;The memory anchor for next time: &lt;strong&gt;a Logic App Standard or Function App with private Storage needs all four Storage subresources behind Private Endpoints&lt;/strong&gt; - not just the ones that look content-related. Queue and Table are pure host-internal infrastructure. They show up in no obvious configuration, which is exactly why they get forgotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Generic Rebuild
&lt;/h2&gt;

&lt;p&gt;Instead of leaving the fix in a wrapper module, the generic template now creates all four by default. The private endpoints are a single &lt;code&gt;for_each&lt;/code&gt; over the subresource list:&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;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;storage_subresources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"blob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"queue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"storage"&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;toset&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;storage_subresources&lt;/span&gt;&lt;span class="p"&gt;)&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;"pe-${local.storage_name}-${each.key}"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_id&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_endpoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;private_service_connection&lt;/span&gt; &lt;span class="p"&gt;{&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;"psc-${local.storage_name}-${each.key}"&lt;/span&gt;
    &lt;span class="nx"&gt;private_connection_resource_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_storage_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
    &lt;span class="nx"&gt;subresource_names&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;is_manual_connection&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;private_dns_zone_group&lt;/span&gt; &lt;span class="p"&gt;{&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;"dns-${each.key}"&lt;/span&gt;
    &lt;span class="nx"&gt;private_dns_zone_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;azurerm_private_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&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;Adding a sixth subresource (e.g. &lt;code&gt;dfs&lt;/code&gt; for Data Lake) is now one list entry instead of a copy-pasted resource block.&lt;/p&gt;

&lt;p&gt;The DNS zones are created and linked to the VNet in the same pass, so &lt;code&gt;*.queue.core.windows.net&lt;/code&gt; resolves privately without touching your DNS infrastructure:&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;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_zones&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;blob&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.blob.core.windows.net"&lt;/span&gt;
    &lt;span class="nx"&gt;file&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.file.core.windows.net"&lt;/span&gt;
    &lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.queue.core.windows.net"&lt;/span&gt;
    &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.table.core.windows.net"&lt;/span&gt;
    &lt;span class="nx"&gt;sites&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"privatelink.azurewebsites.net"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone"&lt;/span&gt; &lt;span class="s2"&gt;"main"&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;private_dns_zones&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_private_dns_zone_virtual_network_link"&lt;/span&gt; &lt;span class="s2"&gt;"main"&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;private_dns_zones&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;"link-${azurerm_virtual_network.main.name}"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_zone_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_private_dns_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_virtual_network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two further details worth stealing from the fix:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Order the dependency.&lt;/strong&gt; The Logic App must not boot before its private endpoints exist, otherwise the first scale-up races the network configuration:&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;"azurerm_logic_app_standard"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;

  &lt;span class="nx"&gt;public_network_access&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Disabled"&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_network_subnet_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_subnet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;integration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;app_settings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s2"&gt;"WEBSITE_CONTENTOVERVNET"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
      &lt;span class="s2"&gt;"WEBSITE_VNET_ROUTE_ALL"&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_settings&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm_private_endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"blob"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm_private_endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm_private_endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"queue"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm_private_endpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"table"&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;&lt;code&gt;WEBSITE_CONTENTOVERVNET = 1&lt;/code&gt; tells the host to serve its own content share over the VNet instead of the public file endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Bootstrap the file share.&lt;/strong&gt; A freshly deployed Standard Logic App boots against an empty share, and the workflow host and the portal designer both trip over that. The template uploads a minimal &lt;code&gt;host.json&lt;/code&gt; / &lt;code&gt;connections.json&lt;/code&gt; into &lt;code&gt;site/wwwroot&lt;/code&gt; during the first apply - empty definition, but a valid host contract:&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;"local_file"&lt;/span&gt; &lt;span class="s2"&gt;"host_json"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;filename&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/.bootstrap-content/host.json"&lt;/span&gt;
  &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2.0"&lt;/span&gt;
    &lt;span class="nx"&gt;extensionBundle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Microsoft.Azure.Functions.ExtensionBundle.Workflows"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"[1.*, 2.0.0)"&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;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;When you wire a Logic App Standard or Function App to a private Storage Account:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;File&lt;/strong&gt; private endpoint - content share&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blob&lt;/strong&gt; private endpoint - bundle cache and logs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue&lt;/strong&gt; private endpoint - host-internal coordination&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table&lt;/strong&gt; private endpoint - host-internal metadata&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sites&lt;/strong&gt; private endpoint on the Logic App itself (&lt;code&gt;privatelink.azurewebsites.net&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Private DNS zones for all five, linked to the VNet&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;public_network_access = "Disabled"&lt;/code&gt; on the Storage Account and the Logic App&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WEBSITE_CONTENTOVERVNET = 1&lt;/code&gt; so content is served over the VNet&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;depends_on&lt;/code&gt; from the Logic App to the private endpoints&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;A private endpoint per Storage subresource is not a nice-to-have for Logic Apps Standard - it is a hard requirement of the host runtime. The trap is that Queue and Table are invisible in every configuration file and every blog tutorial, so they get skipped until a &lt;code&gt;403 Forbidden&lt;/code&gt; starts appearing in Kudu logs at every boot.&lt;/p&gt;

&lt;p&gt;The good news: the fix is deterministic, and it is now encoded in the template rather than remembered by an engineer who already paid the price once.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>terraform</category>
      <category>logicapps</category>
      <category>privatelink</category>
    </item>
    <item>
      <title>Velero Said Backups Succeeded. The Data Was Never There.</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:15:50 +0000</pubDate>
      <link>https://dev.to/dwoitzik/velero-said-backups-succeeded-the-data-was-never-there-563n</link>
      <guid>https://dev.to/dwoitzik/velero-said-backups-succeeded-the-data-was-never-there-563n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/velero-backup-false-positive-no-data/" 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;Velero reported "Completed" on every daily backup. The schedule ran on time, the S3 uploads succeeded, the backup size looked reasonable. I never verified the contents — because "Completed" means completed, right?&lt;/p&gt;

&lt;p&gt;On 2026-06-19, I needed to restore a PVC. The backup existed in Garage S3. The restore completed. The PVC was empty.&lt;/p&gt;

&lt;p&gt;Postgres data, Vaultwarden passwords, Paperless documents, Nextcloud files — none of it was there. Velero had been faithfully backing up Kubernetes manifests for weeks while completely ignoring the actual data in PersistentVolumeClaims.&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 Velero Actually Backs Up
&lt;/h2&gt;

&lt;p&gt;Velero has two backup modes for PersistentVolumes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Snapshot-based&lt;/strong&gt; (default): Takes a CSI snapshot of the PV. Works for cloud providers with snapshot APIs (EBS, Azure Disk). Doesn't work for NFS, local-path, or any storage class without a snapshot provider.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Filesystem-based&lt;/strong&gt; (&lt;code&gt;--default-volumes-to-fs-backup&lt;/code&gt;): Runs a Kopia or Restic sidecar that copies the actual files from the PVC. Works with any storage class, including NFS and local-path.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My storage is NFS (&lt;code&gt;nfs-client&lt;/code&gt; from a Proxmox LXC) and &lt;code&gt;local-path&lt;/code&gt;. Neither has a CSI snapshot provider. Without filesystem backup, Velero has no way to capture PVC contents — it can only back up the Kubernetes resource definitions (the PV and PVC objects themselves, not the data inside them).&lt;/p&gt;

&lt;p&gt;The default is snapshot-based. If you don't explicitly tell Velero to use filesystem backup, it takes snapshots. If no snapshot provider exists, it takes no snapshot and the backup "succeeds" with only manifests.&lt;/p&gt;

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

&lt;p&gt;The backup schedule was:&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;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-backup&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;velero&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;defaultVolumesToFsBackup&lt;/code&gt;. No per-backup volume hooks. No snapshot provider configured. Every backup since the schedule was created had captured only Kubernetes manifests.&lt;/p&gt;

&lt;p&gt;The size of the backups was misleading. Manifests for 25+ applications, their Services, ConfigMaps, Secrets, and ExternalSecrets add up to a few MB — close enough to what I expected for a "metadata-only" backup that I didn't question it.&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/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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. &lt;code&gt;defaultVolumesToFsBackup: true&lt;/code&gt; tells Velero to use Kopia for filesystem backup of every PVC in every included namespace. Kopia runs as a sidecar, mounts the PVC, and copies the files to the Garage S3 bucket.&lt;/p&gt;

&lt;p&gt;After adding this flag, the next backup jumped from ~2 MB to ~4 GB — the actual data.&lt;/p&gt;

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

&lt;p&gt;The real lesson: &lt;code&gt;velero backup describe&lt;/code&gt; with &lt;code&gt;--details&lt;/code&gt; is the only way to verify that PVC data was actually captured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;velero backup describe daily-backup-20260620050012 &lt;span class="nt"&gt;--details&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A5&lt;/span&gt; &lt;span class="s2"&gt;"Pod Volume Backups"&lt;/span&gt;
&lt;span class="c"&gt;# Phase: Completed&lt;/span&gt;
&lt;span class="c"&gt;# Total items: 342&lt;/span&gt;
&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;span class="c"&gt;# Pod Volume Backups:&lt;/span&gt;
&lt;span class="c"&gt;#   Completed: 12&lt;/span&gt;
&lt;span class="c"&gt;#   Failed: 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If "Pod Volume Backups" is missing or shows 0 completed, no filesystem backup happened. The backup "completed" but only captured manifests. The &lt;code&gt;velero backup describe&lt;/code&gt; output is the only place this distinction is visible — &lt;code&gt;velero backup get&lt;/code&gt; just shows &lt;code&gt;Phase: Completed&lt;/code&gt; regardless.&lt;/p&gt;

&lt;p&gt;For automated verification, I added a post-backup check:&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;# After each backup completes&lt;/span&gt;
&lt;span class="nv"&gt;COMPLETED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;velero backup describe &lt;span class="nv"&gt;$BACKUP&lt;/span&gt; &lt;span class="nt"&gt;--details&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="s1"&gt;'.status.progress_podVolumeBackups | .completed // 0'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$COMPLETED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING: No pod volume backups captured"&lt;/span&gt;
  &lt;span class="c"&gt;# Alert via Discord webhook&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Broader Problem: Backup Circularity
&lt;/h2&gt;

&lt;p&gt;The discovery of the false-positive backup led to a deeper issue documented in &lt;code&gt;docs/garage-velero-design-2026-07-17.md&lt;/code&gt;: Velero backs up to Garage S3, which runs inside the same cluster. If the cluster dies, both Velero and Garage are gone — the backup target is inside the thing being backed up.&lt;/p&gt;

&lt;p&gt;This is the classic backup circularity problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Velero (k8s) → Garage S3 (k8s) → same cluster
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backup is only useful for recovering individual PVCs or namespaces within a running cluster. For full cluster recovery, you need the backup to exist &lt;em&gt;outside&lt;/em&gt; the cluster — which is why the Cloudflare R2 offsite backup scaffolding exists but isn't active yet.&lt;/p&gt;

&lt;p&gt;The secondary issue is "torn state" — Velero's metadata (manifests) and the PVC filesystem backups are stored separately. If one succeeds and the other fails, you can end up with manifests but no data, or data but no manifests to restore it into. The &lt;code&gt;defaultVolumesToFsBackup&lt;/code&gt; flag doesn't solve torn state, but it at least ensures both parts of the backup are attempted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Would Have Caught This
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A restore test.&lt;/strong&gt; Running &lt;code&gt;velero restore&lt;/code&gt; against a test namespace monthly would have caught the empty PVCs within the first cycle. The backup "Completed" status gave false confidence.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Backup size monitoring.&lt;/strong&gt; A 2 MB backup for a cluster with 25+ apps and 4 GB of PVC data is obviously wrong. An alert on backup size below a threshold would have caught it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Post-backup verification.&lt;/strong&gt; The &lt;code&gt;velero backup describe --details | grep "Pod Volume Backups"&lt;/code&gt; check should run after every scheduled backup, not just when you need a restore.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Backup verification is the same problem in Azure: Azure Backup reports "Completed" for VM snapshots, but a snapshot without the correct recovery point tier doesn't include disk contents. The verification step — actually restoring a test VM from backup — is the only way to confirm the backup contains what you think it contains. Compliance frameworks like ISO 27001 and NIS2 require documented restore testing for exactly this reason.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>backup</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Self-Hosted SSO for 25 Services: Authelia OIDC on Kubernetes</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sat, 08 Aug 2026 15:08:56 +0000</pubDate>
      <link>https://dev.to/dwoitzik/self-hosted-sso-for-25-services-authelia-oidc-on-kubernetes-316a</link>
      <guid>https://dev.to/dwoitzik/self-hosted-sso-for-25-services-authelia-oidc-on-kubernetes-316a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/authelia-oidc-kubernetes-25-services/" 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 internal service in my homelab goes through the same authentication gate: Authelia. Proxmox, PBS, Grafana, ArgoCD, Headscale, ArgoCD, Uptime Kuma, Paperless, Nextcloud — 25+ web services, one login, one session, one set of access rules. The OIDC provider, the Postgres backend, the session store, and the secrets are all running inside k3s, backed by CNPG, Redis, and Vault.&lt;/p&gt;

&lt;p&gt;This article is the full implementation: how the pieces fit together, why certain design decisions were made, and the specific bugs that bit me along the way.&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 Architecture
&lt;/h2&gt;

&lt;p&gt;Authelia runs as a Kubernetes Deployment in the &lt;code&gt;apps&lt;/code&gt; namespace, protected by the same default-deny NetworkPolicy that applies to everything else. It has three dependencies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; — CNPG-managed &lt;code&gt;postgres-authelia&lt;/code&gt; cluster in the &lt;code&gt;database&lt;/code&gt; namespace&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis&lt;/strong&gt; — session store, ephemeral (no persistence needed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vault&lt;/strong&gt; — hmac_secret, OIDC private keys, JWT secrets, session secrets&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Traefik ForwardAuth middleware sits in front of every service. When a request hits Traefik, the middleware sends a verification request to Authelia's &lt;code&gt;/api/verify&lt;/code&gt; endpoint. Authelia checks the session cookie, validates the OIDC token if applicable, and returns a 200 (allowed) or 401 (redirect to login).&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/authelia/middleware.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;traefik.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;Middleware&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;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;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;forwardAuth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://authelia.apps.svc.cluster.local:9999/api/verify"&lt;/span&gt;
    &lt;span class="na"&gt;trustForwardHeader&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;authResponseHeaders&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Remote-User&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Remote-Groups&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Remote-Email&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every IngressRoute that needs protection adds &lt;code&gt;middlewares: [{name: authelia}]&lt;/code&gt;. Services that need API-level protection (not browser-based) use OIDC client credentials instead.&lt;/p&gt;

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

&lt;p&gt;Authelia acts as an OIDC provider for services that support it. The ConfigMap defines five OIDC clients:&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/authelia/configmap.yml&lt;/span&gt;
&lt;span class="na"&gt;identity_providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;oidc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxmox&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Proxmox VE&lt;/span&gt;
        &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;from vault&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;authorization_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;two_factor&lt;/span&gt;
        &lt;span class="na"&gt;scopes&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;openid"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;profile"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;redirect_uris&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;https://pve.woitzik.dev:8006/pam2/callback/oidc"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pbs&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Proxmox Backup Server&lt;/span&gt;
        &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;from vault&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;authorization_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;two_factor&lt;/span&gt;
        &lt;span class="na"&gt;redirect_uris&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;https://pbs.woitzik.dev:8007/pam2/callback/oidc"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ArgoCD&lt;/span&gt;
        &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;from vault&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;authorization_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;two_factor&lt;/span&gt;
        &lt;span class="na"&gt;grant_types&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;authorization_code"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;redirect_uris&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;https://argo.woitzik.dev/auth/callback"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Grafana&lt;/span&gt;
        &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;from vault&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;authorization_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;two_factor&lt;/span&gt;
        &lt;span class="na"&gt;redirect_uris&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;https://monitoring.woitzik.dev/login/generic_oauth"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;headscale&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Headscale&lt;/span&gt;
        &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;from vault&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;authorization_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;two_factor&lt;/span&gt;
        &lt;span class="na"&gt;redirect_uris&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;https://headscale.woitzik.dev/oauth2/callback"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key detail: ArgoCD uses &lt;code&gt;client_secret_post&lt;/code&gt; for token exchange, while all other clients use &lt;code&gt;client_secret_basic&lt;/code&gt;. This is a quirk of ArgoCD's OIDC implementation — it sends the client secret in the POST body rather than the Authorization header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets in Vault
&lt;/h2&gt;

&lt;p&gt;The OIDC hmac_secret, signing keys, and session secrets were originally committed as plain Kubernetes Secrets. This is fine for a homelab, but it means anyone with &lt;code&gt;kubectl get secret&lt;/code&gt; can read them.&lt;/p&gt;

&lt;p&gt;The migration to Vault:&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/authelia/external-secret.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;external-secrets.io/v1beta1&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;ExternalSecret&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;authelia-secrets&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;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;secretStoreRef&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;vault-backend&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;ClusterSecretStore&lt;/span&gt;
  &lt;span class="na"&gt;target&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;authelia-secrets&lt;/span&gt;
    &lt;span class="na"&gt;creationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Merge&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hmac-secret&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&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;secret/authelia&lt;/span&gt;
        &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hmac-secret&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oidc-issuer-private-key&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&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;secret/authelia&lt;/span&gt;
        &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oidc-issuer-private-key&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jwt-secret&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&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;secret/authelia&lt;/span&gt;
        &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jwt-secret&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;session-secret&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&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;secret/authelia&lt;/span&gt;
        &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;session-secret&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-password&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&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;secret/authelia&lt;/span&gt;
        &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-password&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;creationPolicy: Merge&lt;/code&gt; means the ExternalSecret creates the Kubernetes Secret if it doesn't exist, then updates it on each refresh cycle. If Vault is sealed or unreachable, the existing Secret persists — Authelia can continue operating with stale secrets until the next refresh.&lt;/p&gt;

&lt;p&gt;The users database (username, argon2id hash, group memberships) was also migrated from a plain Secret to an ExternalSecret sourced from Vault. This one required a &lt;code&gt;creationPolicy: Merge&lt;/code&gt; approach because the file is referenced by path in the Authelia config, not as an environment variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Postgres Backend
&lt;/h2&gt;

&lt;p&gt;CNPG manages the &lt;code&gt;postgres-authelia&lt;/code&gt; single-instance cluster in the &lt;code&gt;database&lt;/code&gt; namespace. WAL archiving goes to Garage S3 for point-in-time recovery:&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;# postgres cluster config&lt;/span&gt;
&lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&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;max_connections&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;100"&lt;/span&gt;
    &lt;span class="na"&gt;shared_buffers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256MB"&lt;/span&gt;
    &lt;span class="na"&gt;effective_cache_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;512MB"&lt;/span&gt;
&lt;span class="na"&gt;backup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;barmanObjectStore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;destinationPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s3://postgres-backups"&lt;/span&gt;
    &lt;span class="na"&gt;endpointURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://s3.woitzik.dev"&lt;/span&gt;
  &lt;span class="na"&gt;retentionPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30d"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Daily &lt;code&gt;ScheduledBackup&lt;/code&gt; resources create full base backups. The combination of WAL archiving + daily base backups gives PITR granularity down to the transaction level.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Authelia Schema Bug
&lt;/h2&gt;

&lt;p&gt;One gotcha: Authelia's Postgres schema version can outpace the running image version. If Vault or Renovate bumps the Authelia image while the database has already been migrated to a newer schema, Authelia starts with a schema mismatch and fails.&lt;/p&gt;

&lt;p&gt;The fix was straightforward — bump the image version to match the schema:&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;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/authelia/authelia:4.39.20&lt;/span&gt;  &lt;span class="c1"&gt;# matches DB schema v24&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the symptom was confusing: Authelia reported healthy in ArgoCD, the pods were running, but login attempts returned 500 errors. The health check endpoint (&lt;code&gt;/api/health&lt;/code&gt;) doesn't validate database schema compatibility — it only checks that the process is up and can reach Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access Control Rules
&lt;/h2&gt;

&lt;p&gt;The access_control section defines who can access what:&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;access_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth.woitzik.dev&lt;/span&gt;
      &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bypass&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.woitzik.dev"&lt;/span&gt;
      &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;two_factor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Critical detail: &lt;code&gt;auth.woitzik.dev&lt;/code&gt; must be the &lt;strong&gt;first&lt;/strong&gt; rule. Authelia matches rules top-down and stops at the first match. If the wildcard &lt;code&gt;*.woitzik.dev → two_factor&lt;/code&gt; rule comes before the &lt;code&gt;auth.woitzik.dev → bypass&lt;/code&gt; rule, Authelia redirects to itself — creating an infinite redirect loop.&lt;/p&gt;

&lt;p&gt;I hit this bug during initial setup. The logs showed repeated 302 redirects between &lt;code&gt;auth.woitzik.dev&lt;/code&gt; and itself, which looks like a configuration error but is actually a rule ordering problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Replica Setup
&lt;/h2&gt;

&lt;p&gt;Authelia runs at two replicas with a PodDisruptionBudget (&lt;code&gt;minAvailable: 1&lt;/code&gt;). Redis handles session state, so either replica can serve any session. The only requirement is that both replicas share the same hmac_secret and JWT signing keys — which they do, since both read from the same Vault-backed ExternalSecret.&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/authelia/authelia.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;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;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;authelia&lt;/span&gt;
          &lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/health&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9999&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/health&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9999&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The health probes check &lt;code&gt;/api/health&lt;/code&gt;, which validates Postgres connectivity, Redis availability, and configuration file integrity. Blackbox Exporter probes the same endpoint externally — catching cases where Traefik returns 200 but Authelia itself is down (the Traefik → Authelia middleware can return 200 on connection failure if not configured carefully).&lt;/p&gt;

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

&lt;p&gt;Two things, if starting over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with Vault from day one.&lt;/strong&gt; Migrating secrets from plain Kubernetes Secrets to Vault-backed ExternalSecrets after the fact required a &lt;code&gt;creationPolicy: Merge&lt;/code&gt; dance that wouldn't have been necessary if the secrets were never in git.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;client_secret_post&lt;/code&gt; for everything.&lt;/strong&gt; ArgoCD's &lt;code&gt;client_secret_post&lt;/code&gt; quirk means I can't use a single client template — every OIDC client needs its own configuration. If all clients used the same grant type, the ConfigMap would be simpler.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;SSO across 25+ services is the same problem in enterprise Azure: Entra ID provides the OIDC provider, Azure Application Registrations replace the Authelia client definitions, and Conditional Access Policies replace the &lt;code&gt;access_control&lt;/code&gt; rules. The scale is different (Entra ID handles millions of identities), but the architectural pattern — centralize authentication, delegate authorization to the service, protect everything behind a single middleware — is identical.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>homelab</category>
      <category>sso</category>
    </item>
    <item>
      <title>My Terraform Runner Destroyed Itself Mid-Apply</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:54:57 +0000</pubDate>
      <link>https://dev.to/dwoitzik/my-terraform-runner-destroyed-itself-mid-apply-2995</link>
      <guid>https://dev.to/dwoitzik/my-terraform-runner-destroyed-itself-mid-apply-2995</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/atlantis-terraform-destroyed-itself-mid-apply/" 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;On 2026-07-04, Atlantis applied a Terraform change that shut down the VM it was running on. The Terraform runner destroyed its own runtime environment mid-apply.&lt;/p&gt;

&lt;p&gt;This is the story of a circular dependency that doesn't show up in &lt;code&gt;terraform plan&lt;/code&gt;, why &lt;code&gt;bpg/proxmox&lt;/code&gt; can't always update attributes in-place, and how moving Atlantis from a k3s Deployment to a dedicated LXC container eliminated the hazard entirely.&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 Setup
&lt;/h2&gt;

&lt;p&gt;Atlantis was deployed as a k3s Deployment in the &lt;code&gt;apps&lt;/code&gt; namespace, running on one of three k3s VMs managed by the same Proxmox Terraform stack. The Proxmox Terraform configuration (&lt;code&gt;terraform/stacks/proxmox/&lt;/code&gt;) defines all three k3s VMs, their CPU, memory, disk, and boot settings.&lt;/p&gt;

&lt;p&gt;The circular dependency: Atlantis runs on k3s VMs → Terraform manages k3s VMs → Atlantis applies Terraform changes to k3s VMs.&lt;/p&gt;

&lt;p&gt;In practice, this was safe for most changes. &lt;code&gt;memory&lt;/code&gt;, &lt;code&gt;disk.size&lt;/code&gt;, &lt;code&gt;cpu.cores&lt;/code&gt; — these can all be updated in-place by &lt;code&gt;bpg/proxmox&lt;/code&gt; without stopping the VM. But certain attributes require a full VM shutdown-and-restart cycle. Specifically: &lt;code&gt;cpu.units&lt;/code&gt; (scheduling priority) and &lt;code&gt;serial_device&lt;/code&gt; configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kill Shot
&lt;/h2&gt;

&lt;p&gt;The Terraform diff for a &lt;code&gt;cpu.units&lt;/code&gt; change looks like this:&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;"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="c1"&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;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 scheduling priority over LXCs&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;When &lt;code&gt;bpg/proxmox&lt;/code&gt; detects a change to &lt;code&gt;cpu.units&lt;/code&gt;, it can't hot-apply it. The provider issues a &lt;code&gt;qmshutdown&lt;/code&gt; via the Proxmox API, waits for the VM to stop, applies the change, then starts the VM again.&lt;/p&gt;

&lt;p&gt;On 2026-07-04, a PR changed &lt;code&gt;cpu.units&lt;/code&gt; on one of the k3s VMs. Atlantis picked up the PR, ran &lt;code&gt;terraform plan&lt;/code&gt;, showed the diff (in-place update), and ran &lt;code&gt;terraform apply&lt;/code&gt;. The &lt;code&gt;bpg/proxmox&lt;/code&gt; provider sent &lt;code&gt;qmshutdown&lt;/code&gt; to the Proxmox API for the VM running the Atlantis pod.&lt;/p&gt;

&lt;p&gt;The VM shut down. The Atlantis pod was killed. The Terraform apply was interrupted mid-execution. Kubernetes rescheduled the pod on a different node, but the apply state was lost.&lt;/p&gt;

&lt;p&gt;The same thing happened again on a &lt;code&gt;serial_device&lt;/code&gt; attribute change — another attribute that requires a VM shutdown. Two occurrences, same root cause: the Terraform runner was managing the infrastructure it depended on for its own execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;terraform plan&lt;/code&gt; Doesn't Catch This
&lt;/h2&gt;

&lt;p&gt;The circular dependency isn't expressed in the Terraform configuration. Atlantis's Pod spec doesn't reference the Proxmox VMs, and the Proxmox VMs don't reference Atlantis. Terraform sees two independent resource graphs. The dependency is physical, not declarative — Atlantis runs &lt;em&gt;on&lt;/em&gt; the VMs, but Terraform doesn't know that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terraform plan&lt;/code&gt; shows "will update in-place" for &lt;code&gt;cpu.units&lt;/code&gt;. It doesn't know that "update in-place" means "shut down the VM first." That behavior is a provider implementation detail, not something Terraform's planning phase understands.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;lifecycle.ignore_changes&lt;/code&gt; block can prevent specific attributes from being planned, but that's a workaround, not a fix. You'd be ignoring a real change to avoid a structural hazard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Move Atlantis Off k3s
&lt;/h2&gt;

&lt;p&gt;The fix was ADR-012: move Atlantis from a k3s Deployment to a dedicated LXC container (&lt;code&gt;ct-srv-atlantis-01&lt;/code&gt;, VMID 204, IP &lt;code&gt;10.0.20.250&lt;/code&gt;).&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_atlantis_01"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Dedicated LXC — NOT managed by the same Terraform stack&lt;/span&gt;
  &lt;span class="c1"&gt;# Atlantis manages OTHER stacks, but its own container is outside the scope&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;204&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-atlantis-01"&lt;/span&gt;
  &lt;span class="nx"&gt;node_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pve"&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 key difference: the Atlantis LXC is still defined in the Proxmox Terraform stack, but it's &lt;em&gt;never&lt;/em&gt; managed by Atlantis itself. The &lt;code&gt;atlantis.yaml&lt;/code&gt; repo config whitelists only specific repos and directories:&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;repos&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;github.com/dwoitzik/homelab-infrastructure&lt;/span&gt;
    &lt;span class="na"&gt;allowed_overrides&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;apply_requirements&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;delete_source_branch_on_merge&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;apply_requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;approved&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mergeable&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/network&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/cloudflare&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/garage&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice: &lt;code&gt;terraform/stacks/proxmox/&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; listed. Atlantis can plan and apply network, cloudflare, and garage changes — but never Proxmox changes. Proxmox changes go through a separate review process, or I apply them manually after careful review.&lt;/p&gt;

&lt;p&gt;This breaks the circular dependency structurally: Atlantis manages everything &lt;em&gt;except&lt;/em&gt; the infrastructure it runs on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ansible Layer
&lt;/h2&gt;

&lt;p&gt;The Atlantis LXC runs via Ansible, not k3s:&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;# ansible/roles/atlantis/tasks/main.yml&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;Deploy Atlantis via Docker Compose&lt;/span&gt;
  &lt;span class="na"&gt;community.docker.docker_compose_v2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;project_src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/atlantis&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The custom Dockerfile includes the Proxmox self-signed CA cert (&lt;code&gt;pve-root-ca.crt&lt;/code&gt;) so Atlantis can talk to the Proxmox API over HTTPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; ghcr.io/runatlantis/atlantis:v0.30.0&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pve-root-ca.crt /usr/local/share/ca-certificates/pve-root-ca.crt&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;update-ca-certificates
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Proxmox API token, Cloudflare API token, and MikroTik credentials are all stored in Ansible Vault and injected via Docker Compose environment variables.&lt;/p&gt;

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

&lt;p&gt;Any Terraform runner that manages the infrastructure it runs on has this hazard. In a cloud environment, it's less obvious because &lt;code&gt;terraform apply&lt;/code&gt; against an Azure VM doesn't restart the VM — Azure handles in-place updates at the platform level. But the same structural dependency exists: an Atlantis instance running on an Azure VM that manages that VM's NSG, disk, or network interface.&lt;/p&gt;

&lt;p&gt;The clean fix is always the same: the runner manages everything &lt;em&gt;except&lt;/em&gt; itself. If that's not possible, &lt;code&gt;lifecycle { ignore_changes }&lt;/code&gt; on attributes that trigger restarts is the minimum viable mitigation.&lt;/p&gt;




&lt;p&gt;The same pattern applies to CI/CD runners in enterprise environments. A self-hosted GitHub Actions runner managing its own host's infrastructure via Terraform has the identical circular dependency. The fix is the same: separate the runner's infrastructure from the infrastructure it manages, even if they share the same cloud account.&lt;/p&gt;



</description>
      <category>terraform</category>
      <category>gitops</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The ZFS ARC Freeze: How a Marginal PSU Killed My Entire Homelab</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sat, 01 Aug 2026 00:55:59 +0000</pubDate>
      <link>https://dev.to/dwoitzik/the-zfs-arc-freeze-how-a-marginal-psu-killed-my-entire-homelab-4eaa</link>
      <guid>https://dev.to/dwoitzik/the-zfs-arc-freeze-how-a-marginal-psu-killed-my-entire-homelab-4eaa</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/zfs-arc-freeze-psu-io-deadlock/" 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;Three times in one week, my Proxmox host froze completely. No SSH, no web UI, no console. The only recovery was a hard power cycle. Each time, I lost every running VM and LXC simultaneously — k3s control plane, workers, databases, all of it.&lt;/p&gt;

&lt;p&gt;The first time, I blamed a kernel bug. The second time, I blamed ZFS. The third time, I found the actual chain: a marginal PSU causing CPU throttling under load, which triggered a ZFS 2.4.1 ARC deadlock that locked the entire host.&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 Symptoms
&lt;/h2&gt;

&lt;p&gt;The freezes weren't random. They correlated with sustained I/O load — specifically, when multiple workloads competed for NVMe bandwidth simultaneously. The pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;k3s etcd writing to the same NVMe as the ZFS pool&lt;/li&gt;
&lt;li&gt;Ollama LLM inference spinning up large model loads&lt;/li&gt;
&lt;li&gt;Paperless OCR bursts hitting the disk queue hard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under moderate load, everything was fine. Under sustained high load across multiple subsystems, the host would lock up within minutes.&lt;/p&gt;

&lt;p&gt;The first two freezes produced no useful logs. &lt;code&gt;journalctl&lt;/code&gt; cut off mid-entry. No kernel panic, no oops, no MCE. Just silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PSU Theory
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://amzn.to/4bv3yF1" rel="noopener noreferrer"&gt;BMAX Mini PC*&lt;/a&gt; ships with a 90W PSU. Under normal operation, the Ryzen 7 5825U draws 15W TDP and the NVMe pulls another 8W. But when all cores are loaded — k3s workers, Ollama inference, OCR workers — the CPU can boost well above base TDP.&lt;/p&gt;

&lt;p&gt;I added power monitoring via a &lt;a href="https://amzn.to/3STKPwD" rel="noopener noreferrer"&gt;TP-Link Tapo P110*&lt;/a&gt; to track actual draw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Idle:              47W
k3s + normal apps: 68W
All cores loaded:  94W
Peak (all + NVMe): 108W
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;108W peak against a 90W PSU. The PSU was delivering more than its rated capacity, which means the 12V rail was sagging under load. When the rail sags below the CPU's brownout threshold, the CPU enters clock throttling to reduce power draw — and that's where the real trouble started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ZFS Deadlock
&lt;/h2&gt;

&lt;p&gt;ZFS uses a Transaction Group (txg) commit cycle. Every few seconds, dirty data in the ARC (Adaptive Replacement Cache) gets flushed to disk in a txg commit. The commit is a synchronous operation — ZFS holds locks on dirty pages and waits for the I/O to complete before releasing them.&lt;/p&gt;

&lt;p&gt;When the CPU throttles mid-commit, the I/O latency spikes because the NVMe controller shares the same power rail. The txg commit can't complete within its timeout window, ZFS holds the ARC locks, new I/O requests pile up waiting for those locks, and the entire storage stack deadlocks.&lt;/p&gt;

&lt;p&gt;In ZFS 2.4.1 specifically, the ARC eviction path has a known issue where it can block on the same locks held by a stalled txg commit. This creates a circular wait: ARC eviction needs txg to complete, txg needs I/O bandwidth, I/O is delayed by CPU throttling, CPU throttling is caused by the PSU sag, and the PSU sags because everything is running at once.&lt;/p&gt;

&lt;p&gt;The host doesn't panic. It just stops responding to anything that touches disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fixes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fix 1: Disk I/O Isolation
&lt;/h3&gt;

&lt;p&gt;The k3s VMs and the ZFS pool were sharing the same NVMe. QEMU's page cache was adding to the I/O contention. I moved the k3s VM disks to cache=none + aio=native:&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/vm.tf&lt;/span&gt;
&lt;span class="nx"&gt;disks&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scsi&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;scsi0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;disk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;size&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"50G"&lt;/span&gt;
        &lt;span class="nx"&gt;cache&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
        &lt;span class="nx"&gt;aio&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"native"&lt;/span&gt;
        &lt;span class="nx"&gt;storage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"local-lvm"&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cache=none&lt;/code&gt; bypasses QEMU's page cache entirely — the guest handles its own caching. &lt;code&gt;aio=native&lt;/code&gt; uses Linux's native AIO instead of the emulated thread pool, reducing context switching. This alone reduced I/O latency variance by 40% under load.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 2: ZFS ARC Limits
&lt;/h3&gt;

&lt;p&gt;Without caps, the ARC can grow to consume most of available memory, which means more dirty pages waiting for txg commit. I capped the ARC:&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;# /etc/modprobe.d/zfs.conf&lt;/span&gt;
options zfs &lt;span class="nv"&gt;zfs_arc_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4294967296  &lt;span class="c"&gt;# 4GB max ARC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a 64GB host, 4GB for ARC is generous enough for read caching without creating a massive pool of dirty pages that can deadlock under I/O pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 3: txg Timeout Tuning
&lt;/h3&gt;

&lt;p&gt;ZFS defaults to a 5-second txg timeout. On hardware with known I/O fragility, I shortened 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="c"&gt;# /etc/modprobe.d/zfs.conf&lt;/span&gt;
options zfs &lt;span class="nv"&gt;zfs_txg_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shorter timeout means ZFS gives up on a stalled commit sooner, which sounds counterintuitive, but it prevents the deadlock from propagating. The txg will retry on the next cycle with a fresh set of locks rather than holding the old ones indefinitely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix 4: Staggered Boot Order
&lt;/h3&gt;

&lt;p&gt;The boot storm was a separate but related trigger. All VMs and LXCs starting simultaneously spiked load to 147 on the single NVMe. I added staggered boot ordering:&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;# NFS boots first (no k3s dependency)&lt;/span&gt;
&lt;span class="c1"&gt;# k3s VMs boot 30s apart&lt;/span&gt;
&lt;span class="c1"&gt;# LXCs start last&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents the "everyone needs disk at once" pattern that was triggering the PSU sag in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Taught Me
&lt;/h2&gt;

&lt;p&gt;The root cause wasn't software — it was hardware. But the software (ZFS 2.4.1) turned a power delivery problem into a host-level deadlock. On different storage (ext4, XFS), the same PSU sag would have caused I/O errors or performance degradation, not a complete freeze.&lt;/p&gt;

&lt;p&gt;The lesson: when debugging host-level freezes, check the physical layer first. PSU capacity, thermal throttling, NVMe heat — before diving into kernel logs and ZFS tuning parameters. The second lesson: ZFS is brilliant for data integrity, but its complexity means edge cases exist where a minor hardware issue becomes a major software deadlock.&lt;/p&gt;

&lt;p&gt;After these four changes, the host has been stable through sustained load tests. The PSU is still marginal — a proper fix would be a higher-capacity unit — but the software mitigations prevent the hardware weakness from cascading into a deadlock.&lt;/p&gt;




&lt;p&gt;Power delivery and I/O isolation are the same problem at enterprise scale: Azure VMs have their own power and I/O profiles, Premium SSD P30 IOPS limits interact with CPU burst credits, and a misconfigured VM series can trigger the same "everything competes for one resource" pattern that killed my homelab. The difference is that Azure hides the PSU behind an SLA — you just get a throttled VM instead of a frozen host.&lt;/p&gt;



</description>
      <category>homelab</category>
      <category>proxmox</category>
      <category>zfs</category>
      <category>debugging</category>
    </item>
    <item>
      <title>How a Single Volume Was 65% of My Velero Backup and What I Almost Excluded Instead</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 27 Jul 2026 10:25:30 +0000</pubDate>
      <link>https://dev.to/dwoitzik/how-a-single-volume-was-65-of-my-velero-backup-and-what-i-almost-excluded-instead-2kla</link>
      <guid>https://dev.to/dwoitzik/how-a-single-volume-was-65-of-my-velero-backup-and-what-i-almost-excluded-instead-2kla</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/velero-nfs-provisioner-root-mount-redundant-backup/" 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;Velero's nightly backup was taking 207 seconds and backing up 33.4GB. That's normal for a cluster with 15+ PVCs — except when one volume accounts for 65% of the total bytes. The nfs-provisioner's root-mount volume was backing up the entire shared NFS export as a single blob, even though every app's individual PVC (a subdirectory of the same tree) was already backed up separately.&lt;/p&gt;

&lt;p&gt;The fix was a one-line annotation. The investigation almost went wrong when the first hypothesis pointed at the wrong disk entirely.&lt;/p&gt;

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

&lt;p&gt;Velero's backup stats showed a disproportionate volume:&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;PodVolumeBackup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-provisioner-root&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;nfs-provisioner&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;33.4GB&lt;/span&gt;
&lt;span class="na"&gt;Duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;207s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total backup for that run: ~51GB. This single volume was 65% of the total. Every other PVC was 1-4GB.&lt;/p&gt;

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

&lt;p&gt;The nfs-provisioner pod mounts the entire shared NFS export at &lt;code&gt;/persistentvolumes&lt;/code&gt;. Every app's PVC is a subdirectory of this tree. Velero's &lt;code&gt;defaultVolumesToFsBackup: true&lt;/code&gt; setting means every volume the pod mounts gets backed up — including the root mount that contains all the subdirectories.&lt;/p&gt;

&lt;p&gt;Each app's own pod also mounts its PVC subdirectory separately. Velero backs that up too. So the same data is backed up twice: once as part of the root mount, once as the individual PVC.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Hypothesis (Wrong)
&lt;/h2&gt;

&lt;p&gt;My first guess was &lt;code&gt;garage-data/immich-library&lt;/code&gt; — Immich's photo library, which lives on separate USB-attached disks (&lt;a href="https://amzn.to/4vxwLpY" rel="noopener noreferrer"&gt;Seagate Portable 2TB External HDD*&lt;/a&gt;). Excluding it would have reduced backup size, but the USB disks aren't on the contended rpool. The backup bottleneck was the SSD, not USB. Excluding the Immich volume would have been the wrong fix for the right symptom.&lt;/p&gt;

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

&lt;p&gt;Verified the target before acting:&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;# Confirm the root-mount volume targets rpool (the contended disk)&lt;/span&gt;
findmnt /persistentvolumes
&lt;span class="c"&gt;# /dev/zvol/rpool/nfs  ← confirmed rpool&lt;/span&gt;

&lt;span class="c"&gt;# Confirm the individual PVCs are subdirectories of the same tree&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /persistentvolumes/
&lt;span class="c"&gt;# default-elasticsearch-data-0/&lt;/span&gt;
&lt;span class="c"&gt;# default-immich-redis-data-0/&lt;/span&gt;
&lt;span class="c"&gt;# default-minecraft-data/&lt;/span&gt;
&lt;span class="c"&gt;# ... (each is a separate PVC)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root mount is the entire rpool. Every individual PVC is a subdirectory. Backing up the root mount redundantly backs up everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Exclusion
&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/nfs-provisioner/application.yml&lt;/span&gt;
&lt;span class="na"&gt;podAnnotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backup.velero.io/backup-volumes-excludes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-subdir-external-provisioner-root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One annotation. Velero skips the root-mount volume entirely. Every app's individual PVC still gets backed up through its own pod's mount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before: 51GB total, 33.4GB from root mount (65%)
After:  17.6GB total, 0GB from root mount
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backup duration dropped from 207s to ~60s. No data was lost — every PVC is still backed up individually.&lt;/p&gt;

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

&lt;p&gt;This is a common Velero gotcha with NFS provisioners:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;NFS provisioner pod mounts the entire export root&lt;/li&gt;
&lt;li&gt;App pods mount individual PVC subdirectories&lt;/li&gt;
&lt;li&gt;Velero backs up both — root mount AND individual PVCs&lt;/li&gt;
&lt;li&gt;The root mount is redundant because it contains everything the individual PVCs contain&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is always the same: exclude the root mount via &lt;code&gt;backup.velero.io/backup-volumes-excludes&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Find Redundant Backups
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List all PodVolumeBackups and their sizes&lt;/span&gt;
kubectl get podvolumebacks &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | "\(.spec.volume) \(.status.progress.totalBytes // 0) \(.metadata.namespace)"'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If one volume is disproportionately large and its data is already covered by individual PVC backups, it's redundant.&lt;/p&gt;







&lt;h2&gt;
  
  
  🚀 Azure Firewall - Enterprise Forced Tunneling Edition — €49
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cycle-error-free resource ordering - deploys first time, every time&lt;/li&gt;
&lt;li&gt;KMS &amp;amp; Azure AD bypass routes - no broken Windows VMs or auth failures&lt;/li&gt;
&lt;li&gt;Dynamic for_each subnet binding - scales to any number of Spokes&lt;/li&gt;
&lt;li&gt;IP Group-based firewall policies - no hardcoded IP addresses&lt;/li&gt;
&lt;li&gt;FQDN baseline rules for Windows Updates and core Microsoft services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://woitzik-cloud.lemonsqueezy.com/checkout/buy/a955d698-acf5-4654-ae16-bb8ec1f7be15" rel="noopener noreferrer"&gt;Get the enterprise module →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Full source code · one-time payment · instant download&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Velero's &lt;code&gt;defaultVolumesToFsBackup: true&lt;/code&gt; is convenient but dangerous with NFS provisioners. The root mount contains everything, and Velero doesn't know it's redundant. Check your PodVolumeBackup stats — if one volume is 65% of your total, you're backing up the same data twice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>backup</category>
      <category>nfs</category>
      <category>velero</category>
    </item>
    <item>
      <title>Why a Cloud Backup Sync Was Failing Daily for Six Weeks</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 27 Jul 2026 10:08:19 +0000</pubDate>
      <link>https://dev.to/dwoitzik/why-a-cloud-backup-sync-was-failing-daily-for-six-weeks-5dg4</link>
      <guid>https://dev.to/dwoitzik/why-a-cloud-backup-sync-was-failing-daily-for-six-weeks-5dg4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/google-drive-api-throttling-backup-chunk-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;A systematic &lt;code&gt;ansible-playbook --check --diff&lt;/code&gt; sweep across every host group turned up a stale Vault credential and a cron job that appeared to have simply stopped running. The initial read was a straightforward drift problem: fix the credential, restore the cron schedule, move on. That read was wrong on both the diagnosis and, as it turned out one PR later, the intended fix.&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 the Audit Sweep Found
&lt;/h2&gt;

&lt;p&gt;Two issues on the PBS management host, both real:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A stale Vault credential that would have clobbered a working live token.&lt;/strong&gt; The &lt;code&gt;pbs_rclone_gdrive_token&lt;/code&gt; stored in Vault was from April; the live &lt;code&gt;rclone.conf&lt;/code&gt; on the host had a June token - rclone had refreshed it itself as OAuth tokens do, and Vault had never been updated to match. Running the Ansible role for real (not just &lt;code&gt;--check&lt;/code&gt;) would have overwritten the working, self-refreshed credential with a four-month-stale one, breaking authentication.&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;# Confirmed: live rclone.conf token newer than Vault's stored value&lt;/span&gt;
diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;rclone config show pbs-gdrive | &lt;span class="nb"&gt;grep &lt;/span&gt;token&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;vault kv get &lt;span class="nt"&gt;-field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pbs_rclone_gdrive_token secret/pbs&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# Fixed: update Vault to match the live, working token&lt;/span&gt;
vault kv patch secret/pbs &lt;span class="nv"&gt;pbs_rclone_gdrive_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;rclone config show pbs-gdrive | &lt;span class="nb"&gt;grep &lt;/span&gt;token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. The offsite sync cron job simply wasn't on the host.&lt;/strong&gt; Its own log file, &lt;code&gt;pbs-to-gdrive.log&lt;/code&gt;, showed it running - mostly failing - from May 4 through June 14, then nothing. No entries at all after that date. Something had stopped the schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wrong First Conclusion
&lt;/h2&gt;

&lt;p&gt;The natural read of "cron job disappeared, logs show it was mostly failing before that" is: something broke it, and the failures were probably why. Re-enable the schedule, fix whatever was causing the failures, done.&lt;/p&gt;

&lt;p&gt;Actually diagnosing the failures required running a real, bounded manual sync rather than reasoning from the log lines alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone &lt;span class="nb"&gt;sync&lt;/span&gt; /mnt/pbs-datastore gdrive:pbs-backup &lt;span class="nt"&gt;--transfers&lt;/span&gt; 4 &lt;span class="nt"&gt;--checkers&lt;/span&gt; 8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--stats&lt;/span&gt; 30s &lt;span class="nt"&gt;--stats-one-line&lt;/span&gt; &lt;span class="nt"&gt;--max-duration&lt;/span&gt; 30m
&lt;span class="c"&gt;# Transferred: 3.2 MiB / ~2.1 TiB, 0%, 1.6 KiB/s, ETA 12 weeks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1.6 KiB/s.&lt;/strong&gt; Not a credential failure, not a network failure - Google Drive's API was throttling the sync hard, and the reason became clear from what PBS actually stores on disk: not a handful of large archive files, but tens of thousands of small deduplicated chunk files. Google Drive's API rate-limits per-file operations aggressively, and a chunk-based datastore is close to the worst-case access pattern for that limit.&lt;/p&gt;

&lt;p&gt;At an observed throughput of ~1.6 KiB/s against a multi-terabyte datastore, the estimated time for just the &lt;em&gt;initial&lt;/em&gt; full sync was on the order of 12 weeks. The daily cron job, scoped to a 24-hour window, could never have completed a first full sync - every single run for six weeks was doomed from the start, not by a bug in the job but by a structural mismatch between the storage format and the destination's API characteristics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix That Turned Out to Be Wrong
&lt;/h2&gt;

&lt;p&gt;The first PR treated the missing cron job as accidental drift and restored it - re-added the scheduled task, assuming it had been silently dropped by some unrelated config change and needed putting back.&lt;/p&gt;

&lt;p&gt;It hadn't been silently dropped. Checking with the account owner surfaced the actual story: the cron job had been turned off &lt;em&gt;deliberately&lt;/em&gt;, because the destination Google Drive account doesn't have enough free storage quota for the PBS datastore's full size. Turning the schedule back on wasn't fixing drift - it was un-doing an intentional decision that the earlier PR had mistaken for an accident.&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 the first PR did (wrong - re-enables a deliberately-disabled job)&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;Sync PBS offsite cron&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.cron&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;pbs-to-gdrive-sync&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;   &lt;span class="c1"&gt;# ← reintroduces a schedule the user turned off&lt;/span&gt;

&lt;span class="c1"&gt;# The corrected version&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;Remove PBS offsite cron (deliberately disabled -- insufficient quota)&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.cron&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;pbs-to-gdrive-sync&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;absent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correction PR also removed the live crontab entry that the first "fix" had reintroduced, and rewrote the documentation to describe the situation accurately: not "broken, needs re-enabling" but "deliberately deferred pending a decision about storage quota or a different offsite strategy."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Same Log Pattern, Two Opposite Conclusions
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't the throttling finding by itself - it's that the same evidence (&lt;code&gt;pbs-to-gdrive.log&lt;/code&gt; showing failures, then silence) supported two completely different, mutually exclusive conclusions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;"This is drift - something broke, restore the intended state."&lt;/li&gt;
&lt;li&gt;"This is intentional - a human made a deliberate decision that the automation shouldn't override."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ansible's entire idea of "correct state" assumes the desired state is knowable from the playbook. It has no way to distinguish "this differs from the playbook because of an accident" from "this differs from the playbook because a human changed their mind after the playbook was written." Both look identical in a &lt;code&gt;--diff&lt;/code&gt; output. The only way to tell them apart is to ask.&lt;/p&gt;

&lt;p&gt;This is also, notably, the same false-positive class that closed out a side finding in the same PR: an AdGuard admin-password "drift" flagged by the same sweep turned out to be bcrypt's per-render random salt making two hashes of the &lt;em&gt;same&lt;/em&gt; password look different in a diff - not a real config drift either. Two findings from one audit sweep, both initially read as drift, both actually non-issues once verified against ground truth rather than assumed from the diff output.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Argues For
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Any Ansible task that can toggle a schedule, feature flag, or setting on or off needs a documented reason when it's off&lt;/strong&gt; - not just an inferred one from "the playbook says it should be on." A short comment (&lt;code&gt;# disabled 2026-06-XX: destination quota insufficient, see docs/backup-strategy.md&lt;/code&gt;) turns a future &lt;code&gt;--diff&lt;/code&gt; output from "ambiguous, ask the owner" into "documented, already explained."&lt;/p&gt;

&lt;p&gt;And more broadly: a drift-detection sweep is good at finding &lt;em&gt;that&lt;/em&gt; something differs from the intended state. It says nothing about &lt;em&gt;why&lt;/em&gt; it differs, and treating every diff as an accident to be corrected is exactly as wrong as treating every diff as intentional and ignoring it. Both directions of this mistake happened in the same PR chain here, within hours of each other.&lt;/p&gt;




&lt;p&gt;The direct Azure parallel: Azure Policy remediation tasks that "fix" a resource back to policy-compliant state can just as easily undo a deliberate, documented exception a team made for a legitimate reason. Policy exemptions exist for exactly this - but only if someone actually creates the exemption object instead of leaving the deviation undocumented and hoping nobody re-applies the policy. An undocumented intentional deviation and an actual accidental drift look identical to any automated compliance sweep.&lt;/p&gt;



</description>
      <category>backup</category>
      <category>homelab</category>
      <category>ansible</category>
    </item>
    <item>
      <title>MikroTik vs Ubiquiti for Home Network 2026</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 19 Jul 2026 11:07:31 +0000</pubDate>
      <link>https://dev.to/dwoitzik/mikrotik-vs-ubiquiti-for-home-network-2026-5g5j</link>
      <guid>https://dev.to/dwoitzik/mikrotik-vs-ubiquiti-for-home-network-2026-5g5j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/mikrotik-vs-ubiquiti-home-network-2026/" 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;&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;p&gt;This is the networking debate that never dies in homelab circles: MikroTik or Ubiquiti? I have run both in production — MikroTik RB5009 as my primary router for over two years with every firewall rule and VLAN managed via Terraform, and Ubiquiti UniFi gear in a friend's lab where I helped with deployment. This is not a spec-sheet comparison. It is a practical breakdown of what each platform actually delivers for a homelab that runs Proxmox, K3s, and zero-trust networking.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;MikroTik RB5009&lt;/strong&gt; if you want maximum control, Terraform-native automation, and the best price-to-performance in router hardware. The learning curve is steep, but the ceiling is infinitely higher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ubiquiti UniFi Dream Router&lt;/strong&gt; if you want plug-and-play setup, a polished UI, and an ecosystem where access points, switches, and cameras all manage from one dashboard. You pay a premium and give up depth of control.&lt;/p&gt;

&lt;p&gt;The short version: MikroTik is for engineers who want to own their network stack. Ubiquiti is for users who want a network that works without deep networking knowledge. Both are valid — but for a homelab running infrastructure-as-code, MikroTik wins decisively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&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;MikroTik RB5009&lt;/th&gt;
&lt;th&gt;Ubiquiti UniFi Dream Router&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;~€180*&lt;/td&gt;
&lt;td&gt;~€300*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;Marvell 98DX3236 (ARM)&lt;/td&gt;
&lt;td&gt;Quad-core ARM Cortex-A57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;1GB&lt;/td&gt;
&lt;td&gt;2GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ports&lt;/td&gt;
&lt;td&gt;7x GbE + 1x 10G SFP+&lt;/td&gt;
&lt;td&gt;4x GbE + 1x 2.5GbE WAN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switch Chip&lt;/td&gt;
&lt;td&gt;Yes (hardware offloading)&lt;/td&gt;
&lt;td&gt;Integrated (software)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;RouterOS 7&lt;/td&gt;
&lt;td&gt;UniFi Network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Management&lt;/td&gt;
&lt;td&gt;CLI, WinBox, API, Terraform&lt;/td&gt;
&lt;td&gt;UniFi Controller (UI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VLAN Support&lt;/td&gt;
&lt;td&gt;Full (bridge VLAN filtering)&lt;/td&gt;
&lt;td&gt;Full (VLAN networks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPN&lt;/td&gt;
&lt;td&gt;WireGuard, OpenVPN, IPsec, PPTP&lt;/td&gt;
&lt;td&gt;WireGuard, OpenVPN, L2TP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wi-Fi&lt;/td&gt;
&lt;td&gt;None (bring your own AP)&lt;/td&gt;
&lt;td&gt;Built-in Wi-Fi 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price/Performance&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Category-by-Category Breakdown
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Design &amp;amp; Build Quality
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;RB5009&lt;/strong&gt; is an industrial-looking metal box. No Wi-Fi, no antennas, no RGB. It has 7 Gigabit Ethernet ports, one 10G SFP+ cage, and a USB 3.0 port. The metal chassis dissipates heat passively — no fan, no noise. It is designed to live in a rack or on a shelf without anyone noticing it. At 100x155x33mm, it is compact enough to mount in a 10-inch rack (I use a &lt;a href="https://amzn.to/4pGBkxb" rel="noopener noreferrer"&gt;DIGITUS 10" rack*&lt;/a&gt; for mine).&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;UniFi Dream Router&lt;/strong&gt; (UDR) is designed to be seen. It is a white, cylindrical unit with a built-in Wi-Fi 6 access point and a small LED status ring. It looks like something from a modern living room, not a server closet. The build quality is good — solid plastic, well-ventilated — but it is designed for desk or shelf placement, not rack mounting. The form factor says "consumer product" while the RB5009 says "networking equipment."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: MikroTik RB5009&lt;/strong&gt; for homelab use. The fanless design, rack-friendly form factor, and 10G SFP+ port make it the better infrastructure component. The UDR wins for living room aesthetics if that matters to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Features &amp;amp; Flexibility
&lt;/h3&gt;

&lt;p&gt;This is where the gap becomes a canyon.&lt;/p&gt;

&lt;p&gt;RouterOS 7 on the RB5009 is, functionally, a full Linux networking stack exposed through a proprietary CLI and API. You get: bridging with VLAN filtering, firewall with stateful packet inspection, NAT (source and destination), QoS (HTSQ, PCQ, CAKE), MPLS, BGP, OSPF, VPLS, bonding, MLAG, traffic shaping, bandwidth limiting, DHCP server with static bindings, DNS forwarding with split-horizon, CAPsMAN (centralized AP management), and more. The feature set is closer to enterprise networking gear (Cisco, Juniper) than to consumer routers.&lt;/p&gt;

&lt;p&gt;The UDR runs UniFi Network, which is a software-defined networking platform. It provides: VLAN segmentation, firewall rules, IDS/IPS (Intrusion Detection/Prevention), traffic identification, guest portal, and DPI (Deep Packet Inspection). The UniFi Controller — a web application that runs either on the UDR itself or on a separate server — manages all of it through a graphical interface.&lt;/p&gt;

&lt;p&gt;The UniFi feature set is impressive for what it is, but it is fundamentally a curated subset. You get the features Ubiquiti decided to implement, configured the way Ubiquiti decided to configure them. MikroTik gives you the entire toolbox and lets you build whatever you want. For a homelab that runs Terraform-managed infrastructure, the difference is critical — MikroTik's API and CLI let you automate everything; UniFi's API is limited and unofficial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: MikroTik RB5009&lt;/strong&gt; for depth and automation potential. &lt;strong&gt;UniFi UDR&lt;/strong&gt; for breadth of managed features out of the box (IDS/IPS, DPI, traffic identification).&lt;/p&gt;

&lt;h3&gt;
  
  
  Ease of Use
&lt;/h3&gt;

&lt;p&gt;Ubiquiti wins this category flatly, and it is not close.&lt;/p&gt;

&lt;p&gt;The UniFi Controller provides a visual dashboard showing network topology, connected clients, traffic flows, and security events. VLAN creation is a form fill. Firewall rules are a guided wizard. Firmware updates are one-click. If you have never configured a router before, the UDR will have your network segmented and secured in under 30 minutes.&lt;/p&gt;

&lt;p&gt;RouterOS is a different world. The WinBox GUI exists and works, but it mirrors the CLI structure — you navigate menus organized by protocol and feature, not by task. Creating a VLAN involves: creating a bridge, adding ports to the bridge, creating VLAN entries on the bridge, creating an interface for the VLAN, assigning an IP address, creating a DHCP server, and creating firewall rules for the new network. Each step is a separate resource. The CLI (or Terraform) makes this repeatable; the GUI makes it tedious.&lt;/p&gt;

&lt;p&gt;I will not sugarcoat it: RouterOS has a steeper learning curve than any consumer router OS. The first time you configure Bridge VLAN Filtering, you will lock yourself out. The second time, you will not. By the third time, you will understand L2 networking better than 99% of home network users. That knowledge has value beyond MikroTik — it transfers to every networking platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Ubiquiti UDR&lt;/strong&gt; for initial setup and ongoing management. &lt;strong&gt;MikroTik&lt;/strong&gt; for users who want to actually understand their network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security &amp;amp; Firewall
&lt;/h3&gt;

&lt;p&gt;Both platforms support stateful packet inspection, NAT, and rule-based firewalling. The differences are in philosophy and depth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MikroTik&lt;/strong&gt; gives you full control over every firewall rule. The RouterOS firewall is a chain-based system (input, forward, output) with rule ordering that matters. You can filter by source/destination IP, port, protocol, connection state, interface, VLAN, packet content, and more. The zero-trust approach I documented in &lt;a href="https://dev.to/blog/mikrotik-zero-trust-firewall-terraform"&gt;MikroTik Zero Trust Firewall with Terraform&lt;/a&gt; is built entirely on this: default-drop on input and forward, with explicit allow rules for each service.&lt;/p&gt;

&lt;p&gt;Here is the actual Terraform code that creates the final drop rule in my firewall:&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_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"fwd_99_drop_all"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"drop"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"99: Global - Final Drop (Zero Trust Policy)"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single rule, placed last in the chain, enforces zero trust for all forwarded traffic. Every allowed service must have an explicit rule before it. The simplicity is deceptive — the complexity is in the rules above it, but the principle is clear: deny everything, allow by exception.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ubiquiti&lt;/strong&gt; offers IDS/IPS as a built-in feature, which MikroTik does not have natively. The UniFi IDS/IPS inspects traffic for known attack signatures and can automatically block or alert on suspicious activity. For a home network, this provides a layer of protection that a MikroTik firewall alone does not — unless you add Suricata or Snort as a separate IDS on a mirror port.&lt;/p&gt;

&lt;p&gt;The tradeoff: UniFi's IDS/IPS is a black box. You cannot customize the signatures, you cannot tune the sensitivity beyond broad categories, and you cannot export the logs to a SIEM easily. MikroTik's firewall is transparent — every rule is visible, every log entry is configurable, and you can pipe logs to an external syslog server or Loki stack for analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Tie.&lt;/strong&gt; MikroTik wins for transparent, automatable, zero-trust firewall rules. UniFi wins for built-in IDS/IPS that requires zero configuration. The ideal setup uses both — MikroTik as the router/firewall, UniFi APs for wireless, and an external IDS like Suricata for deep packet inspection.&lt;/p&gt;

&lt;h3&gt;
  
  
  VPN
&lt;/h3&gt;

&lt;p&gt;Both platforms support WireGuard and OpenVPN. The implementation differences matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MikroTik RB5009&lt;/strong&gt; runs WireGuard natively in RouterOS 7. The WireGuard implementation supports multiple peers, fine-grained allowed IPs, and persistent keepalive — everything you need for site-to-site or remote access VPN. I run WireGuard on my RB5009 for remote access to the homelab, and the Terraform code for it is in the repo:&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_interface_wireguard"&lt;/span&gt; &lt;span class="s2"&gt;"wg0"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;listen_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;13231&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;"wg0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MikroTik also supports IPsec (IKEv1 and IKEv2) for site-to-site VPN with other vendors, OpenVPN for legacy client access, and PPTP (which you should never use). The RB5009's CPU handles WireGuard encryption in software — it maxes out around 1.5-2Gbps throughput, which is more than enough for a home WAN link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UniFi UDR&lt;/strong&gt; supports WireGuard and OpenVPN through the controller interface. Setup is simpler — a few form fills and you have a VPN tunnel. The UDR also supports Teleport (Ubiquiti's proprietary VPN) which uses WireGuard under the hood but with a simplified peer exchange mechanism.&lt;/p&gt;

&lt;p&gt;The limitation: UniFi's VPN integration is shallow. You cannot fine-tune WireGuard parameters (MTU, pre-shared keys, handshake intervals) the way you can on MikroTik. Site-to-site VPN between UniFi and non-UniFi devices is possible but not well-documented. For a homelab that needs VPN access to specific VLANs or subnets, MikroTik's granular control is essential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: MikroTik RB5009&lt;/strong&gt; for VPN flexibility and fine-grained control. UniFi wins for quick-and-easy remote access setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  VLAN Support
&lt;/h3&gt;

&lt;p&gt;Both platforms support VLAN tagging, trunk ports, and access ports. The implementation approach is fundamentally different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MikroTik&lt;/strong&gt; handles VLANs through Bridge VLAN Filtering on a single bridge. All physical ports join one bridge, and VLANs are defined as entries in the bridge's VLAN table. This approach enables hardware offloading on the RB5009's switch chip — tagged traffic is switched in hardware, not software. The result: full wire-speed VLAN performance even with dozens of VLANs configured.&lt;/p&gt;

&lt;p&gt;The Terraform code for my entire VLAN matrix — five VLANs, trunk ports, access ports, IP addresses, and DHCP servers — is about 100 lines of HCL. Add a new VLAN? Change one map and run &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UniFi&lt;/strong&gt; handles VLANs by creating "VLAN Networks" in the controller. Each VLAN is a separate network object with its own subnet, DHCP scope, and firewall rules. Trunk ports are configured per-switch-port in the UI. The approach is intuitive — each VLAN is a first-class object in the UI — but it ties you to the UniFi Controller for management.&lt;/p&gt;

&lt;p&gt;Both approaches work well. MikroTik's is more flexible and automatable. UniFi's is more visual and self-documenting. For a homelab that needs VLANs for network segmentation (management, server, DMZ, IoT, admin), both platforms handle it without issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Tie.&lt;/strong&gt; MikroTik for automation and hardware offloading. UniFi for visual management and simplicity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terraform &amp;amp; Automation Support
&lt;/h3&gt;

&lt;p&gt;This is where MikroTik pulls away decisively for any homelab running infrastructure-as-code.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;MikroTik Terraform Provider&lt;/strong&gt; (&lt;code&gt;routeros&lt;/code&gt;) is mature, well-maintained, and covers nearly every RouterOS resource: interfaces, bridges, VLANs, firewall rules, DHCP, DNS, WireGuard, routing, and more. You can define your entire network stack in HCL, version it in Git, and apply changes through a CI pipeline or Atlantis. This is exactly how I manage my homelab — every MikroTik configuration change goes through a PR, gets reviewed, and is applied via Atlantis.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Ubiquiti Terraform Provider&lt;/strong&gt; (&lt;code&gt;ubiquiti&lt;/code&gt;) is unofficial and limited. The community provider (&lt;code&gt;paultyng/ubiquiti&lt;/code&gt;) covers basic device management but does not support network configuration, VLAN creation, or firewall rules through Terraform. There is no official UniFi Terraform provider from Ubiquiti. This means every network configuration change on UniFi must go through the controller UI — there is no IaC path.&lt;/p&gt;

&lt;p&gt;For a homelab that already uses Terraform for Proxmox VMs, MikroTik firewall rules, and Kubernetes resources, the lack of UniFi Terraform support is a dealbreaker. You would end up with two configuration workflows: Terraform for everything except the network, and manual UI clicks for the network. That is exactly the kind of configuration drift that IaC is designed to prevent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: MikroTik RB5009&lt;/strong&gt; — decisively. No meaningful competition in this category.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ecosystem &amp;amp; Expandability
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Ubiquiti&lt;/strong&gt; wins on ecosystem breadth. The UniFi ecosystem includes switches, access points, cameras (Protect), phones (Talk), displays (Connect), and a centralized controller that manages all of it. If you want one vendor for your entire network stack — router, switches, APs, cameras — UniFi provides that experience. The ecosystem lock-in is real, but so is the integration quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MikroTik&lt;/strong&gt; has a broader product line than most people realize — routers, switches, access points, antennas, and even outdoor wireless gear. CAPsMAN (Centralized Access Point Manager) lets you manage MikroTik APs from the router itself. But the ecosystem is not as tightly integrated as UniFi. Each device runs RouterOS independently; there is no single controller dashboard (The Dude exists but is not comparable to the UniFi Controller).&lt;/p&gt;

&lt;p&gt;For a homelab that primarily needs a router and a few switches, MikroTik's ecosystem is sufficient. For a whole-home networking deployment with cameras, multiple APs, and managed switches, UniFi's ecosystem provides a more cohesive experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: Ubiquiti UDR&lt;/strong&gt; for integrated ecosystem. &lt;strong&gt;MikroTik&lt;/strong&gt; for standalone router/switch deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Price &amp;amp; Value
&lt;/h3&gt;

&lt;p&gt;The RB5009 costs roughly €180. The UniFi Dream Router costs roughly €300. That €120 difference buys you: built-in Wi-Fi 6 on the UDR (MikroTik requires a separate AP, ~€50-100 for a hAP ac3 or cAP ax), the UniFi Controller software, and IDS/IPS.&lt;/p&gt;

&lt;p&gt;If you add a MikroTik AP to the RB5009 (which you will need if you want Wi-Fi), the total cost approaches the UDR. But you get a significantly more powerful router and a separate, dedicated AP — which is arguably better than a combined router/AP unit that must serve both roles.&lt;/p&gt;

&lt;p&gt;For a homelab that does not need Wi-Fi from the router (most homelabs have a separate AP or run wired-only), the RB5009 at €180 is the clear value winner. For a whole-home setup where one device handles routing and Wi-Fi, the UDR's combined value is harder to beat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winner: MikroTik RB5009&lt;/strong&gt; for homelab use. &lt;strong&gt;UDR&lt;/strong&gt; for whole-home Wi-Fi + routing in one device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Should Buy Which
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Buy the MikroTik RB5009 if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You run Terraform for infrastructure management and want network automation&lt;/li&gt;
&lt;li&gt;You want zero-trust firewall rules with full control over every packet&lt;/li&gt;
&lt;li&gt;You value learning networking over having it pre-configured&lt;/li&gt;
&lt;li&gt;You do not need Wi-Fi from the router itself&lt;/li&gt;
&lt;li&gt;Budget matters — €180 buys enterprise-grade routing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Buy the UniFi Dream Router if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want plug-and-play setup with a polished UI&lt;/li&gt;
&lt;li&gt;You need built-in Wi-Fi 6 without a separate AP&lt;/li&gt;
&lt;li&gt;You want IDS/IPS without deploying a separate appliance&lt;/li&gt;
&lt;li&gt;You are building a whole-home UniFi ecosystem (switches, APs, cameras)&lt;/li&gt;
&lt;li&gt;You do not want to learn CLI-based network configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Buy both (MikroTik router + UniFi AP) if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want the best of both worlds: MikroTik routing/firewall automation with UniFi wireless management&lt;/li&gt;
&lt;li&gt;This is what I would do if I were starting a new homelab today. MikroTik for the router, UniFi for the APs. The MikroTik handles VLANs, firewall, and VPN via Terraform. The UniFi AP handles wireless through its controller. Two vendors, two strengths, zero compromise.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Can MikroTik and UniFi devices coexist on the same network?
&lt;/h3&gt;

&lt;p&gt;Yes, they work together without issue. MikroTik handles routing and VLAN segmentation; UniFi APs broadcast the VLAN-tagged SSIDs. The key configuration point: the switch port connecting the UniFi AP must be a trunk port carrying all VLAN SSIDs. This is configured on the MikroTik side (bridge VLAN filtering) and the UniFi side (port profile with VLAN tagging). Both platforms handle this standard configuration well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is MikroTik really harder to set up than Ubiquiti?
&lt;/h3&gt;

&lt;p&gt;Yes, initially. MikroTik's RouterOS has a learning curve that takes 2-4 weeks to climb for basic proficiency. Ubiquiti's UniFi can be configured in an afternoon. But the long-term payoff is significant: once you understand RouterOS, you can configure any networking scenario without vendor lock-in. The knowledge transfers to Cisco, Juniper, and every other networking platform. UniFi knowledge stays within the UniFi ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use MikroTik for my home Wi-Fi?
&lt;/h3&gt;

&lt;p&gt;Not directly. MikroTik makes access points (hAP ac3, cAP ax), but their wireless performance and roaming are inferior to UniFi's. MikroTik excels at routing and switching; let a dedicated AP handle wireless. Pair an RB5009 with a UniFi U6 Lite or U6 Pro for the best of both worlds.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does MikroTik security compare to Ubiquiti?
&lt;/h3&gt;

&lt;p&gt;MikroTik's firewall is more configurable but requires manual hardening. RouterOS 7 has had security vulnerabilities (WinBox CVE in 2018, Chimay Red attacks), but MikroTik patches quickly and the attack surface is manageable with proper firewall rules and by disabling unused services. UniFi's IDS/IPS provides automated threat detection that MikroTik lacks natively, but the black-box nature of the detection limits its utility for security-conscious users who want full visibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I manage MikroTik with Terraform?
&lt;/h3&gt;

&lt;p&gt;Yes, the &lt;code&gt;routeros&lt;/code&gt; Terraform provider is mature and actively maintained. It covers interfaces, bridges, VLANs, firewall rules, DHCP, DNS, WireGuard, routing, and more. I manage my entire MikroTik RB5009 configuration through Terraform — every change goes through Git, gets reviewed, and is applied via Atlantis. The provider is at version 1.x and supports RouterOS 7.x. See the &lt;a href="///blog/mikrotik-vlan-filtering-terraform-proxmox.mdx"&gt;Terraform VLAN filtering article&lt;/a&gt; for a real-world example.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the best MikroTik router for a home lab?
&lt;/h3&gt;

&lt;p&gt;The RB5009 is the sweet spot. It has 7 Gigabit ports (enough for most homelabs), a 10G SFP+ uplink for future-proofing, a switch chip for hardware-offloaded VLANs, and RouterOS 7 with full feature support. The hEX S (€70) is the budget option but lacks the 10G port and has a less capable switch chip. The CCR2004 (€300+) is overkill for home use unless you need 10Gbps routing throughput.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This is not a close comparison — but it is not a one-sided one either.&lt;/p&gt;

&lt;p&gt;MikroTik wins for any homelab running infrastructure-as-code. The Terraform integration, the granular firewall control, the hardware-offloaded VLAN switching, and the 10G SFP+ uplink make the RB5009 the definitive choice for engineers who want to own their network stack. At €180, it is also the better value for routing performance.&lt;/p&gt;

&lt;p&gt;Ubiquiti wins for whole-home networking where ease of use, ecosystem integration, and built-in Wi-Fi matter more than IaC automation. The UniFi Controller is the best consumer networking management platform available, and the ecosystem of switches, APs, and cameras is genuinely compelling.&lt;/p&gt;

&lt;p&gt;For a homelab running Proxmox, K3s, and Terraform: buy the MikroTik RB5009. The learning curve pays for itself in flexibility, control, and the ability to manage your network the same way you manage your servers — as code.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Azure Zero-Trust Starter Pack — €99
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Includes: Acmebot Enterprise VNet, Hub &amp;amp; Spoke Zero-Trust, Azure Firewall Forced Tunneling&lt;/li&gt;
&lt;li&gt;Save €48 vs. buying the three modules separately&lt;/li&gt;
&lt;li&gt;Bonus: ISO 27001 Auditor Checklist (PDF) - Annex A control map for all 3 modules&lt;/li&gt;
&lt;li&gt;Everything to pass your first Azure compliance audit&lt;/li&gt;
&lt;li&gt;Full source code for all 3 modules - no lock-in, no black box&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.toPENDING_LEMONSQUEEZY_BUNDLE_URL"&gt;Get the enterprise module →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Full source code · one-time payment · instant download&lt;/em&gt;&lt;/p&gt;




</description>
      <category>networking</category>
      <category>mikrotik</category>
      <category>review</category>
    </item>
  </channel>
</rss>
