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

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

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

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

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

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

&lt;p&gt;My k3s cluster has two StorageClasses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NFS — for most workloads&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;storage.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;StorageClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-client&lt;/span&gt;
&lt;span class="na"&gt;provisioner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-subdir-external-provisioner&lt;/span&gt;
&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.20.100&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/archive&lt;/span&gt;
&lt;span class="na"&gt;reclaimPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Retain&lt;/span&gt;

&lt;span class="c1"&gt;# Local-path — for embedded databases&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;storage.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;StorageClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local-path&lt;/span&gt;
&lt;span class="na"&gt;provisioner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rancher.io/local-path&lt;/span&gt;
&lt;span class="na"&gt;reclaimPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Retain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;p&gt;Garage runs with two storage mounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/apps/garage/garage.yml&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;data&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;garage-data&lt;/span&gt;      &lt;span class="c1"&gt;# NFS — bucket objects&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;garage-meta&lt;/span&gt;      &lt;span class="c1"&gt;# local-path — SQLite metadata&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;SQLite has a &lt;code&gt;.recover&lt;/code&gt; command that can extract data from a corrupted database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dump recoverable data from corrupted SQLite&lt;/span&gt;
sqlite3 db.sqlite &lt;span class="s2"&gt;".recover"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; recovered.sql

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

&lt;p&gt;Move every embedded database to &lt;code&gt;local-path&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Garage — meta volume moved to local-path&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;garage-meta&lt;/span&gt;      &lt;span class="c1"&gt;# NOW: local-path (was: nfs-client)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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




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

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



</description>
      <category>kubernetes</category>
      <category>storage</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>My Media Stack Lives in Two Containers and a Python CronJob</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 14 Sep 2026 12:37:38 +0000</pubDate>
      <link>https://dev.to/dwoitzik/my-media-stack-lives-in-two-containers-and-a-python-cronjob-j2k</link>
      <guid>https://dev.to/dwoitzik/my-media-stack-lives-in-two-containers-and-a-python-cronjob-j2k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/media-stack-two-containers-python-cronjob/" 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;My media acquisition stack — SABnzbd for Usenet downloads, Sonarr for TV, Radarr for movies, Bazarr for subtitles, NZBHydra2 for indexer search — used to run as k3s Deployments. It worked, but three problems made it a bad fit for Kubernetes: GPU passthrough for Jellyfin transcoding, per-flow traffic isolation for indexer queries versus the actual download path, and the NFS file-locking trap for media libraries.&lt;/p&gt;

&lt;p&gt;The solution: move the media stack out of k3s entirely. Jellyfin runs in its own GPU-passthrough LXC. The acquisition stack runs in a second LXC with Docker Compose. A Python CronJob in k3s bridges the two via Traefik Service+Endpoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; the first version of this stack wrapped the whole acquisition LXC in a Mullvad WireGuard tunnel via &lt;a href="https://github.com/qdm12/gluetun" rel="noopener noreferrer"&gt;gluetun&lt;/a&gt;, on the assumption that every flow out of that box needed VPN protection equally. Two days later I tore that back out — see "The Traffic-Isolation Rethink" below for why a single blanket tunnel was the wrong model for what these five apps actually do.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why Kubernetes Was Wrong for Media
&lt;/h2&gt;

&lt;h3&gt;
  
  
  GPU Passthrough
&lt;/h3&gt;

&lt;p&gt;Jellyfin needs GPU access for hardware video transcoding. The &lt;a href="https://dev.to/go/4bv3yF1"&gt;BMAX Mini PC*&lt;/a&gt; has an AMD Radeon Vega iGPU that supports VAAPI hardware transcoding. Proxmox GPU passthrough requires IOMMU group isolation — the GPU is passed to a single container or VM exclusively.&lt;/p&gt;

&lt;p&gt;Kubernetes doesn't natively support GPU passthrough for LXCs. The &lt;code&gt;nvidia-device-plugin&lt;/code&gt; works for NVIDIA GPUs on specific cloud providers, but for AMD iGPU passthrough on bare-metal Proxmox, you need a dedicated LXC with &lt;code&gt;/dev/dri/renderD128&lt;/code&gt; mapped directly.&lt;/p&gt;

&lt;p&gt;Jellyfin runs in &lt;code&gt;ct-srv-jellyfin-01&lt;/code&gt; with:&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;lxc_conf&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;desc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Jellyfin - GPU passthrough"&lt;/span&gt;
  &lt;span class="c1"&gt;# GPU device mapped via pct set&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;k3s's own nodes here (&lt;code&gt;vm-srv-k3s-11/12/13&lt;/code&gt;) are Proxmox &lt;strong&gt;VMs&lt;/strong&gt;, not LXCs — a VM doesn't share the host kernel, so there's no "just bind-mount the device node" path the way there is for an LXC. Getting a k3s pod real GPU access on this hardware would mean classic VFIO passthrough of the iGPU to one specific VM: unbinding &lt;code&gt;amdgpu&lt;/code&gt; from the Proxmox host and handing the whole device to &lt;code&gt;vfio-pci&lt;/code&gt; instead. I looked at this seriously before ruling it out, because "GPU-in-Kubernetes" device plugins exist and I wanted to know if they'd apply.&lt;/p&gt;

&lt;p&gt;They don't, for this hardware. AMD's own &lt;code&gt;rocm/k8s-device-plugin&lt;/code&gt; targets ROCm compute (HIP/OpenCL) — a materially heavier stack than what VAAPI hardware transcoding actually needs, which is just &lt;code&gt;/dev/dri&lt;/code&gt; visibility. And a Kubernetes device plugin can't manufacture GPU access a node's kernel doesn't already have — for a VM, that access only exists after real hypervisor-level VFIO passthrough, which a plugin doesn't do. Worse, this is a single consumer Ryzen APU, not a data-center part with SR-IOV or mediated-device support for splitting one GPU across VMs — passthrough would hand the &lt;em&gt;entire&lt;/em&gt; GPU to exactly one of the three k3s VMs, and the Proxmox host itself (which uses &lt;code&gt;amdgpu&lt;/code&gt; for its own display/telemetry) would permanently lose access to it. There's also no portability payoff to offset that cost: k3s's scheduler can't move a pod needing a passed-through device to a &lt;em&gt;different&lt;/em&gt; node than the one VFIO was bound to, so the usual "GPU follows the pod" reason people move transcoding into Kubernetes never materializes on a single-host, single-iGPU homelab. It would just be Jellyfin running on a VM instead of an LXC, at the permanent cost of the GPU being unavailable to anything else on the host.&lt;/p&gt;

&lt;p&gt;The LXC path avoids all of that: an LXC shares the host's kernel, so the host keeps the &lt;code&gt;amdgpu&lt;/code&gt; driver bound and simply grants the container access to the resulting &lt;code&gt;/dev/dri/renderD128&lt;/code&gt; device node. Non-exclusive from the host's perspective, already proven working, no PCI device binding to get wrong. If this box ever gets a GPU with real SR-IOV support, this is worth revisiting — the constraint here is the specific hardware, not a principled objection to GPU workloads in Kubernetes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Traffic-Isolation Rethink
&lt;/h3&gt;

&lt;p&gt;The acquisition LXC has two genuinely different outbound flows, and my first pass treated them as one problem. SABnzbd connects to Eweka (my Usenet provider) over NNTPS on port 563 — already encrypted end-to-end, and Usenet copyright enforcement works exclusively via BitTorrent peer-list monitoring, so there's no mechanism by which an ISP or rights-holder observes or reports Usenet downloads in the first place. NZBHydra2's indexer search queries are a completely different flow: plain HTTP/HTTPS lookups against third-party indexer sites, which &lt;em&gt;does&lt;/em&gt; expose the home IP to whoever's on the other end, the same as browsing any site directly.&lt;/p&gt;

&lt;p&gt;Wrapping the whole LXC in gluetun/Mullvad "solved" both at once, but it was the wrong tool for either: VPN on the download path halves throughput for no privacy benefit Eweka's own SSL doesn't already provide, and it risks Eweka flagging the account for apparent multi-subscriber IP sharing. I tore gluetun out two days after standing it up and replaced it with a model that actually matches the two flows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SABnzbd → Eweka, direct, over SSL.&lt;/strong&gt; No VPN. The ISP sees "connected to news.eweka.nl," never content — that's the whole job done by NNTPS alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NZBHydra2 → indexers, through a Tor SOCKS5 proxy&lt;/strong&gt;, &lt;code&gt;tor&lt;/code&gt; container at &lt;code&gt;172.28.1.10:9050&lt;/code&gt;, configured with &lt;strong&gt;no direct-connection fallback&lt;/strong&gt;. Search queries are small and latency-tolerant, a good fit for Tor's limited bandwidth, and Tor is a better fit than a commercial VPN for metadata-only queries — no single operator to trust, no throughput to throttle. If Tor is down, indexer queries fail outright instead of silently leaking the home IP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I verified this configuration directly rather than trusting the design intent on paper: &lt;code&gt;sabnzbd.ini&lt;/code&gt; shows &lt;code&gt;socks5_proxy_url = ""&lt;/code&gt; (empty — SABnzbd was never routed through Tor) with &lt;code&gt;ssl = 1&lt;/code&gt;, &lt;code&gt;ssl_verify = 2&lt;/code&gt; confirming the direct-to-Eweka SSL path is real; &lt;code&gt;nzbhydra.yml&lt;/code&gt; shows &lt;code&gt;proxyType: SOCKS&lt;/code&gt; pointed at the Tor container with no fallback option enabled. One live exception I noticed and haven't chased down yet: one specific indexer bypasses Tor and connects directly (&lt;code&gt;proxyIgnoreDomains&lt;/code&gt;) — possibly a site that blocks Tor exit nodes, possibly a leftover exception from before I understood this stack properly. Worth revisiting.&lt;/p&gt;

&lt;p&gt;Tor's bandwidth genuinely can't handle bulk transfers, and routing downloads through it would be abusive to a network that exists for people who need anonymity for safety — never route the actual download path through Tor, only small metadata lookups.&lt;/p&gt;

&lt;p&gt;The acquisition LXC (&lt;code&gt;ct-srv-media-acq-01&lt;/code&gt;) runs all five apps this way — no blanket tunnel, no kill-switch sidecar to maintain, no shared failure mode between "is Eweka's SSL up" and "is the VPN provider's WireGuard endpoint reachable today."&lt;/p&gt;

&lt;h3&gt;
  
  
  NFS File Locking
&lt;/h3&gt;

&lt;p&gt;Media libraries (downloaded files, metadata databases) live on NFS. As I wrote about in &lt;a href="https://dev.to/blog/nfs-vs-local-path-sqlite-trap/"&gt;the SQLite trap article&lt;/a&gt;, NFS file-locking semantics don't work with embedded databases. Sonarr and Radarr use SQLite internally for their media databases — and those databases corrupt on NFS under concurrent access.&lt;/p&gt;

&lt;p&gt;Moving the acquisition stack to a local LXC with local storage eliminates the NFS lock problem. The media files themselves (downloaded episodes, movies) still live on NFS for sharing, but the application databases stay local.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│  k3s Cluster                                │
│  ┌─────────────────────────────────────┐    │
│  │ Python CronJob (every 10 min)       │    │
│  │ - Checks Sonarr/Radarr queue        │    │
│  │ - Clears stuck items                │    │
│  │ - Triggers Jellyfin library scan    │    │
│  └──────────┬──────────────────────────┘    │
│             │ HTTP via Traefik              │
│  ┌──────────▼──────────────────────────┐    │
│  │ Traefik IngressRoutes               │    │
│  │ - sabnzbd.woitzik.dev               │    │
│  │ - sonarr.woitzik.dev                │    │
│  │ - radarr.woitzik.dev                │    │
│  │ - bazarr.woitzik.dev                │    │
│  └─────────────────────────────────────┘    │
└──────────────────┬──────────────────────────┘
                   │ Traefik Service+Endpoints
┌──────────────────▼──────────────────────────┐
│  ct-srv-media-acq-01 (LXC, Tor for indexers)│
│  ┌─────────┐ ┌────────┐ ┌────────┐         │
│  │ SABnzbd │ │ Sonarr │ │ Radarr │         │
│  └─────────┘ └────────┘ └────────┘         │
│  ┌─────────┐ ┌──────────────┐              │
│  │ Bazarr  │ │ NZBHydra2    │              │
│  └─────────┘ └──────────────┘              │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│  ct-srv-jellyfin-01 (LXC, GPU passthrough)  │
│  ┌─────────┐ ┌──────────────┐              │
│  │ Jellyfin│ │ /dev/dri/    │              │
│  │         │ │ renderD128   │              │
│  └─────────┘ └──────────────┘              │
└─────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Traefik Bridge
&lt;/h2&gt;

&lt;p&gt;k3s Traefik routes external traffic to services inside the cluster. But the media stack isn't in the cluster — it's in LXCs. The bridge: Traefik IngressRoutes point at Kubernetes Services, which use &lt;code&gt;Endpoints&lt;/code&gt; objects with hardcoded IP addresses pointing at the LXC containers.&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/jellyfin/jellyfin.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;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;Service&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;jellyfin&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;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;8096&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8096&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;Endpoints&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;jellyfin&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;subsets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.20.254&lt;/span&gt;  &lt;span class="c1"&gt;# ct-srv-jellyfin-01&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;8096&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same pattern for all external services. The Service has no &lt;code&gt;selector&lt;/code&gt; — it's a "headless" Service where the Endpoints are manually maintained. Traefik doesn't know or care that the backend is an LXC instead of a pod.&lt;/p&gt;

&lt;p&gt;The IngressRoute for Jellyfin:&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;jellyfin&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(`media.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;jellyfin&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;8096&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;The media stack gets &lt;a href="https://dev.to/blog/k3s-authelia-proxmox-homelab/"&gt;Authelia&lt;/a&gt; protection, wildcard TLS, and &lt;a href="https://dev.to/blog/cloudflare-tunnel-zero-inbound-ports/"&gt;Cloudflare Tunnel&lt;/a&gt; external access — the same as every in-cluster service. The only difference is the backend IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Python CronJob
&lt;/h2&gt;

&lt;p&gt;A Python CronJob runs every 10 minutes inside k3s, bridging the gap between the media stack and the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# CronJob that monitors media acquisition&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;*/10&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="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;media-watchdog&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;python:3.12-slim&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;python&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/scripts/media-watchdog.py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ol&gt;
&lt;li&gt;Queries Sonarr/Radarr APIs for stuck queue items (downloads stuck for &amp;gt;30 minutes)&lt;/li&gt;
&lt;li&gt;Clears stuck items and re-triggers the download&lt;/li&gt;
&lt;li&gt;Checks Jellyfin's library scan status&lt;/li&gt;
&lt;li&gt;Posts status to Discord via webhook&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without this CronJob, stuck downloads sit indefinitely. Sonarr and Radarr don't have built-in queue monitoring — they trust the download client to report status, and SABnzbd sometimes silently fails without notifying the *arr stack.&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 a proper service mesh for cross-boundary traffic.&lt;/strong&gt; The manual Endpoints pattern works but is fragile — if the LXC IP changes, the Endpoints must be updated manually. A DNS-based service discovery (Headscale DNS entries, for example) would be more resilient.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Move the CronJob to a native LXC cron.&lt;/strong&gt; The Python script runs in a k3s Pod but talks to LXC-hosted services. It has no business being in the cluster. A systemd timer on the media acquisition LXC would be simpler.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;The media stack's departure from Kubernetes is the same pattern as running stateful workloads outside AKS: GPU workloads go to dedicated VMs with GPU passthrough, traffic-sensitive workloads get network-level isolation instead of a sidecar, and file-locking workloads go to local SSDs. Kubernetes excels at stateless, horizontally-scalable workloads. Media transcoding and acquisition are neither — they're stateful, single-instance, and hardware-dependent. The right platform for them is the bare metal underneath, not the orchestration layer on top.&lt;/p&gt;



</description>
      <category>homelab</category>
      <category>proxmox</category>
      <category>networking</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Private AKS on Azure: No Public API Server, No Public Node IPs, No Default Outbound</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 07 Sep 2026 19:17:12 +0000</pubDate>
      <link>https://dev.to/dwoitzik/private-aks-on-azure-no-public-api-server-no-public-node-ips-no-default-outbound-5ga3</link>
      <guid>https://dev.to/dwoitzik/private-aks-on-azure-no-public-api-server-no-public-node-ips-no-default-outbound-5ga3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/azure-private-aks-zero-trust/" 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;Run &lt;code&gt;az aks create&lt;/code&gt; with the defaults and you get a public API server FQDN, a Standard Load Balancer giving every node its own path to the internet, and — unless you go out of your way to stop it — an admin kubeconfig that works from anywhere with the right token. None of that is a bug. It's the fast path, and for a demo cluster it's fine.&lt;/p&gt;

&lt;p&gt;For a cluster inside a governed Hub &amp;amp; Spoke, it's three separate audit findings. This is the Terraform to close all three at once: a private API server, forced-tunneled egress through a firewall you already control, and private-endpoint-only ACR and Key Vault.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/dwoitzik/terraform-azurerm-private-aks" rel="noopener noreferrer"&gt;Get the base template free on GitHub 🐙&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Target Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         ┌─────────────────────┐
                         │   Azure Firewall     │  ← you supply the IP
                         │  (existing / NVA)     │
                         └──────────┬───────────┘
                                    │ UDR: 0.0.0.0/0
                         ┌──────────┴───────────┐
                         │   vnet-aks            │
                         │                       │
              ┌──────────┴──────────┐  ┌─────────┴──────────┐
              │  snet-aks-nodes     │  │ snet-private-       │
              │  (default-deny NSG) │  │ endpoints            │
              │                     │  │  - ACR (Premium)     │
              │  Private AKS        │  │  - Key Vault         │
              │  (no public FQDN)   │  │                      │
              └─────────────────────┘  └─────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three engineering decisions drive this, same as the &lt;a href="https://dev.to/blog/azure-terraform-hub-spoke-zero-trust"&gt;Hub &amp;amp; Spoke module&lt;/a&gt; this one is designed to sit behind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No public control-plane endpoint&lt;/strong&gt; — reachable only from inside the VNet, a peered network, or a VPN&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forced tunneling, not default outbound&lt;/strong&gt; — every node packet leaves through a firewall you control&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private-endpoint-only dependencies&lt;/strong&gt; — ACR and Key Vault never touch the public internet either&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: The API Server Has No Public FQDN
&lt;/h2&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_kubernetes_cluster"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;private_cluster_enabled&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;private_dns_zone_id&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;aks&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_cluster_public_fqdn_enabled&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;network_profile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;network_plugin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azure"&lt;/span&gt;
    &lt;span class="nx"&gt;network_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"azure"&lt;/span&gt;
    &lt;span class="nx"&gt;outbound_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"userDefinedRouting"&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;private_cluster_public_fqdn_enabled = false&lt;/code&gt; is the setting people miss. &lt;code&gt;private_cluster_enabled = true&lt;/code&gt; alone still publishes a public FQDN that resolves to nothing reachable — a fingerprintable artifact for no benefit. Turning it off means &lt;code&gt;az aks show&lt;/code&gt; doesn't leak a hostname at all.&lt;/p&gt;

&lt;p&gt;The cluster needs its own Private DNS Zone for the API server (&lt;code&gt;privatelink.&amp;lt;region&amp;gt;.azmk8s.io&lt;/code&gt;), linked to the VNet — same DINE-policy-safe &lt;code&gt;lifecycle.ignore_changes&lt;/code&gt; pattern as the &lt;a href="https://dev.to/blog/azure-terraform-hub-spoke-zero-trust"&gt;Hub &amp;amp; Spoke module's&lt;/a&gt; centralized zones, because Azure Policy tagging fights this resource too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: &lt;code&gt;userDefinedRouting&lt;/code&gt; — the Setting That Actually Matters
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;network_plugin = "azure"&lt;/code&gt; and &lt;code&gt;network_policy = "azure"&lt;/code&gt; get most of the attention in AKS networking guides. The setting that actually determines whether traffic is inspectable is &lt;code&gt;outbound_type&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Left at its default (&lt;code&gt;loadBalancer&lt;/code&gt;), AKS provisions its own Standard Load Balancer outbound rule and every node gets a direct, un-inspected path to the internet — a private API server with fully public node egress is a common half-measure that looks locked down in the portal and isn't.&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_route_table"&lt;/span&gt; &lt;span class="s2"&gt;"aks_egress"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;route&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;"default-via-firewall"&lt;/span&gt;
    &lt;span class="nx"&gt;address_prefix&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;
    &lt;span class="nx"&gt;next_hop_type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"VirtualAppliance"&lt;/span&gt;
    &lt;span class="nx"&gt;next_hop_in_ip_address&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;firewall_private_ip&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_subnet_route_table_association"&lt;/span&gt; &lt;span class="s2"&gt;"aks_nodes"&lt;/span&gt; &lt;span class="p"&gt;{&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;aks_nodes&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;route_table_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_route_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aks_egress&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;&lt;code&gt;outbound_type = "userDefinedRouting"&lt;/code&gt; tells AKS to trust this route table instead of provisioning its own path. The Route Table has to exist and be associated to the node subnet &lt;em&gt;before&lt;/em&gt; the cluster is created — &lt;code&gt;depends_on&lt;/code&gt; enforces the ordering, because AKS validates the UDR is actually in place at cluster-creation time and fails otherwise.&lt;/p&gt;



&lt;h2&gt;
  
  
  Step 3: ACR and Key Vault Without a Public Endpoint
&lt;/h2&gt;

&lt;p&gt;Nodes still need to pull images and (often) read secrets. The naive fix — &lt;code&gt;admin_enabled = true&lt;/code&gt; on the registry, a shared pull secret in a Kubernetes Secret — is a credential that outlives any single deployment and shows up in &lt;code&gt;kubectl get secret -o yaml&lt;/code&gt; for anyone with namespace read access.&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_container_registry"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;sku&lt;/span&gt;                            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Premium"&lt;/span&gt; &lt;span class="c1"&gt;# required for Private Link&lt;/span&gt;
  &lt;span class="nx"&gt;admin_enabled&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;public_network_access_enabled&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;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_role_assignment"&lt;/span&gt; &lt;span class="s2"&gt;"aks_acr_pull"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt;                            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_container_registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;role_definition_name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AcrPull"&lt;/span&gt;
  &lt;span class="nx"&gt;principal_id&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_kubernetes_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kubelet_identity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;object_id&lt;/span&gt;
  &lt;span class="nx"&gt;skip_service_principal_aad_check&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kubelet identity — the identity that actually pulls images, not the cluster's control-plane identity — gets &lt;code&gt;AcrPull&lt;/code&gt; directly. No credential to rotate, nothing in a Secret, nothing that outlives the node it's bound to. Key Vault follows the identical shape: RBAC-authorized, &lt;code&gt;public_network_access_enabled = false&lt;/code&gt;, kubelet identity gets &lt;code&gt;Key Vault Secrets User&lt;/code&gt;, ready for the CSI Secrets Store driver to mount without ever touching a static key.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Doesn't Do
&lt;/h2&gt;

&lt;p&gt;It's a starting cluster, not a finished platform. No Entra ID–integrated &lt;code&gt;kubectl&lt;/code&gt; auth (add &lt;code&gt;azure_active_directory_role_based_access_control&lt;/code&gt; yourself), no multi-pool topology, no cluster autoscaler wiring, &lt;code&gt;sku_tier&lt;/code&gt; defaults to &lt;code&gt;Free&lt;/code&gt; (no uptime SLA — set &lt;code&gt;Standard&lt;/code&gt; for production). And it assumes you already have a firewall to hand it a private IP — pair it with the &lt;a href="https://github.com/dwoitzik/terraform-azurerm-firewall-forced-tunneling" rel="noopener noreferrer"&gt;Azure Firewall Forced Tunneling module&lt;/a&gt; if you don't.&lt;/p&gt;

&lt;p&gt;For the operational reality of running production Kubernetes past the "it deployed" stage — the workflows and failure modes that don't show up in a getting-started guide — &lt;a href="https://dev.to/go/gitops-kubernetes-book"&gt;GitOps and Kubernetes*&lt;/a&gt; is the reference I've actually kept open while doing this.&lt;/p&gt;



</description>
      <category>azure</category>
      <category>terraform</category>
      <category>kubernetes</category>
      <category>zerotrust</category>
    </item>
    <item>
      <title>Staggered VM Boot: How I Prevented a Load Average of 147</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:29:57 +0000</pubDate>
      <link>https://dev.to/dwoitzik/staggered-vm-boot-how-i-prevented-a-load-average-of-147-18cd</link>
      <guid>https://dev.to/dwoitzik/staggered-vm-boot-how-i-prevented-a-load-average-of-147-18cd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/staggered-vm-boot-load-average-147/" 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;After a host reboot, every VM and LXC on the Proxmox host started simultaneously. Twelve containers and VMs, all booting at once, all hitting the same NVMe for root filesystem reads, service starts, and NFS mounts. The host load average hit 147.&lt;/p&gt;

&lt;p&gt;For context: load average represents the number of processes in the run queue or waiting for I/O. On a 16-thread CPU, a load average of 147 means 147 processes are competing for CPU or disk time. Every service was slow to start, k3s took minutes to become ready, and DNS didn't resolve for the first 90 seconds because the Raspberry Pi DNS nodes were waiting for services that hadn't booted yet.&lt;/p&gt;

&lt;p&gt;The fix wasn't more resources — it was boot order.&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 Boot Storm
&lt;/h2&gt;

&lt;p&gt;When the Proxmox host starts, all VMs and LXCs configured with &lt;code&gt;onboot=1&lt;/code&gt; start simultaneously. The host's NVMe handles root filesystem reads for every container, plus the ZFS txg commits from the NFS server, plus etcd writes from the k3s control-plane.&lt;/p&gt;

&lt;p&gt;The simultaneous startup creates a thundering herd: 12 processes all requesting I/O at the same time, the NVMe queue depth maxes out, I/O latency spikes, and services that depend on each other (k3s needs NFS, k3s apps need DNS, DNS needs k3s services) enter a cascading wait state.&lt;/p&gt;

&lt;p&gt;The load average doesn't just spike and recover — it compounds. Services that fail to start within their timeout window retry, adding more processes to the queue. k3s control-plane tries to mount NFS volumes, NFS is slow because it's competing with 11 other containers for I/O, k3s retries the mount, adding more load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Staggered Boot Order
&lt;/h2&gt;

&lt;p&gt;Proxmox supports &lt;code&gt;startup&lt;/code&gt; order with delays. The configuration in Terraform:&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="c1"&gt;# NFS first — k3s depends on it&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"ct_srv_nfs_01"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;startup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nx"&gt;up&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;  &lt;span class="c1"&gt;# wait 30s after boot before starting next&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# k3s control-plane — waits for NFS&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"vm_srv_k3s_11"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;startup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="nx"&gt;up&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;  &lt;span class="c1"&gt;# 30s after NFS is up&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# k3s workers — 30s apart from each other&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"vm_srv_k3s_12"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;startup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="nx"&gt;up&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&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;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"vm_srv_k3s_13"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;startup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="nx"&gt;up&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&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 order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;NFS&lt;/strong&gt; (order 1) — boots first, 30s head start. k3s PVCs mount from NFS, so NFS must be ready before k3s starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;k3s-11&lt;/strong&gt; (order 2) — control-plane + etcd. Boots 30s after NFS. Needs NFS for system PVCs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;k3s-12&lt;/strong&gt; (order 3) — worker. Boots 30s after control-plane. Needs API server ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;k3s-13&lt;/strong&gt; (order 4) — worker. Boots 30s after k3s-12.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LXCs&lt;/strong&gt; (order 5+) — everything else. Docker workloads, media stack, DMZ.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total boot sequence: ~3 minutes for everything to be online. Previously: all at once, 147 load average, 5+ minutes to stability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why NFS First
&lt;/h2&gt;

&lt;p&gt;NFS is the foundation of the storage layer. Every k3s PVC (Authelia, Vaultwarden, Paperless, Nextcloud) mounts from the NFS server at &lt;code&gt;10.0.20.100&lt;/code&gt;. If NFS isn't ready when k3s starts, the pod mount attempts fail, Kubernetes retries with exponential backoff, and the pods sit in &lt;code&gt;ContainerCreating&lt;/code&gt; for minutes.&lt;/p&gt;

&lt;p&gt;NFS itself depends on ZFS — the NFS export directory lives on the ZFS pool. ZFS needs a few seconds after boot to complete any pending txg commits and mount the pool. The 30-second head start gives ZFS and NFS time to stabilize before k3s starts hammering them with mount requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CPU Scheduling Priority
&lt;/h2&gt;

&lt;p&gt;Beyond boot order, k3s VMs get CPU scheduling priority via &lt;code&gt;cpu.units&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;# k3s VMs get 2x scheduling priority&lt;/span&gt;
&lt;span class="nx"&gt;cpu&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;  &lt;span class="c1"&gt;# default is 1024&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# LXCs stay at default priority&lt;/span&gt;
&lt;span class="nx"&gt;cpu&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;  &lt;span class="c1"&gt;# default&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cpu.units&lt;/code&gt; tells the Proxmox scheduler how to weight CPU time when multiple VMs compete for the same physical cores. &lt;code&gt;units=2048&lt;/code&gt; means k3s VMs get twice the CPU scheduling priority over LXCs. When Ollama is running LLM inference on the AI LXC and k3s needs CPU for etcd fdatasync, etcd wins.&lt;/p&gt;

&lt;p&gt;This matters during boot too: even with staggered starts, there's overlap between late-booting LXCs and already-running k3s workloads. The CPU priority ensures k3s gets scheduling preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;onboot&lt;/code&gt; Gotcha
&lt;/h2&gt;

&lt;p&gt;Proxmox's &lt;code&gt;bpg/proxmox&lt;/code&gt; Terraform provider doesn't reliably manage the &lt;code&gt;onboot&lt;/code&gt; attribute. &lt;code&gt;terraform plan&lt;/code&gt; always shows "No changes" regardless of the live value — a known limitation of the provider.&lt;/p&gt;

&lt;p&gt;This means &lt;code&gt;onboot&lt;/code&gt; must be set manually after any LXC recreate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pct &lt;span class="nb"&gt;set&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;-onboot&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I discovered this the hard way: after recreating a container via Terraform, &lt;code&gt;onboot&lt;/code&gt; defaulted to &lt;code&gt;0&lt;/code&gt;. The next host reboot silently skipped that container. The k3s control-plane node came up without its NFS mount, and half the cluster was in CrashLoopBackOff until I noticed.&lt;/p&gt;

&lt;p&gt;The workaround: a manual &lt;code&gt;pct set&lt;/code&gt; step in the operations runbook, applied after every LXC creation. Not ideal, but documented and repeatable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before vs. After
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before (simultaneous)&lt;/th&gt;
&lt;th&gt;After (staggered)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peak load average&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to k3s ready&lt;/td&gt;
&lt;td&gt;5+ minutes&lt;/td&gt;
&lt;td&gt;90 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to DNS functional&lt;/td&gt;
&lt;td&gt;90 seconds&lt;/td&gt;
&lt;td&gt;30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I/O wait %&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed mount attempts&lt;/td&gt;
&lt;td&gt;12-15&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The staggering didn't add total boot time — it redistributed the I/O load over 3 minutes instead of concentrating it in 30 seconds. Services come up later individually but the cluster as a whole is stable sooner because nothing is fighting for I/O. The same host also runs &lt;a href="https://dev.to/blog/overcommit-guard-python-host-freeze/"&gt;a memory overcommit guard&lt;/a&gt; for the RAM side of this problem — boot order fixes the I/O storm, the guard fixes the memory one.&lt;/p&gt;




&lt;p&gt;Boot storm mitigation is the same problem in Azure: when you scale out a VMSS from 0 to 50 instances, all 50 hit the Azure fabric simultaneously. Azure handles this with staggered placement and shared disks, but the principle is identical — spread the I/O load over time instead of concentrating it. In a homelab, you do it yourself with &lt;code&gt;startup.order&lt;/code&gt; and &lt;code&gt;startup.up&lt;/code&gt; delays.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/go/4aUtkCs"&gt;Designing Data-Intensive Applications*&lt;/a&gt; has a genuinely useful framing for this kind of thundering-herd problem, even though it's written about databases rather than hypervisors - the queueing math behind "everything wants the same resource at the same instant" doesn't care what the resource actually is.&lt;/p&gt;



</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Overcommit Guard: How a Python Script Prevents Host Freezes</title>
      <dc:creator>david</dc:creator>
      <pubDate>Thu, 03 Sep 2026 19:30:32 +0000</pubDate>
      <link>https://dev.to/dwoitzik/the-overcommit-guard-how-a-python-script-prevents-host-freezes-48h0</link>
      <guid>https://dev.to/dwoitzik/the-overcommit-guard-how-a-python-script-prevents-host-freezes-48h0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/overcommit-guard-python-host-freeze/" 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;My Proxmox host froze three times in one week. The root cause was RAM pressure pushing ZFS into an I/O stall on a single shared NVMe. After fixing the freeze (ZFS tuning, cache=none+aio=native), I needed a way to prevent it from ever happening again — a CI guard that catches memory overcommit before it hits the host. It's the same host where &lt;a href="https://dev.to/blog/staggered-vm-boot-load-average-147/"&gt;uncontrolled boot storms once pushed load average to 147&lt;/a&gt;; memory pressure and I/O pressure are two versions of the same single-host bottleneck.&lt;/p&gt;

&lt;p&gt;The first version of the guard was wrong. It summed VM and LXC memory allocations together against one ceiling, and flagged 85 GB as "allocated" on a host with 62 GB of physical RAM. A live check showed only 41 GB actually in use — 21 GB available. The guard was overstating pressure by 40 GB because it treated two different memory physics as the same thing.&lt;/p&gt;

&lt;p&gt;The fix was a two-tier model that understands the difference between a VM reservation and an LXC ceiling.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The Two Memory Physics
&lt;/h2&gt;

&lt;h3&gt;
  
  
  VM &lt;code&gt;dedicated&lt;/code&gt; — Real Reservation
&lt;/h3&gt;

&lt;p&gt;When you set &lt;code&gt;dedicated = 12288&lt;/code&gt; (12 GB) on a Proxmox VM, QEMU pre-allocates that memory as a host process. The moment the VM starts, 12 GB of physical RAM is gone — reserved for the QEMU process, not available for anything else. The host's &lt;code&gt;free -h&lt;/code&gt; reflects this immediately.&lt;/p&gt;

&lt;p&gt;This is a hard reservation. If the sum of all VM &lt;code&gt;dedicated&lt;/code&gt; values plus ZFS ARC plus host overhead exceeds physical RAM, the host is overcommitted. The kernel will start swapping, ZFS will stall waiting for I/O, and the freeze pattern repeats.&lt;/p&gt;

&lt;h3&gt;
  
  
  LXC &lt;code&gt;dedicated&lt;/code&gt; — Soft Ceiling
&lt;/h3&gt;

&lt;p&gt;When you set &lt;code&gt;dedicated = 4096&lt;/code&gt; (4 GB) on a Proxmox LXC, you're setting &lt;code&gt;memory.max&lt;/code&gt; in the cgroup. This is a ceiling the kernel enforces &lt;em&gt;only if the container actually tries to use that much&lt;/em&gt;. It reserves nothing on the host up front.&lt;/p&gt;

&lt;p&gt;A container with &lt;code&gt;dedicated = 4096&lt;/code&gt; might be using 800 MB. The host sees 800 MB, not 4 GB. The remaining 3.2 GB is available for other workloads. This is why the original guard was wrong: summing LXC &lt;code&gt;dedicated&lt;/code&gt; values counts memory that isn't actually consumed.&lt;/p&gt;

&lt;p&gt;A live check on the host confirmed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check actual LXC memory usage vs configured limits&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;ct &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;pct list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1 {print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pct config &lt;span class="nv"&gt;$ct&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"dedicated"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;actual&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pct &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nv"&gt;$ct&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/memory.current 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CT &lt;/span&gt;&lt;span class="nv"&gt;$ct&lt;/span&gt;&lt;span class="s2"&gt;: limit=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;MB actual=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;actual/1024/1024&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;MB"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="c"&gt;# → Most CTs using 20-40% of their configured limit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Two-Tier Guard
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hard Gate (CI-Fail)
&lt;/h3&gt;

&lt;p&gt;The hard gate catches real overcommit risk. It sums only VM &lt;code&gt;dedicated&lt;/code&gt; values (real reservations) plus ZFS ARC max plus a fixed host reserve:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Hard gate: VM reservations + ARC + host reserve
&lt;/span&gt;&lt;span class="n"&gt;ZFS_ARC_MAX_GB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;       &lt;span class="c1"&gt;# /sys/module/zfs/parameters/zfs_arc_max
&lt;/span&gt;&lt;span class="n"&gt;HOST_RESERVE_GB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;       &lt;span class="c1"&gt;# kernel + QEMU overhead
&lt;/span&gt;&lt;span class="n"&gt;HARD_GATE_CEILING_GB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt; &lt;span class="c1"&gt;# safe ceiling for 62 GB physical
&lt;/span&gt;
&lt;span class="n"&gt;hard_gate_mb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vm_dedicated_total&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZFS_ARC_MAX_GB&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HOST_RESERVE_GB&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the hard gate exceeds 44 GB, the CI build fails. The PR cannot be merged. This is intentional: a VM &lt;code&gt;dedicated&lt;/code&gt; change that pushes past the ceiling is exactly the kind of change that caused the original freeze.&lt;/p&gt;

&lt;p&gt;The ceiling (44 GB on a 62 GB host) leaves 18 GB of headroom for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LXC actual usage (typically 8-12 GB across all containers)&lt;/li&gt;
&lt;li&gt;Kernel and system overhead not captured in the reserve&lt;/li&gt;
&lt;li&gt;Burst spikes from Ollama inference or Paperless OCR&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Soft Check (Warn-Only)
&lt;/h3&gt;

&lt;p&gt;The soft check sums LXC &lt;code&gt;dedicated&lt;/code&gt; values and compares against physical RAM. If it exceeds 62 GB, a warning is printed — but the build does &lt;strong&gt;not&lt;/strong&gt; fail. LXC ceilings are soft; summing them overstates real pressure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Soft check: LXC CT limits vs physical RAM (visibility only)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lxc_dedicated_total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PHYSICAL_RAM_GB&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WARN: LXC CT limits sum to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lxc_dedicated_total&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; GB, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;over physical RAM (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;PHYSICAL_RAM_GB&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; GB). &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Check actual usage: pct exec &amp;lt;id&amp;gt; -- cat /sys/fs/cgroup/memory.current&lt;/span&gt;&lt;span class="sh"&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 soft check exists for visibility. If someone adds a new LXC with 32 GB &lt;code&gt;dedicated&lt;/code&gt; and the sum crosses 100 GB, the warning fires — but it doesn't block the merge, because the actual usage is probably a fraction of the configured limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;floating&lt;/code&gt; Is Ignored
&lt;/h2&gt;

&lt;p&gt;VMs can have both &lt;code&gt;dedicated&lt;/code&gt; (ceiling) and &lt;code&gt;floating&lt;/code&gt; (balloon-driven floor). Under host memory pressure, Proxmox can deflate the balloon down to the &lt;code&gt;floating&lt;/code&gt; minimum, freeing memory for other workloads.&lt;/p&gt;

&lt;p&gt;The guard conservatively uses &lt;code&gt;dedicated&lt;/code&gt;, not &lt;code&gt;floating&lt;/code&gt;, because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The balloon only deflates &lt;em&gt;after&lt;/em&gt; pressure is detected — it doesn't prevent the pressure&lt;/li&gt;
&lt;li&gt;A sudden allocation spike (Ollama loading a 26B model) can't wait for the balloon to deflate&lt;/li&gt;
&lt;li&gt;The guard exists to catch &lt;em&gt;pending&lt;/em&gt; risk, not to model &lt;em&gt;steady-state&lt;/em&gt; usage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;floating&lt;/code&gt; values are parsed and printed for visibility, never counted in the hard gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI Integration
&lt;/h2&gt;

&lt;p&gt;The script runs in pre-commit hooks and GitHub Actions CI:&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;# .github/workflows/ci.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;Memory overcommit guard&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python scripts/check-host-memory-overcommit.py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Terraform change that adds a new VM or increases a VM's &lt;code&gt;dedicated&lt;/code&gt; value is checked against the ceiling before merge. If it pushes past 44 GB, the PR is blocked with a clear error message explaining why.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;python scripts/check-host-memory-overcommit.py
REL-035 memory overcommit guard &lt;span class="o"&gt;(&lt;/span&gt;two-tier model&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nt"&gt;--&lt;/span&gt; Hard gate &lt;span class="o"&gt;(&lt;/span&gt;CI-fail&lt;span class="o"&gt;)&lt;/span&gt;: VM reservations + ARC + host reserve &lt;span class="nt"&gt;--&lt;/span&gt;
  VM &lt;span class="sb"&gt;`&lt;/span&gt;dedicated&lt;span class="sb"&gt;`&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;3 VMs&lt;span class="o"&gt;)&lt;/span&gt;: 36864 MB &lt;span class="o"&gt;(&lt;/span&gt;36.0 GB&lt;span class="o"&gt;)&lt;/span&gt;
  VM &lt;span class="sb"&gt;`&lt;/span&gt;floating&lt;span class="sb"&gt;`&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;  &lt;span class="o"&gt;(&lt;/span&gt;3 VMs&lt;span class="o"&gt;)&lt;/span&gt;: 40960 MB &lt;span class="o"&gt;(&lt;/span&gt;40.0 GB&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; informational only, NOT counted
  + ZFS ARC max: 4 GB
  + host/hypervisor reserve: 6 GB
  &lt;span class="o"&gt;=&lt;/span&gt; hard gate total: 47104 MB &lt;span class="o"&gt;(&lt;/span&gt;46.0 GB&lt;span class="o"&gt;)&lt;/span&gt;
  Ceiling: 44 GB

FAIL: hard gate total &lt;span class="o"&gt;(&lt;/span&gt;46.0 GB&lt;span class="o"&gt;)&lt;/span&gt; exceeds the 44 GB ceiling by 2.0 GB.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The guard exists because &lt;code&gt;mini&lt;/code&gt; — the single Proxmox host running this entire homelab — has no failover. A host freeze means every service is down simultaneously: k3s, databases, DNS, monitoring, backups. The only recovery is a hard power cycle.&lt;/p&gt;

&lt;p&gt;REL-016 (the ZFS freeze) happened because RAM pressure pushed ZFS into a stall-wait state on the shared NVMe. The hard gate catches the most common path to that state: a VM &lt;code&gt;dedicated&lt;/code&gt; change that leaves insufficient headroom for the kernel, ZFS, and LXC workloads.&lt;/p&gt;

&lt;p&gt;It doesn't prevent every possible freeze — a runaway process inside a VM can still consume all its allocated memory and cause host pressure. But it prevents the &lt;em&gt;planned&lt;/em&gt; overcommit: the Terraform change that accidentally pushes past the ceiling because someone added a new VM without checking the math. CPU has the same two-tier problem; see &lt;a href="https://dev.to/blog/cpu-scheduling-etcd-proxmox-units/"&gt;how &lt;code&gt;cpu.units&lt;/code&gt; scheduling priority solved it for etcd&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Memory overcommit modeling is the same problem in Azure: Reserved VM instances guarantee physical memory allocation, while Burstable VMs share host memory and can be throttled. Mixing both in the same Availability Set without understanding the difference produces the same false-sense-of-security that my original guard had. The fix is the same: separate hard reservations from soft ceilings, and never sum them together.&lt;/p&gt;



</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>debugging</category>
      <category>cicd</category>
    </item>
    <item>
      <title>GitOps for Firewall Rules: MikroTik + Terraform + Atlantis</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:31:54 +0000</pubDate>
      <link>https://dev.to/dwoitzik/gitops-for-firewall-rules-mikrotik-terraform-atlantis-1i9l</link>
      <guid>https://dev.to/dwoitzik/gitops-for-firewall-rules-mikrotik-terraform-atlantis-1i9l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/mikrotik-terraform-gitops-firewall-management/" 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;My MikroTik RB5009 has no manual RouterOS configuration. Every firewall rule, every VLAN interface, every DHCP static lease, every NAT port-forward — all of it lives in Terraform, reviewed via Atlantis pull requests, and applied through the same GitOps workflow that manages the Kubernetes cluster. This is the delivery pipeline underneath &lt;a href="https://dev.to/blog/mikrotik-zero-trust-firewall-terraform/"&gt;the zero-trust default-deny firewall policy&lt;/a&gt; and &lt;a href="https://dev.to/blog/mikrotik-vlan-filtering-terraform-proxmox/"&gt;the VLAN matrix&lt;/a&gt; — this article covers the Atlantis plumbing, not the rules themselves.&lt;/p&gt;

&lt;p&gt;This article covers the full implementation: the &lt;code&gt;locals&lt;/code&gt; block that drives the VLAN matrix, the &lt;code&gt;place_before&lt;/code&gt; pattern for deterministic firewall ordering, and how a router's LED schedule ended up in Terraform.&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 Provider Setup
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;routeros&lt;/code&gt; provider (v1.80+) connects to the MikroTik via the REST API:&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/network/providers.tf&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.5"&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;routeros&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-routeros/routeros"&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;"~&amp;gt; 1.80"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"network/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;endpoints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://s3.woitzik.dev"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;skip_credentials_validation&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;skip_metadata_api_check&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;skip_requesting_account_id&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The S3 backend is Garage, the self-hosted S3-compatible storage running inside k3s. The Terraform state for the network stack lives on the same cluster that depends on the network — another circular dependency that's acceptable because a full cluster failure means the network is the least of your problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The VLAN Matrix
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;locals&lt;/code&gt; block is the single source of truth for the entire network:&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/network/main.tf&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;homelab_vlans&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan20-srv"&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan30-dmz"&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan40-iot"&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan100-admin"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;rpi_port_mapping&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"ether6"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;  &lt;span class="c1"&gt;# RPi 4B #1 (Keepalived Node A)&lt;/span&gt;
    &lt;span class="s2"&gt;"ether7"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;  &lt;span class="c1"&gt;# RPi 4B #2 (Keepalived Node B)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;proxmox_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ether5"&lt;/span&gt;

  &lt;span class="c1"&gt;# Port mapping for all VLAN-tagged trunks&lt;/span&gt;
  &lt;span class="nx"&gt;port_mapping&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"ether1"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# WAN (FritzBox)&lt;/span&gt;
    &lt;span class="s2"&gt;"ether2"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;  &lt;span class="c1"&gt;# k3s-11&lt;/span&gt;
    &lt;span class="s2"&gt;"ether3"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;  &lt;span class="c1"&gt;# k3s-12&lt;/span&gt;
    &lt;span class="s2"&gt;"ether4"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;  &lt;span class="c1"&gt;# k3s-13&lt;/span&gt;
    &lt;span class="s2"&gt;"ether5"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;  &lt;span class="c1"&gt;# Proxmox host&lt;/span&gt;
    &lt;span class="s2"&gt;"ether6"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;  &lt;span class="c1"&gt;# RPi #1&lt;/span&gt;
    &lt;span class="s2"&gt;"ether7"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;  &lt;span class="c1"&gt;# RPi #2&lt;/span&gt;
    &lt;span class="s2"&gt;"sfp-sfpplus1"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;  &lt;span class="c1"&gt;# DMZ switch&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 new VLAN means one entry in &lt;code&gt;homelab_vlans&lt;/code&gt;. Terraform auto-generates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bridge VLAN entries&lt;/li&gt;
&lt;li&gt;DHCP server per VLAN&lt;/li&gt;
&lt;li&gt;Firewall rules per VLAN&lt;/li&gt;
&lt;li&gt;VLAN interface on the bridge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Moving a device between VLANs means changing one number in &lt;code&gt;port_mapping&lt;/code&gt;. The firewall rules, DHCP leases, and bridge entries update automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic Firewall Ordering
&lt;/h2&gt;

&lt;p&gt;MikroTik evaluates firewall rules in order and stops at the first match. Terraform manages rule ordering via &lt;code&gt;place_before&lt;/code&gt; references:&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/network/firewall_deterministic.tf&lt;/span&gt;

&lt;span class="c1"&gt;# Final drop rule — must be last in the forward chain&lt;/span&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;place_before&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;  &lt;span class="c1"&gt;# explicit: this is the last rule&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;span class="c1"&gt;# Anti-spoofing — must come before any accept rules&lt;/span&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_00b_anti_spoof"&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;src_address&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.0/8"&lt;/span&gt;
  &lt;span class="nx"&gt;in_interface_list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"WAN"&lt;/span&gt;
  &lt;span class="nx"&gt;place_before&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;routeros_ip_firewall_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fwd_01_established&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;comment&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"00b: Anti-Spoof - Drop internal src from WAN"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Established/related — allows return traffic&lt;/span&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_01_established"&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;"accept"&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;connection_state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"established"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"related"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;place_before&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;routeros_ip_firewall_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fwd_99_drop_all&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;comment&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"01: Allow Established/Related"&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;place_before&lt;/code&gt; attribute creates explicit ordering dependencies. Rule 00b must come before rule 01, which must come before rule 99. Terraform resolves these dependencies during plan, so the apply order matches the intended evaluation order.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;place_before&lt;/code&gt;, Terraform would apply rules in dependency-graph order, which is not guaranteed to match the intended firewall evaluation order. A rule that should be evaluated first might be created last, allowing a broader rule to match traffic before the narrower, more specific rule is evaluated.&lt;/p&gt;

&lt;p&gt;The full ruleset in &lt;code&gt;firewall_deterministic.tf&lt;/code&gt; has 22 rules with explicit ordering. The &lt;code&gt;firewall_extra.tf&lt;/code&gt; file has 19 additional rules for VPN access tiers, monitoring, and application-specific port forwards — all with &lt;code&gt;place_before&lt;/code&gt; references to maintain correct evaluation order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Atlantis Flow
&lt;/h2&gt;

&lt;p&gt;Every change to the network stack goes through Atlantis:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Edit &lt;code&gt;terraform/stacks/network/*.tf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Push to a feature branch&lt;/li&gt;
&lt;li&gt;Open a PR against &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Atlantis comments with the &lt;code&gt;terraform plan&lt;/code&gt; output&lt;/li&gt;
&lt;li&gt;Review the plan&lt;/li&gt;
&lt;li&gt;Comment &lt;code&gt;atlantis apply&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Atlantis applies the changes to the MikroTik&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;atlantis.yaml&lt;/code&gt; at the repo root defines the project:&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;network&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="na"&gt;autoplan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;when_modified&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;*.tf"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.tfvars"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan output shows exactly what will change:&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;# routeros_ip_firewall_filter.fwd_04a_monitoring will be updated in-place&lt;/span&gt;
&lt;span class="err"&gt;~&lt;/span&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_04a_monitoring"&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;"accept"&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="err"&gt;~&lt;/span&gt;   &lt;span class="nx"&gt;dst_port&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"9100"&lt;/span&gt; &lt;span class="nx"&gt;-&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"9100,9090"&lt;/span&gt;
      &lt;span class="nx"&gt;src_address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.20.0/24"&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;"04a: SRV - Prometheus scrape to MGMT"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No surprise changes. Every rule modification is visible in the PR before it touches the live router.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Goes in Terraform vs. What Doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In Terraform:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All firewall filter rules (input + forward chains)&lt;/li&gt;
&lt;li&gt;VLAN interfaces and bridge matrix&lt;/li&gt;
&lt;li&gt;DHCP static leases&lt;/li&gt;
&lt;li&gt;NAT port-forwards&lt;/li&gt;
&lt;li&gt;QoS traffic shaping&lt;/li&gt;
&lt;li&gt;SNMP community configuration&lt;/li&gt;
&lt;li&gt;Power LED scheduling (yes, really)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Not in Terraform:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RouterOS user management (done once, rarely changes)&lt;/li&gt;
&lt;li&gt;Certificate renewals (handled by the router's own scheduler)&lt;/li&gt;
&lt;li&gt;Custom scripts for monitoring (run via SNMP, not Terraform)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The principle: if it affects traffic flow or network behavior, it's in Terraform. If it's one-time configuration or the router's own maintenance, it stays in RouterOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LED Night-Mode
&lt;/h2&gt;

&lt;p&gt;The MikroTik RB5009 has a power LED that's bright enough to be annoying in a dark room. RouterOS supports LED scheduling via the system scheduler. This ended up in Terraform:&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_system_scheduler"&lt;/span&gt; &lt;span class="s2"&gt;"led_night_mode_off"&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;"led-off"&lt;/span&gt;
  &lt;span class="nx"&gt;start_time&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"23:00:00"&lt;/span&gt;
  &lt;span class="nx"&gt;interval&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"00:24:00"&lt;/span&gt;
  &lt;span class="nx"&gt;on_event&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/system led set led1 state=off"&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;"routeros_system_scheduler"&lt;/span&gt; &lt;span class="s2"&gt;"led_night_mode_on"&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;"led-on"&lt;/span&gt;
  &lt;span class="nx"&gt;start_time&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"07:00:00"&lt;/span&gt;
  &lt;span class="nx"&gt;interval&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"00:24:00"&lt;/span&gt;
  &lt;span class="nx"&gt;on_event&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/system led set led1 state=on"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two scheduler entries. LED off at 23:00, on at 07:00. Managed via Terraform, reviewed via PR, applied via Atlantis. It's the most trivial thing in the entire network stack, and it's also the change that made my partner the happiest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anti-Patterns
&lt;/h2&gt;

&lt;p&gt;Two things I deliberately avoid:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Never create resources directly via RouterOS API.&lt;/strong&gt; Every resource must be created through Terraform. If I need to debug a live issue and make a direct API change, I immediately add a &lt;code&gt;moved {}&lt;/code&gt; block or &lt;code&gt;import {}&lt;/code&gt; block to bring that resource under Terraform management. This prevents the "77 rules, Terraform knows about 22" problem I wrote about earlier.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Never &lt;code&gt;terraform apply&lt;/code&gt; locally.&lt;/strong&gt; All applies go through Atlantis PRs. This creates an audit trail, forces me to review every change, and prevents accidental modifications during debugging sessions.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Firewall-as-code via Terraform is the same pattern in Azure: NSG rules, Azure Firewall Policy rule collections, and Application Security Groups are all Terraform resources. The difference is that Azure's NSG API handles rule ordering automatically based on priority numbers, while MikroTik requires explicit &lt;code&gt;place_before&lt;/code&gt; dependencies. Both approaches achieve the same goal: firewall rules that are reviewed, version-controlled, and auditable.&lt;/p&gt;



</description>
      <category>terraform</category>
      <category>mikrotik</category>
      <category>gitops</category>
      <category>networking</category>
    </item>
    <item>
      <title>When Your LLM Hallucinated Your OCR</title>
      <dc:creator>david</dc:creator>
      <pubDate>Thu, 27 Aug 2026 15:05:30 +0000</pubDate>
      <link>https://dev.to/dwoitzik/when-your-llm-hallucinated-your-ocr-2hp5</link>
      <guid>https://dev.to/dwoitzik/when-your-llm-hallucinated-your-ocr-2hp5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/llm-hallucinated-ocr-paperless/" 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;I discovered that paperless-gpt was generating plausible-looking but entirely fabricated German OCR text for scanned documents. The model returned confident, grammatically correct German sentences for pages that were actually invoices, receipts, and handwritten notes. The titles it assigned to these documents were similarly fictional.&lt;/p&gt;

&lt;p&gt;The root cause wasn't a bug in paperless-gpt. It was a configuration error that sent page images to a text-only model that couldn't see images at all — and the model did exactly what language models do when given input they can't process: it made something up.&lt;/p&gt;

&lt;p&gt;The second root cause was underneath: the Ollama iGPU backend that was supposed to accelerate inference was crashing 451 times per day from an unstable Vulkan/radv driver fallback.&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 Configuration Error
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;paperless-gpt&lt;/code&gt; deployment had two model settings:&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/paperless/paperless-gpt.yml&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;LLM_MODEL&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;qwen2.5:7b-instruct"&lt;/span&gt;        &lt;span class="c1"&gt;# text-only model for tagging/title generation&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;VISION_LLM_MODEL&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;qwen2.5-coder:7b"&lt;/span&gt;           &lt;span class="c1"&gt;# ← BUG: this is also text-only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VISION_LLM_MODEL&lt;/code&gt; should point at a model that can process images. &lt;code&gt;qwen2.5-coder:7b&lt;/code&gt; is a text-only code completion model — it has no vision capability, no image encoder, no way to process pixel data.&lt;/p&gt;

&lt;p&gt;paperless-gpt's OCR provider sends page images directly to the model specified by &lt;code&gt;VISION_LLM_MODEL&lt;/code&gt;. There's no fallback mode, no capability check, no error when the model can't process images. The model receives the image (encoded as base64 in the prompt), can't interpret it, and returns its best guess based on context clues in the prompt text.&lt;/p&gt;

&lt;p&gt;For a scanned German invoice, the model would generate something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rechnung Nr. 2024-0847
Betrag: €1.247,83
Firma: Mustermann GmbH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confident. Grammatically correct. Entirely fictional. The real document might be a parking ticket, a utility bill, or a handwritten note — the model couldn't tell because it couldn't see the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Many Documents Were Affected
&lt;/h2&gt;

&lt;p&gt;The pipeline processed documents for several weeks with this misconfiguration. Every image-based document — scanned PDFs, photographed receipts, screenshots — received fabricated OCR content. Text-based documents (digital PDFs with selectable text) were unaffected because paperless-gpt uses the embedded text layer, not the vision model, for those.&lt;/p&gt;

&lt;p&gt;The affected documents had titles like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rechnung Nr. 2024-1847 — Firma Schmidt &amp;amp; Partner
GEPRÜFT — Handelsregister HRB 12345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The titles were completely made up, but they looked real enough that I didn't notice until I searched for a specific invoice and found a document titled with a company name I didn't recognize. Checking the actual scanned image confirmed: the document was a utility bill from a different provider entirely.&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/apps/paperless/paperless-gpt.yml&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;LLM_MODEL&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;qwen2.5:7b-instruct"&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;VISION_LLM_MODEL&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;minicpm-v"&lt;/span&gt;                   &lt;span class="c1"&gt;# actual vision-capable model&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;minicpm-v&lt;/code&gt; is a small (4B parameter) vision-language model that can process images and return text. It's not as capable as larger models, but it can actually see the page content and generate accurate OCR text.&lt;/p&gt;

&lt;p&gt;After switching, previously affected documents needed re-OCR. paperless-gpt doesn't re-process documents automatically — the fabricated content was already committed to the Paperless database. Manual re-processing was required for each affected document.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ollama iGPU Problem
&lt;/h2&gt;

&lt;p&gt;The configuration error was discoverable earlier if the Ollama backend had been stable. But the Ollama instance was running on the AI LXC with the AMD Barcelo iGPU's Vulkan/radv fallback, and it was crashing constantly.&lt;/p&gt;

&lt;p&gt;The AMD Ryzen 7 5825U's integrated GPU (gfx90c) has no official ROCm support. The community workaround uses &lt;code&gt;HSA_OVERRIDE_GFX_VERSION=9.0.0&lt;/code&gt; to spoof a supported GPU, combined with the Vulkan/radv driver for compute. This configuration was never stable on this chip.&lt;/p&gt;

&lt;p&gt;The crash pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vk::DeviceLostError: the GPU has been lost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;451 crashes in a single day. Each crash terminates the Ollama inference process, which kills any in-flight LLM request. paperless-gpt, Open WebUI, and any other service using Ollama would see connection refused errors.&lt;/p&gt;

&lt;p&gt;The iGPU crashes were the &lt;em&gt;actual&lt;/em&gt; cause of paperless-gpt failures that I initially attributed to paperless-gpt itself. The vision model was working correctly — it was Ollama crashing before the model could finish processing.&lt;/p&gt;

&lt;p&gt;The fix: switch Ollama to CPU-only mode and remove the &lt;code&gt;OLLAMA_IGPU_ENABLE=1&lt;/code&gt; flag entirely.&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 role for Ollama&lt;/span&gt;
&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;OLLAMA_HOST&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.0.0.0:11434"&lt;/span&gt;
  &lt;span class="c1"&gt;# OLLAMA_IGPU_ENABLE removed — CPU-only for stability&lt;/span&gt;
  &lt;span class="c1"&gt;# HSA_OVERRIDE_GFX_VERSION removed — was never stable on gfx90c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CPU inference is slower (qwen2.5:7b takes ~30 seconds per inference vs ~8 seconds on the iGPU), but it doesn't crash. Stability over speed for a homelab document processing pipeline — the same trade-off that governs &lt;a href="https://dev.to/blog/operating-model-auto-update-human/"&gt;what gets to auto-update vs. what needs a human&lt;/a&gt; on this cluster: a slower, boring path beats a fast, unstable one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lesson: Two Failure Modes Stacked
&lt;/h2&gt;

&lt;p&gt;The incident had two independent root causes that interacted to make debugging harder:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vision model pointing at text-only model&lt;/strong&gt; — fabricated OCR content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama iGPU crashing 451x/day&lt;/strong&gt; — intermittent connection failures&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If only (1) existed, I'd have noticed the fabricated content immediately. If only (2) existed, I'd have blamed Ollama instability. Both together meant that paperless-gpt failures looked like Ollama crashes (intermittent, random) rather than a systematic configuration error (consistent, every image document).&lt;/p&gt;

&lt;p&gt;The debugging lesson: when a system fails in two different ways (sometimes fabricated content, sometimes connection refused), check for two separate root causes rather than assuming one explains both — the same discipline that mattered when &lt;a href="https://dev.to/blog/gitops-merged-not-applied-argocd-drift/"&gt;three merged GitOps fixes turned out to have three unrelated failure modes&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;AI model misconfiguration in production has the same pattern: a model that can't process the input type it's given will produce plausible-looking but wrong output, and there's no automatic error. Azure OpenAI's vision models require explicit multimodal input formatting — sending a raw image to a text-only deployment doesn't error, it just ignores the image and responds to any text in the prompt. The fix is the same: verify that your model's capabilities match your input types, and test with known inputs before trusting the output.&lt;/p&gt;



</description>
      <category>ai</category>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <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>
  </channel>
</rss>
