<?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>My Terraform Runner Destroyed Itself Mid-Apply</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:54:57 +0000</pubDate>
      <link>https://dev.to/dwoitzik/my-terraform-runner-destroyed-itself-mid-apply-2995</link>
      <guid>https://dev.to/dwoitzik/my-terraform-runner-destroyed-itself-mid-apply-2995</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/atlantis-terraform-destroyed-itself-mid-apply/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;On 2026-07-04, Atlantis applied a Terraform change that shut down the VM it was running on. The Terraform runner destroyed its own runtime environment mid-apply.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

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

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

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

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

&lt;p&gt;The Terraform diff for a &lt;code&gt;cpu.units&lt;/code&gt; change looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"vm_srv_k3s_11"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;cpu&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;  &lt;span class="c1"&gt;# 2x scheduling priority over LXCs&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;bpg/proxmox&lt;/code&gt; detects a change to &lt;code&gt;cpu.units&lt;/code&gt;, it can't hot-apply it. The provider issues a &lt;code&gt;qmshutdown&lt;/code&gt; via the Proxmox API, waits for the VM to stop, applies the change, then starts the VM again.&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;p&gt;The fix was ADR-012: move Atlantis from a k3s Deployment to a dedicated LXC container (&lt;code&gt;ct-srv-atlantis-01&lt;/code&gt;, VMID 204, IP &lt;code&gt;10.0.20.250&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/stacks/proxmox/lxc.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"ct_srv_atlantis_01"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Dedicated LXC — NOT managed by the same Terraform stack&lt;/span&gt;
  &lt;span class="c1"&gt;# Atlantis manages OTHER stacks, but its own container is outside the scope&lt;/span&gt;
  &lt;span class="nx"&gt;vm_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ct-srv-atlantis-01"&lt;/span&gt;
  &lt;span class="nx"&gt;node_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pve"&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key difference: the Atlantis LXC is still defined in the Proxmox Terraform stack, but it's &lt;em&gt;never&lt;/em&gt; managed by Atlantis itself. The &lt;code&gt;atlantis.yaml&lt;/code&gt; repo config whitelists only specific repos and directories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.com/dwoitzik/homelab-infrastructure&lt;/span&gt;
    &lt;span class="na"&gt;allowed_overrides&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;apply_requirements&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;delete_source_branch_on_merge&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;apply_requirements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;approved&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mergeable&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/network&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/cloudflare&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform/stacks/garage&lt;/span&gt;
        &lt;span class="na"&gt;workspace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;The Atlantis LXC runs via Ansible, not k3s:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ansible/roles/atlantis/tasks/main.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Atlantis via Docker Compose&lt;/span&gt;
  &lt;span class="na"&gt;community.docker.docker_compose_v2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;project_src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/atlantis&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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




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



</description>
      <category>terraform</category>
      <category>gitops</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The ZFS ARC Freeze: How a Marginal PSU Killed My Entire Homelab</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sat, 01 Aug 2026 00:55:59 +0000</pubDate>
      <link>https://dev.to/dwoitzik/the-zfs-arc-freeze-how-a-marginal-psu-killed-my-entire-homelab-4eaa</link>
      <guid>https://dev.to/dwoitzik/the-zfs-arc-freeze-how-a-marginal-psu-killed-my-entire-homelab-4eaa</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/zfs-arc-freeze-psu-io-deadlock/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Three times in one week, my Proxmox host froze completely. No SSH, no web UI, no console. The only recovery was a hard power cycle. Each time, I lost every running VM and LXC simultaneously — k3s control plane, workers, databases, all of it.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  The Symptoms
&lt;/h2&gt;

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;p&gt;The k3s VMs and the ZFS pool were sharing the same NVMe. QEMU's page cache was adding to the I/O contention. I moved the k3s VM disks to cache=none + aio=native:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/stacks/proxmox/vm.tf&lt;/span&gt;
&lt;span class="nx"&gt;disks&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scsi&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;scsi0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;disk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;size&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"50G"&lt;/span&gt;
        &lt;span class="nx"&gt;cache&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
        &lt;span class="nx"&gt;aio&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"native"&lt;/span&gt;
        &lt;span class="nx"&gt;storage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"local-lvm"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Without caps, the ARC can grow to consume most of available memory, which means more dirty pages waiting for txg commit. I capped the ARC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/modprobe.d/zfs.conf&lt;/span&gt;
options zfs &lt;span class="nv"&gt;zfs_arc_max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4294967296  &lt;span class="c"&gt;# 4GB max ARC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;ZFS defaults to a 5-second txg timeout. On hardware with known I/O fragility, I shortened it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/modprobe.d/zfs.conf&lt;/span&gt;
options zfs &lt;span class="nv"&gt;zfs_txg_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;The boot storm was a separate but related trigger. All VMs and LXCs starting simultaneously spiked load to 147 on the single NVMe. I added staggered boot ordering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# NFS boots first (no k3s dependency)&lt;/span&gt;
&lt;span class="c1"&gt;# k3s VMs boot 30s apart&lt;/span&gt;
&lt;span class="c1"&gt;# LXCs start last&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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




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



</description>
      <category>homelab</category>
      <category>proxmox</category>
      <category>zfs</category>
      <category>debugging</category>
    </item>
    <item>
      <title>How a Single Volume Was 65% of My Velero Backup and What I Almost Excluded Instead</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 27 Jul 2026 10:25:30 +0000</pubDate>
      <link>https://dev.to/dwoitzik/how-a-single-volume-was-65-of-my-velero-backup-and-what-i-almost-excluded-instead-2kla</link>
      <guid>https://dev.to/dwoitzik/how-a-single-volume-was-65-of-my-velero-backup-and-what-i-almost-excluded-instead-2kla</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/velero-nfs-provisioner-root-mount-redundant-backup/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Velero's nightly backup was taking 207 seconds and backing up 33.4GB. That's normal for a cluster with 15+ PVCs — except when one volume accounts for 65% of the total bytes. The nfs-provisioner's root-mount volume was backing up the entire shared NFS export as a single blob, even though every app's individual PVC (a subdirectory of the same tree) was already backed up separately.&lt;/p&gt;

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

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

&lt;p&gt;Velero's backup stats showed a disproportionate volume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;PodVolumeBackup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-provisioner-root&lt;/span&gt;
&lt;span class="na"&gt;Namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-provisioner&lt;/span&gt;
&lt;span class="na"&gt;Size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;33.4GB&lt;/span&gt;
&lt;span class="na"&gt;Duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;207s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;p&gt;Verified the target before acting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Confirm the root-mount volume targets rpool (the contended disk)&lt;/span&gt;
findmnt /persistentvolumes
&lt;span class="c"&gt;# /dev/zvol/rpool/nfs  ← confirmed rpool&lt;/span&gt;

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

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  The Exclusion
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/nfs-provisioner/application.yml&lt;/span&gt;
&lt;span class="na"&gt;podAnnotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backup.velero.io/backup-volumes-excludes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nfs-subdir-external-provisioner-root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

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

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

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

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



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

&lt;/div&gt;



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







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

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

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

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




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

</description>
      <category>kubernetes</category>
      <category>backup</category>
      <category>nfs</category>
      <category>velero</category>
    </item>
    <item>
      <title>Why a Cloud Backup Sync Was Failing Daily for Six Weeks</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 27 Jul 2026 10:08:19 +0000</pubDate>
      <link>https://dev.to/dwoitzik/why-a-cloud-backup-sync-was-failing-daily-for-six-weeks-5dg4</link>
      <guid>https://dev.to/dwoitzik/why-a-cloud-backup-sync-was-failing-daily-for-six-weeks-5dg4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/google-drive-api-throttling-backup-chunk-storage/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;A systematic &lt;code&gt;ansible-playbook --check --diff&lt;/code&gt; sweep across every host group turned up a stale Vault credential and a cron job that appeared to have simply stopped running. The initial read was a straightforward drift problem: fix the credential, restore the cron schedule, move on. That read was wrong on both the diagnosis and, as it turned out one PR later, the intended fix.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What the Audit Sweep Found
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;1. A stale Vault credential that would have clobbered a working live token.&lt;/strong&gt; The &lt;code&gt;pbs_rclone_gdrive_token&lt;/code&gt; stored in Vault was from April; the live &lt;code&gt;rclone.conf&lt;/code&gt; on the host had a June token - rclone had refreshed it itself as OAuth tokens do, and Vault had never been updated to match. Running the Ansible role for real (not just &lt;code&gt;--check&lt;/code&gt;) would have overwritten the working, self-refreshed credential with a four-month-stale one, breaking authentication.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Confirmed: live rclone.conf token newer than Vault's stored value&lt;/span&gt;
diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;rclone config show pbs-gdrive | &lt;span class="nb"&gt;grep &lt;/span&gt;token&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;vault kv get &lt;span class="nt"&gt;-field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pbs_rclone_gdrive_token secret/pbs&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# Fixed: update Vault to match the live, working token&lt;/span&gt;
vault kv patch secret/pbs &lt;span class="nv"&gt;pbs_rclone_gdrive_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;rclone config show pbs-gdrive | &lt;span class="nb"&gt;grep &lt;/span&gt;token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;p&gt;It hadn't been silently dropped. Checking with the account owner surfaced the actual story: the cron job had been turned off &lt;em&gt;deliberately&lt;/em&gt;, because the destination Google Drive account doesn't have enough free storage quota for the PBS datastore's full size. Turning the schedule back on wasn't fixing drift - it was un-doing an intentional decision that the earlier PR had mistaken for an accident.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What the first PR did (wrong - re-enables a deliberately-disabled job)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sync PBS offsite cron&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pbs-to-gdrive-sync&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;   &lt;span class="c1"&gt;# ← reintroduces a schedule the user turned off&lt;/span&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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




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



</description>
      <category>backup</category>
      <category>homelab</category>
      <category>ansible</category>
    </item>
    <item>
      <title>MikroTik vs Ubiquiti for Home Network 2026</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 19 Jul 2026 11:07:31 +0000</pubDate>
      <link>https://dev.to/dwoitzik/mikrotik-vs-ubiquiti-for-home-network-2026-5g5j</link>
      <guid>https://dev.to/dwoitzik/mikrotik-vs-ubiquiti-for-home-network-2026-5g5j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/mikrotik-vs-ubiquiti-home-network-2026/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

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

&lt;p&gt;This is the networking debate that never dies in homelab circles: MikroTik or Ubiquiti? I have run both in production — MikroTik RB5009 as my primary router for over two years with every firewall rule and VLAN managed via Terraform, and Ubiquiti UniFi gear in a friend's lab where I helped with deployment. This is not a spec-sheet comparison. It is a practical breakdown of what each platform actually delivers for a homelab that runs Proxmox, K3s, and zero-trust networking.&lt;/p&gt;

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

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

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

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

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;Here is the actual Terraform code that creates the final drop rule in my firewall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ip_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"fwd_99_drop_all"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"drop"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"99: Global - Final Drop (Zero Trust Policy)"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

&lt;p&gt;&lt;strong&gt;MikroTik RB5009&lt;/strong&gt; runs WireGuard natively in RouterOS 7. The WireGuard implementation supports multiple peers, fine-grained allowed IPs, and persistent keepalive — everything you need for site-to-site or remote access VPN. I run WireGuard on my RB5009 for remote access to the homelab, and the Terraform code for it is in the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_interface_wireguard"&lt;/span&gt; &lt;span class="s2"&gt;"wg0"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;listen_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;13231&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"wg0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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




</description>
      <category>networking</category>
      <category>mikrotik</category>
      <category>review</category>
    </item>
    <item>
      <title>Best NAS for Home Backup 2026</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 19 Jul 2026 10:53:20 +0000</pubDate>
      <link>https://dev.to/dwoitzik/best-nas-for-home-backup-2026-15m</link>
      <guid>https://dev.to/dwoitzik/best-nas-for-home-backup-2026-15m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/best-nas-home-backup-2026/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

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

&lt;p&gt;A NAS is the single most important piece of hardware in any homelab. I learned this the hard way when a Proxmox host's NVMe died without a verified backup — the incident I wrote about in &lt;a href="https://dev.to/blog/backup-restore-test-never-verified-network-conflict"&gt;Backup &amp;amp; Restore: The Test You Never Verified&lt;/a&gt;. Since then, I run the 3-2-1 rule religiously: three copies of critical data, on two different media types, with one offsite. A NAS is the backbone of that strategy.&lt;/p&gt;

&lt;p&gt;But the NAS market in 2026 is crowded. Synology still dominates mindshare, QNAP competes on features, Terramaster fights on price, and UGREEN is the new player trying to disrupt both. This comparison is based on real deployment experience and hands-on testing — not spec sheet comparisons.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Bays&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Price (approx.)&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;Synology DS224+*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Intel Celeron J4125&lt;/td&gt;
&lt;td&gt;2GB (expandable)&lt;/td&gt;
&lt;td&gt;~€300&lt;/td&gt;
&lt;td&gt;Best Overall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;QNAP TS-264*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Intel Celeron N5095&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;~€350&lt;/td&gt;
&lt;td&gt;Best Features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;Terramaster F2-223*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Intel Celeron N4505&lt;/td&gt;
&lt;td&gt;4GB&lt;/td&gt;
&lt;td&gt;~€200&lt;/td&gt;
&lt;td&gt;Best Budget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;Synology DS423+*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Intel Celeron J4125&lt;/td&gt;
&lt;td&gt;2GB (expandable)&lt;/td&gt;
&lt;td&gt;~€500&lt;/td&gt;
&lt;td&gt;Best for Growth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;UGREEN DXP2800*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Intel N100&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;~€280&lt;/td&gt;
&lt;td&gt;Best Newcomer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want the short version: the &lt;strong&gt;Synology DS224+&lt;/strong&gt; remains the default recommendation for anyone who wants a NAS that works reliably for 5+ years with minimal maintenance. If you need more bays and growth potential, the &lt;strong&gt;Synology DS423+&lt;/strong&gt; is the investment pick. If budget matters and you are comfortable with less polish, the &lt;strong&gt;Terramaster F2-223&lt;/strong&gt; punches well above its price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selection Criteria — What Matters in a Home Backup NAS
&lt;/h2&gt;

&lt;p&gt;A NAS for home backup has different priorities than a NAS for media streaming or enterprise storage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reliability over features&lt;/strong&gt; — You need a device that stays online 24/7, handles power loss gracefully, and does not corrupt your data during firmware updates. Synology leads here by a wide margin. QNAP's feature set is broader but their update track record is rougher.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk compatibility&lt;/strong&gt; — Not all NAS enclosures work well with all drives. Synology's drive compatibility list is restrictive (they now push their own HAT5300 drives), but this conservatism means fewer surprise failures. QNAP and Terramaster are more permissive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backup software&lt;/strong&gt; — The native backup application matters more than the hardware. Synology Active Backup for Business, Hyper Backup, and Snapshot Replication are best-in-class for consumer NAS. QNAP's Hybrid Backup Sync is powerful but more complex. Terramaster and UGREEN's software ecosystems are younger and less proven.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAID support&lt;/strong&gt; — For 2-bay units, RAID 1 (mirroring) is the minimum. For 4-bay, RAID 5 or RAID 6 gives you redundancy with capacity. Synology's SHR (Synology Hybrid RAID) is particularly clever — it lets you mix drive sizes without wasting space, which matters when you upgrade drives incrementally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Power consumption&lt;/strong&gt; — The NAS runs 24/7. An idle draw of 10W versus 25W is significant over a year. Disk count matters more than the enclosure itself — two 7200RPM HDDs draw more power than the entire NAS unit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expandability&lt;/strong&gt; — RAM upgrades, NVMe cache drives, 10GbE expansion, and USB connectivity for external backup targets all extend the useful life of a NAS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Individual Reviews
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Synology DS224+ — Best Overall
&lt;/h3&gt;

&lt;p&gt;2-Bay · Intel Celeron J4125 · 2GB DDR4 (expandable to 6GB) · ~€300&lt;/p&gt;

&lt;p&gt;The DS224+ is the NAS I recommend to everyone who asks, and the one I run myself. It is not the cheapest, not the fastest, and not the most feature-rich — it is the one that works without drama for years.&lt;/p&gt;

&lt;p&gt;Synology's DSM 7.2 operating system is the reason to buy this NAS. The interface is polished, the backup applications are mature, and the package ecosystem (Docker support, surveillance, media serving) covers every use case without feeling bloated. Hyper Backup handles encrypted, deduplicated backups to local drives, remote Synology NAS, and cloud targets (S3, Backblaze B2, Google Drive) with a single application.&lt;/p&gt;

&lt;p&gt;The J4125 CPU is not fast — it is a 4-core embedded chip designed for low power, not performance. For file serving, backup, and even Docker containers running lightweight services, it is perfectly adequate. For Plex transcoding of 4K HEVC content, it will struggle. The 2GB base RAM is tight if you run multiple packages; upgrading to 6GB via the SO-DIMM slot is essential if you use Docker or run more than 2-3 services.&lt;/p&gt;

&lt;p&gt;Power consumption is excellent: idle around 10-12W with two drives spun down, 18-22W under active load. This matters for a device running 24/7.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DSM 7.2 — best NAS operating system available&lt;/li&gt;
&lt;li&gt;Excellent power efficiency&lt;/li&gt;
&lt;li&gt;Hyper Backup and Active Backup are mature, reliable&lt;/li&gt;
&lt;li&gt;SHR allows mixed-drive-size RAID&lt;/li&gt;
&lt;li&gt;Strong community and documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Proprietary drive push (HAT5300 drives are expensive)&lt;/li&gt;
&lt;li&gt;Base RAM is only 2GB — upgrade needed for Docker&lt;/li&gt;
&lt;li&gt;J4125 is aging; no hardware transcoding for 4K HEVC&lt;/li&gt;
&lt;li&gt;Price premium over competitors with similar raw specs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. QNAP TS-264 — Best Features
&lt;/h3&gt;

&lt;p&gt;2-Bay · Intel Celeron N5095 · 8GB DDR4 · ~€350&lt;/p&gt;

&lt;p&gt;The QNAP TS-264 is the feature powerhouse. Where Synology focuses on reliability and polish, QNAP throws everything at the wall: HDMI 2.0 output, dual 2.5GbE ports, two M.2 NVMe slots, USB 3.2 Gen 2, and a built-in speaker for alerts. It is a NAS that tries to be a mini server.&lt;/p&gt;

&lt;p&gt;The N5095 CPU is faster than Synology's J4125, and the 8GB base RAM means you can run Docker containers, VMs via Virtualization Station, and QNAP's own apps without immediately needing an upgrade. The dual 2.5GbE ports support link aggregation, which is useful if your network backbone supports it.&lt;/p&gt;

&lt;p&gt;QNAP's QTS 5 operating system is powerful but less intuitive than DSM. The interface is busier, the settings are scattered across more menus, and the default security posture is weaker — QNAP has had several high-profile vulnerabilities in recent years (Deadbolt ransomware was particularly painful for the community). You need to harden a QNAP NAS on day one: disable default admin, enable 2FA, and configure the firewall.&lt;/p&gt;

&lt;p&gt;The real advantage of the TS-264 is HDMI output. If you want a NAS that doubles as a media player connected directly to a TV, this is the only option on the list that does it natively without a separate NUC or Apple TV.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most features per euro (HDMI, dual 2.5GbE, dual M.2)&lt;/li&gt;
&lt;li&gt;8GB RAM base — no immediate upgrade needed&lt;/li&gt;
&lt;li&gt;Virtualization Station for lightweight VMs&lt;/li&gt;
&lt;li&gt;HDMI 2.0 for direct media playback&lt;/li&gt;
&lt;li&gt;PCIe slot for 10GbE expansion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;QTS software is less polished than DSM&lt;/li&gt;
&lt;li&gt;Security track record (Deadbolt, Qlocker incidents)&lt;/li&gt;
&lt;li&gt;Higher idle power than Synology (~15-18W without drives)&lt;/li&gt;
&lt;li&gt;Fan noise higher than Synology at comparable load&lt;/li&gt;
&lt;li&gt;Support documentation less comprehensive&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Terramaster F2-223 — Best Budget
&lt;/h3&gt;

&lt;p&gt;2-Bay · Intel Celeron N4505 · 4GB DDR4 · ~€200&lt;/p&gt;

&lt;p&gt;The Terramaster F2-223 is the budget pick that does not feel like a compromise in core functionality. At €200, it undercuts the Synology DS224+ by €100 while offering similar hardware: a dual-core Intel CPU, 4GB RAM (upgradeable), and dual GbE ports.&lt;/p&gt;

&lt;p&gt;Terramaster's TOS 5 operating system is the weak point. It has improved significantly from TOS 4, which was genuinely bad, but it still lacks the polish and reliability of DSM or QTS. The Docker integration works, the backup applications exist, and the file sharing protocols (SMB, NFS, WebDAV) are functional — but the overall experience feels like using a first-generation product. The community is smaller, the documentation is thinner, and finding solutions to edge-case problems takes more effort.&lt;/p&gt;

&lt;p&gt;Where the F2-223 wins is raw value. The N4505 is a modern dual-core chip that handles file serving and light Docker workloads without issue. The 4GB base RAM is adequate for basic use. The dual GbE ports support link aggregation. And at €200, you can buy two of them for the price of one Synology DS224+ — which, if you need redundancy, is a compelling argument.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Best price-to-hardware ratio&lt;/li&gt;
&lt;li&gt;4GB RAM base (more than Synology's 2GB)&lt;/li&gt;
&lt;li&gt;Dual GbE with link aggregation&lt;/li&gt;
&lt;li&gt;Compact, quiet design&lt;/li&gt;
&lt;li&gt;Adequate for basic file backup and Docker&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TOS 5 is significantly less polished than DSM&lt;/li&gt;
&lt;li&gt;Smaller community and thinner documentation&lt;/li&gt;
&lt;li&gt;No Hyper Backup equivalent — more manual backup setup&lt;/li&gt;
&lt;li&gt;Build quality noticeably cheaper than Synology/QNAP&lt;/li&gt;
&lt;li&gt;Long-term software support uncertain&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Synology DS423+ — Best for Growth
&lt;/h3&gt;

&lt;p&gt;4-Bay · Intel Celeron J4125 · 2GB DDR4 (expandable to 32GB) · ~€500&lt;/p&gt;

&lt;p&gt;The DS423+ is the NAS you buy when you know your storage needs will grow. Four bays with Synology Hybrid RAID (SHR) means you can start with two drives, add a third later, and a fourth later — without rebuilding the array from scratch each time.&lt;/p&gt;

&lt;p&gt;The J4125 CPU is the same as the DS224+, which means the same performance limitations apply. For file serving and backup, it is fine. For media transcoding, it is not. The value of the DS423+ is not in its CPU — it is in the four bays, the expandability, and the Synology ecosystem at a scale that grows with your data.&lt;/p&gt;

&lt;p&gt;Max RAM is 32GB (via a single SO-DIMM slot), which is overkill for most home use but enables running Synology's Virtual Machine Manager with multiple lightweight VMs. The two built-in NVMe M.2 slots (for cache, not storage) improve random I/O for database-heavy applications.&lt;/p&gt;

&lt;p&gt;Power consumption scales with drives: idle around 15-20W with four drives spun down, 35-45W under load with four active drives. This is the price of four bays — plan your power budget accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SHR with 4 bays — maximum flexibility for mixed drives&lt;/li&gt;
&lt;li&gt;Expandable to 32GB RAM&lt;/li&gt;
&lt;li&gt;NVMe cache slots for performance-sensitive workloads&lt;/li&gt;
&lt;li&gt;Same reliable DSM 7.2 as the DS224+&lt;/li&gt;
&lt;li&gt;10GbE expandable via PCIe slot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;€500 is a significant investment for a 4-bay consumer NAS&lt;/li&gt;
&lt;li&gt;J4125 CPU is aging — same as the cheaper DS224+&lt;/li&gt;
&lt;li&gt;2GB base RAM requires immediate upgrade&lt;/li&gt;
&lt;li&gt;Power consumption with 4 active drives is meaningful&lt;/li&gt;
&lt;li&gt;No hardware transcoding&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. UGREEN DXP2800 — Best Newcomer
&lt;/h3&gt;

&lt;p&gt;2-Bay · Intel N100 · 8GB DDR5 · ~€280&lt;/p&gt;

&lt;p&gt;UGREEN is the new entrant trying to disrupt the Synology-QNAP duopoly. The DXP2800 is their flagship 2-bay, and on paper, it is impressive: Intel N100 CPU (the same chip powering the best mini PCs), 8GB DDR5 RAM, dual 2.5GbE, two M.2 NVMe slots, and a metal chassis that feels more premium than the price suggests.&lt;/p&gt;

&lt;p&gt;UGREEN's UGOS operating system is the wildcard. It is new, still in active development, and lacks the maturity of DSM or QTS. The core features work — file sharing, Docker, basic backup — but the software ecosystem is thin, the app store is limited, and the long-term support commitment is unproven. UGREEN is a hardware company, not a software company, and it shows.&lt;/p&gt;

&lt;p&gt;The hardware is genuinely excellent for the price. The N100 is more powerful than Synology's J4125, the 8GB DDR5 RAM eliminates the immediate upgrade need, and the dual 2.5GbE ports are faster than anything Synology offers in this price range. If UGOS matures, the DXP2800 could be the best value NAS on the market. If it does not, you are left with good hardware running mediocre software.&lt;/p&gt;

&lt;p&gt;The N100's idle power draw (~8-10W for the enclosure) is competitive with Synology. The DDR5 memory and efficient Intel chip keep thermals low even under sustained file operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Best hardware-per-euro in the 2-bay category&lt;/li&gt;
&lt;li&gt;Intel N100 — more powerful than any Synology in this range&lt;/li&gt;
&lt;li&gt;8GB DDR5 base RAM&lt;/li&gt;
&lt;li&gt;Dual 2.5GbE and dual M.2 NVMe&lt;/li&gt;
&lt;li&gt;Metal chassis, solid build quality&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UGOS is immature — limited app ecosystem&lt;/li&gt;
&lt;li&gt;Long-term software support uncertain (new company in NAS space)&lt;/li&gt;
&lt;li&gt;Backup software not comparable to Hyper Backup&lt;/li&gt;
&lt;li&gt;Community is small; troubleshooting requires more self-reliance&lt;/li&gt;
&lt;li&gt;Firmware update history is short — no proven track record&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;DS224+&lt;/th&gt;
&lt;th&gt;TS-264&lt;/th&gt;
&lt;th&gt;F2-223&lt;/th&gt;
&lt;th&gt;DS423+&lt;/th&gt;
&lt;th&gt;DXP2800&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;J4125 (4C)&lt;/td&gt;
&lt;td&gt;N5095 (4C)&lt;/td&gt;
&lt;td&gt;N4505 (2C)&lt;/td&gt;
&lt;td&gt;J4125 (4C)&lt;/td&gt;
&lt;td&gt;N100 (4C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base RAM&lt;/td&gt;
&lt;td&gt;2GB&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;4GB&lt;/td&gt;
&lt;td&gt;2GB&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max RAM&lt;/td&gt;
&lt;td&gt;6GB&lt;/td&gt;
&lt;td&gt;8GB&lt;/td&gt;
&lt;td&gt;32GB&lt;/td&gt;
&lt;td&gt;32GB&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bays&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NIC&lt;/td&gt;
&lt;td&gt;2x 1GbE&lt;/td&gt;
&lt;td&gt;2x 2.5GbE&lt;/td&gt;
&lt;td&gt;2x 1GbE&lt;/td&gt;
&lt;td&gt;2x 1GbE&lt;/td&gt;
&lt;td&gt;2x 2.5GbE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M.2 Slots&lt;/td&gt;
&lt;td&gt;2 (cache)&lt;/td&gt;
&lt;td&gt;2 (storage)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2 (cache)&lt;/td&gt;
&lt;td&gt;2 (storage)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HDMI&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (2.0)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PCIe Slot&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle Power&lt;/td&gt;
&lt;td&gt;~10-12W&lt;/td&gt;
&lt;td&gt;~15-18W&lt;/td&gt;
&lt;td&gt;~8-10W&lt;/td&gt;
&lt;td&gt;~15-20W&lt;/td&gt;
&lt;td&gt;~8-10W&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;DSM 7.2&lt;/td&gt;
&lt;td&gt;QTS 5&lt;/td&gt;
&lt;td&gt;TOS 5&lt;/td&gt;
&lt;td&gt;DSM 7.2&lt;/td&gt;
&lt;td&gt;UGOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;~€300&lt;/td&gt;
&lt;td&gt;~€350&lt;/td&gt;
&lt;td&gt;~€200&lt;/td&gt;
&lt;td&gt;~€500&lt;/td&gt;
&lt;td&gt;~€280&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Buyer's Guide
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Buy the Synology DS224+ if:&lt;/strong&gt; You want a NAS that works reliably for 5+ years with minimal maintenance. DSM is the best NAS OS, Hyper Backup handles every backup scenario, and the ecosystem is proven. This is the default recommendation for 90% of home users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the QNAP TS-264 if:&lt;/strong&gt; You need maximum features in a 2-bay form factor. HDMI output for direct media playback, dual 2.5GbE for fast network transfers, and 8GB RAM for Docker-heavy use cases. Be prepared to harden the security on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the Terramaster F2-223 if:&lt;/strong&gt; Budget is the primary constraint and you are comfortable with a less polished software experience. The hardware is solid for the price, and TOS 5 is functional even if it is not inspiring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the Synology DS423+ if:&lt;/strong&gt; You know your storage needs will grow beyond two drives, or you want the option to run VMs alongside file serving. The four bays with SHR give you the most flexible RAID configuration available in a consumer NAS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the UGREEN DXP2800 if:&lt;/strong&gt; You are betting on UGOS maturing and want the best hardware value today. The N100 + 8GB DDR5 + dual 2.5GbE combination is unbeatable at €280. Just be aware you are an early adopter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For the homelab audience:&lt;/strong&gt; The Synology DS224+ with two 4TB drives in SHR-1 (mirroring) is the backbone of a 3-2-1 backup strategy. Pair it with a cloud sync target (Backblaze B2 or your own offsite S3-compatible endpoint) and you have a backup architecture that survives fire, theft, and ransomware. I run exactly this — the details are in the &lt;a href="https://dev.to/blog/backup-restore-test-never-verified-network-conflict"&gt;disaster recovery runbook&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How much storage do I need for home backup?
&lt;/h3&gt;

&lt;p&gt;For most households, 2-4TB covers documents, photos, phone backups, and critical system images. If you are archiving video or running a media server, 8-16TB becomes necessary. Start with two drives in a mirrored RAID (RAID 1 or SHR-1), and expand later — the point of a NAS is that you can replace drives one at a time with larger ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  What drives should I put in a NAS?
&lt;/h3&gt;

&lt;p&gt;NAS-rated drives (Seagate IronWolf, WD Red Plus) are designed for 24/7 operation and vibration tolerance in multi-bay enclosures. Desktop drives (WD Blue, Seagate Barracuda) are cheaper but not rated for continuous use — they work, but the failure rate in a NAS environment is measurably higher. For the home user who wants set-and-forget reliability, buy NAS-rated drives and sleep soundly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is RAID the same as backup?
&lt;/h3&gt;

&lt;p&gt;No. RAID protects against drive failure, not against file deletion, corruption, ransomware, or fire. A NAS in RAID 1 with no offsite backup is one &lt;code&gt;rm -rf /&lt;/code&gt; away from losing everything. RAID is availability; backup is recoverability. You need both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use Synology Hybrid RAID or standard RAID?
&lt;/h3&gt;

&lt;p&gt;SHR if you are on Synology — it is strictly better than standard RAID for home use. SHR allows mixing different drive sizes without wasting capacity, and it degrades more gracefully than traditional RAID when a drive fails. For two drives, SHR-1 is identical to RAID 1. For four drives, SHR-1 gives you single-drive fault tolerance with the flexibility to add larger drives later without rebuilding the entire array.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run Docker on these NAS devices?
&lt;/h3&gt;

&lt;p&gt;Yes, all five support Docker. Synology's Container Manager (formerly Docker package) is the most polished implementation. QNAP's Container Station works well but has a more complex interface. Terramaster and UGREEN's Docker support is functional but less integrated. If Docker is a primary use case, the QNAP TS-264 (8GB RAM, N5095 CPU) or UGREEN DXP2800 (8GB RAM, N100 CPU) give you the best hardware foundation.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does a NAS last?
&lt;/h3&gt;

&lt;p&gt;A quality NAS (Synology, QNAP) typically lasts 5-8 years before the hardware becomes obsolete or the software support ends. The drives inside have a rated lifespan of 3-5 years for NAS-rated models. Budget for drive replacement every 3-4 years as a preventive measure — drives do not give reliable warning before failing. The NAS enclosure itself will outlast two or three sets of drives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a NAS worth it versus just using an external hard drive?
&lt;/h3&gt;

&lt;p&gt;For a single computer with non-critical data, an external drive is fine. For any multi-device household, any data you cannot afford to lose, or any homelab running services that need shared storage — a NAS is worth the investment. The difference is automation: a NAS handles RAID, snapshots, cloud sync, and multi-user access automatically. An external drive requires you to remember to plug it in and copy files, which is a backup strategy that fails the moment you forget.&lt;/p&gt;

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

&lt;p&gt;The NAS market in 2026 is defined by one question: do you trust the software or the hardware more? Synology's DS224+ has aging hardware but bulletproof software. The UGREEN DXP2800 has excellent hardware but unproven software. The QNAP TS-264 sits in between, with strong hardware and software that requires active security management.&lt;/p&gt;

&lt;p&gt;For a home backup NAS that runs 24/7 for the next five years, trust matters more than specs. The Synology DS224+ is the boring, reliable choice — and that is exactly what you want from the device holding your only copy of family photos and critical documents.&lt;/p&gt;

&lt;p&gt;Buy the DS224+. Put two IronWolf drives in it. Configure Hyper Backup to sync to a cloud target. Verify the restore works once. Then forget about it — which is exactly how a backup system should behave.&lt;/p&gt;




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

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

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

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




</description>
      <category>backup</category>
      <category>hardware</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Best Mini PC for Homelab 2026</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 19 Jul 2026 10:52:44 +0000</pubDate>
      <link>https://dev.to/dwoitzik/best-mini-pc-for-homelab-2026-3c04</link>
      <guid>https://dev.to/dwoitzik/best-mini-pc-for-homelab-2026-3c04</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/best-mini-pc-homelab-2026/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

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

&lt;p&gt;After cycling through more mini PCs than I care to admit in my homelab — some running 24/7, others retired after a single disappointing week — I have settled on a clear ranking for 2026. The mini PC market has matured significantly: Intel's N100 still dominates the efficiency tier, AMD's Ryzen mobile chips crush multi-threaded workloads, and even the budget options no longer feel like compromise.&lt;/p&gt;

&lt;p&gt;This comparison is based on real deployment experience. My lab runs Proxmox, K3s, Terraform-managed VMs, and local LLM inference. Every machine on this list has been tested under those workloads.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Price (approx.)&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;BMAX B1 Pro*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Intel N100&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;td&gt;~€180&lt;/td&gt;
&lt;td&gt;Best Overall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;Beelink SER5*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Ryzen 5 5560U&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;td&gt;~€230&lt;/td&gt;
&lt;td&gt;Best Performance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;GMKTec NucBox K6*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Ryzen 7 6800H&lt;/td&gt;
&lt;td&gt;32GB&lt;/td&gt;
&lt;td&gt;~€350&lt;/td&gt;
&lt;td&gt;Best for Heavy Workloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;Intel NUC 13 Pro*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;i5-1340P&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;td&gt;~€400&lt;/td&gt;
&lt;td&gt;Best Build Quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://amzn.to/4XXXXXX" rel="noopener noreferrer"&gt;Trigkey Speed S5*&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Intel N100&lt;/td&gt;
&lt;td&gt;16GB&lt;/td&gt;
&lt;td&gt;~€150&lt;/td&gt;
&lt;td&gt;Best Budget&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want the short version: the &lt;strong&gt;BMAX B1 Pro&lt;/strong&gt; is the best all-rounder for a homelab that needs to run Proxmox, containers, or lightweight VMs 24/7. If you need more compute for local LLM inference or heavy compilation, the &lt;strong&gt;GMKTec NucBox K6&lt;/strong&gt; with its 6800H and 32GB RAM is worth the premium.&lt;/p&gt;

&lt;h2&gt;
  
  
  Selection Criteria — What Matters in a Homelab Mini PC
&lt;/h2&gt;

&lt;p&gt;Buying a mini PC for a homelab is fundamentally different from buying one as a desktop replacement. The priorities shift entirely:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idle power draw&lt;/strong&gt; — This machine runs 24/7. A 5W difference in idle consumption is €40+ per year. Intel N100 platforms consistently win here, idling at 6-10W versus 15-25W for Ryzen alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thermal management under sustained load&lt;/strong&gt; — Mini PCs live in racks or shelves with limited airflow. A chip that throttles after 20 minutes of sustained compilation is useless for building container images or running batch jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RAM expandability&lt;/strong&gt; — 16GB is the minimum for a serious Proxmox host running multiple VMs. Prefer models that accept SO-DIMM upgrades to 32GB or 64GB, not soldered memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network interfaces&lt;/strong&gt; — A single 1GbE port is a bottleneck for any hypervisor. Dual NICs or at least a 2.5GbE port makes the machine viable as a Proxmox node without USB Ethernet dongles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage options&lt;/strong&gt; — M.2 NVMe is table stakes. A second drive bay (2.5" SATA or a second M.2 slot) for Proxmox local storage or TrueNAS is a major advantage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Noise&lt;/strong&gt; — If this thing sits in your office, fan noise at load matters. Some N100 boxes are fanless; most Ryzen units need active cooling under load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Individual Reviews
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. BMAX B1 Pro — Best Overall
&lt;/h3&gt;

&lt;p&gt;Intel N100 · 16GB DDR5 · 500GB NVMe · ~€180&lt;/p&gt;

&lt;p&gt;The BMAX B1 Pro hits the sweet spot for a homelab node. The Intel N100 is a 6W TDP chip that idles around 7-8W wall draw, making it nearly invisible on your electricity bill when running 24/7. It has a single M.2 NVMe slot and a 2.5" SATA bay, giving you enough storage for Proxmox OS plus a reasonable local datastore.&lt;/p&gt;

&lt;p&gt;Where it really shines is reliability. I have had BMAX units running in my lab for over a year with zero hardware failures. The DDR5 memory is a nice upgrade from the DDR4 N100 boxes — not because N100 needs the bandwidth, but because DDR5 SO-DIMMs are easier to source at higher capacities for future upgrades.&lt;/p&gt;

&lt;p&gt;The 2.5GbE Realtek NIC is the one weak point. Realtek drivers on Linux work, but they lack the kernel support depth of Intel i225/i226 NICs. For a Proxmox node running Terraform-managed VMs, it works fine. For serious packet inspection or high-throughput bridging, you will notice the difference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Excellent idle efficiency (7-8W)&lt;/li&gt;
&lt;li&gt;DDR5 + SATA expansion bay&lt;/li&gt;
&lt;li&gt;Compact, quiet under light load&lt;/li&gt;
&lt;li&gt;Proven reliability over 12+ months&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Realtek 2.5GbE NIC (not Intel)&lt;/li&gt;
&lt;li&gt;Only 1x M.2 slot + 1x SATA&lt;/li&gt;
&lt;li&gt;Single-channel memory&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Beelink SER5 — Best Performance
&lt;/h3&gt;

&lt;p&gt;Ryzen 5 5560U · 16GB DDR4 · 500GB NVMe · ~€230&lt;/p&gt;

&lt;p&gt;The Beelink SER5 is the performance-per-euro champion. The Ryzen 5 5560U offers 6 cores / 12 threads with a 15W TDP, delivering roughly 2.5x the multi-threaded performance of the N100 while only costing €50 more. For containerized workloads — building Docker images, running Kubernetes operators, or hosting development databases — the extra cores translate directly into faster builds and lower latency.&lt;/p&gt;

&lt;p&gt;The tradeoff is power. Idle draw sits around 18-22W, which over a year adds up. If this machine is your primary Proxmox host running 10+ containers, the performance justifies the extra power. If it is just running Pi-hole and a couple of lightweight services, the N100 is the smarter buy.&lt;/p&gt;

&lt;p&gt;The SER5 has a single M.2 NVMe slot and a 2.5" SATA bay, plus dual USB 3.2 ports for external storage. The dual 2.5GbE ports (Realtek on some revisions, Intel on newer batches — check before buying) make it viable as a multi-VLAN Proxmox trunk node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Strong multi-threaded performance for the price&lt;/li&gt;
&lt;li&gt;Dual 2.5GbE (Intel NIC on newer revisions)&lt;/li&gt;
&lt;li&gt;Upgradeable to 64GB DDR4&lt;/li&gt;
&lt;li&gt;Good build quality for the price tier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Higher idle power (18-22W)&lt;/li&gt;
&lt;li&gt;Fan audible under sustained load&lt;/li&gt;
&lt;li&gt;DDR4 not DDR5 (less future-proof)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. GMKTec NucBox K6 — Best for Heavy Workloads
&lt;/h3&gt;

&lt;p&gt;Ryzen 7 6800H · 32GB DDR5 · 1TB NVMe · ~€350&lt;/p&gt;

&lt;p&gt;If you need raw compute in a small box, the GMKTec K6 is the answer. The 6800H is a 45W class chip — significantly more powerful than anything else on this list — and it comes with 32GB of DDR5 RAM out of the box. That is enough to run Proxmox with several VMs, a K3s cluster, and still have headroom for local LLM inference with Ollama.&lt;/p&gt;

&lt;p&gt;The power draw reflects the performance: idle around 20-28W, and under full load it can hit 55-65W. This is not a machine you deploy for Pi-hole. This is a machine you deploy when you need one box to replace three lesser ones.&lt;/p&gt;

&lt;p&gt;The cooling system on the K6 is beefier than the BMAX or Beelink — dual copper heat pipes and a larger fan. Under sustained compilation loads (building a K3s cluster from source, for example), temperatures stay below 80°C, which is impressive for this form factor. The fan is audible but not obnoxious; I would not put it on a desk next to my ear, but in a rack shelf it disappears.&lt;/p&gt;

&lt;p&gt;Storage is generous: 2x M.2 NVMe slots (one PCIe 4.0, one PCIe 3.0) plus a 2.5" SATA bay. This makes it viable as a small Proxmox + local storage server without needing a separate NAS for warm data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Serious multi-thread performance (8 cores / 16 threads)&lt;/li&gt;
&lt;li&gt;32GB DDR5 included&lt;/li&gt;
&lt;li&gt;Dual M.2 NVMe + SATA&lt;/li&gt;
&lt;li&gt;Good thermal design for sustained loads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High idle power for a mini PC (20-28W)&lt;/li&gt;
&lt;li&gt;Larger and heavier than competitors&lt;/li&gt;
&lt;li&gt;Fan noise at load — not suitable for a quiet office shelf&lt;/li&gt;
&lt;li&gt;Price premium over N100/Ryzen 5 alternatives&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Intel NUC 13 Pro — Best Build Quality
&lt;/h3&gt;

&lt;p&gt;Intel Core i5-1340P · 16GB DDR5 · 500GB NVMe · ~€400&lt;/p&gt;

&lt;p&gt;The Intel NUC 13 Pro is the premium choice, and the price reflects it. Intel's own NUC line (now maintained by ASUS after the brand transfer) has the best build quality in this category: the chassis is solid aluminum, the thermal design is refined, and the component selection — particularly the Intel i226-V 2.5GbE NIC — is the best you will find.&lt;/p&gt;

&lt;p&gt;The i5-1340P is a 12-core / 16-thread chip that sits between the Ryzen 5 5560U and the 6800H in performance. Intel's hybrid architecture (P-cores + E-cores) works well for heterogeneous workloads: the P-cores handle bursty compilation tasks while the E-cores absorb background services without burning power.&lt;/p&gt;

&lt;p&gt;The i226-V NIC deserves special mention. Intel's 2.5GbE controllers have the best Linux driver support of any vendor. If you are running Proxmox with VLAN bridging, OVS (Open vSwitch), or any kind of advanced networking, the Intel NIC eliminates a whole category of troubleshooting. I have spent enough time debugging Realtek driver issues on N100 boxes that this alone justifies the premium for a production homelab node.&lt;/p&gt;

&lt;p&gt;The downside is pure cost. At €400, you are paying more than double the BMAX B1 Pro for a machine that, in raw compute benchmarks, is only marginally faster than the Beelink SER5 at €230. You are paying for build quality, the Intel NIC, and NUC reliability — which matters when this machine runs your production K3s cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Best-in-class build quality and thermal design&lt;/li&gt;
&lt;li&gt;Intel i226-V 2.5GbE NIC (best Linux driver support)&lt;/li&gt;
&lt;li&gt;VPro/AMT for remote out-of-band management&lt;/li&gt;
&lt;li&gt;Excellent idle efficiency for a 12-core chip (~12-15W)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Significant price premium&lt;/li&gt;
&lt;li&gt;Only 1x M.2 + 1x 2.5" SATA&lt;/li&gt;
&lt;li&gt;Performance gap vs. cheaper options is marginal&lt;/li&gt;
&lt;li&gt;ASUS NUC availability can be inconsistent in EU&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Trigkey Speed S5 — Best Budget
&lt;/h3&gt;

&lt;p&gt;Intel N100 · 16GB DDR4 · 500GB NVMe · ~€150&lt;/p&gt;

&lt;p&gt;The Trigkey Speed S5 is the BMAX B1 Pro's budget cousin. Same Intel N100 chip, same 16GB RAM, same basic form factor — but at €150 it undercuts the BMAX by €30. For a first homelab node or a dedicated Pi-hole/AdGuard Home box, this is the price-to-performance king.&lt;/p&gt;

&lt;p&gt;The tradeoffs are predictable: the chassis feels cheaper (more plastic, less metal), the thermal solution is more basic (the fan ramps up more aggressively under load), and the expansion options are limited — single M.2 NVMe only, no SATA bay. The NIC is Realtek 2.5GbE, same as the BMAX.&lt;/p&gt;

&lt;p&gt;For a dedicated role — running a single service like DNS, a reverse proxy, or a lightweight monitoring stack — these compromises are irrelevant. The N100 chip is identical, and the performance is identical. You just get a slightly noisier box with less upgrade potential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lowest price for N100 + 16GB platform&lt;/li&gt;
&lt;li&gt;Same N100 performance as more expensive options&lt;/li&gt;
&lt;li&gt;Adequate for single-purpose homelab roles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No SATA expansion bay&lt;/li&gt;
&lt;li&gt;Cheaper chassis and thermal solution&lt;/li&gt;
&lt;li&gt;Fan more aggressive under load&lt;/li&gt;
&lt;li&gt;Limited brand support/longevity compared to BMAX or Beelink&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;BMAX B1 Pro&lt;/th&gt;
&lt;th&gt;Beelink SER5&lt;/th&gt;
&lt;th&gt;GMKTec K6&lt;/th&gt;
&lt;th&gt;Intel NUC 13&lt;/th&gt;
&lt;th&gt;Trigkey S5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;N100 (4C/4T)&lt;/td&gt;
&lt;td&gt;5560U (6C/12T)&lt;/td&gt;
&lt;td&gt;6800H (8C/16T)&lt;/td&gt;
&lt;td&gt;i5-1340P (12C/16T)&lt;/td&gt;
&lt;td&gt;N100 (4C/4T)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;16GB DDR5&lt;/td&gt;
&lt;td&gt;16GB DDR4&lt;/td&gt;
&lt;td&gt;32GB DDR5&lt;/td&gt;
&lt;td&gt;16GB DDR5&lt;/td&gt;
&lt;td&gt;16GB DDR4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max RAM&lt;/td&gt;
&lt;td&gt;32GB&lt;/td&gt;
&lt;td&gt;64GB&lt;/td&gt;
&lt;td&gt;64GB&lt;/td&gt;
&lt;td&gt;64GB&lt;/td&gt;
&lt;td&gt;32GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M.2 Slots&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SATA Bay&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NIC&lt;/td&gt;
&lt;td&gt;1x 2.5GbE (RTL)&lt;/td&gt;
&lt;td&gt;2x 2.5GbE&lt;/td&gt;
&lt;td&gt;2x 2.5GbE (Intel)&lt;/td&gt;
&lt;td&gt;1x 2.5GbE (Intel i226)&lt;/td&gt;
&lt;td&gt;1x 2.5GbE (RTL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle Power&lt;/td&gt;
&lt;td&gt;~7-8W&lt;/td&gt;
&lt;td&gt;~18-22W&lt;/td&gt;
&lt;td&gt;~20-28W&lt;/td&gt;
&lt;td&gt;~12-15W&lt;/td&gt;
&lt;td&gt;~7-8W&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fanless Option&lt;/td&gt;
&lt;td&gt;No (very quiet)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;~€180&lt;/td&gt;
&lt;td&gt;~€230&lt;/td&gt;
&lt;td&gt;~€350&lt;/td&gt;
&lt;td&gt;~€400&lt;/td&gt;
&lt;td&gt;~€150&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Buyer's Guide
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Buy the BMAX B1 Pro if:&lt;/strong&gt; You want a reliable, efficient all-rounder that sips power and handles Proxmox, containers, and lightweight VMs without fuss. This is the default recommendation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the Beelink SER5 if:&lt;/strong&gt; You need more CPU power than the N100 but want to keep costs under €250. Ideal for a primary Proxmox host running a mix of containerized and VM workloads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the GMKTec K6 if:&lt;/strong&gt; You want one box to rule them all. The 32GB RAM and 6800H handle local LLM inference, heavy compilation, and multi-VM Proxmox without breaking a sweat. Accept the higher power draw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the Intel NUC 13 Pro if:&lt;/strong&gt; Build quality and NIC reliability matter more than price. The i226-V NIC and VPro/AMT remote management make this the "set and forget" choice for a production homelab that you do not want to babysit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buy the Trigkey S5 if:&lt;/strong&gt; Budget is the primary constraint, or you need a dedicated single-purpose node (DNS, monitoring, reverse proxy) where the N100 is more than enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are starting from scratch:&lt;/strong&gt; Buy two BMAX B1 Pro units. Run Proxmox with a VM or container failover strategy. The total cost (€360) is less than a single Intel NUC, and you get hardware redundancy that no single mini PC can provide regardless of its specs.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How much RAM do I need for a homelab mini PC?
&lt;/h3&gt;

&lt;p&gt;16GB is the practical minimum if you are running Proxmox with more than one VM or container stack. For K3s with a few worker pods and monitoring (Prometheus, Grafana), 32GB gives you breathing room. 64GB only makes sense if you are running local LLM inference or multiple heavy databases. The N100 platforms max out at 32GB; Ryzen and Intel i5 platforms support 64GB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I care about DDR4 vs DDR5 in a mini PC?
&lt;/h3&gt;

&lt;p&gt;For N100 chips, the difference is negligible — the N100's memory bandwidth ceiling is low enough that DDR4 and DDR5 perform identically in practice. DDR5 matters on the Ryzen 7 and Intel i5 platforms where the memory controller can actually use the extra bandwidth. The real DDR5 advantage is availability: DDR5 SO-DIMMs are easier to find at 32GB and 48GB capacities now, making future upgrades simpler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a mini PC replace a full-size server for homelab use?
&lt;/h3&gt;

&lt;p&gt;For 80% of homelab use cases, yes. A single Ryzen 7 mini PC with 32GB RAM can run Proxmox with 5-8 VMs, a K3s cluster, monitoring, and local storage — enough for most self-hosting setups. What it cannot replace: high-storage NAS (you still need separate disks), GPU-accelerated workloads (no discrete GPU), and high-availability clusters (you need at least 2-3 nodes for proper failover).&lt;/p&gt;

&lt;h3&gt;
  
  
  What about noise levels?
&lt;/h3&gt;

&lt;p&gt;N100 boxes (BMAX, Trigkey) are nearly silent at idle and quiet under moderate load — you will not hear them from a meter away. The Beelink SER5 and GMKTec K6 have audible fans under sustained load; the K6 is the loudest of the group. If noise is a primary concern, the BMAX B1 Pro or Intel NUC 13 Pro are the quietest options with meaningful compute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I buy one powerful mini PC or multiple cheaper ones?
&lt;/h3&gt;

&lt;p&gt;Multiple cheaper ones, almost always. A single point of failure is a single point of failure, regardless of how powerful it is. Two BMAX B1 Pro units (€360 total) give you hardware redundancy and the ability to migrate VMs between hosts during maintenance. The only exception: if you need GPU compute for local LLMs, in which case a single GMKTec K6 with its Radeon 680M is the minimum viable platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do these mini PCs support Proxmox well?
&lt;/h3&gt;

&lt;p&gt;All of them work with Proxmox. The Intel NUC 13 Pro has the smoothest experience thanks to the Intel i226-V NIC and full kernel support. The N100 boxes work fine with Proxmox but may need the &lt;code&gt;r8169&lt;/code&gt; driver tuned for the Realtek NIC under heavy bridged traffic. Ryzen platforms (Beelink, GMKTec) work well out of the box. I have personally run Proxmox on all five of these platforms without blocking issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about the Raspberry Pi 5 as a homelab node?
&lt;/h3&gt;

&lt;p&gt;The Raspberry Pi 5 is a legitimate homelab option — I run two in my own lab for edge DNS and Keepalived — but it does not compete with mini PCs for compute-intensive workloads. The Pi 5's ARM Cortex-A76 cores are roughly 40% slower than an N100 in single-threaded work and lack the x86 instruction set compatibility that Proxmox and many Docker images assume. For lightweight services, the Pi 5 wins on power efficiency (idle ~3-4W). For anything beyond DNS, reverse proxy, and monitoring, a mini PC is the better investment.&lt;/p&gt;

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

&lt;p&gt;The mini PC market for homelab use in 2026 is excellent. There has never been a better time to buy: even the €150 budget option gives you enough compute to run a serious self-hosted stack. The choice comes down to your workload profile and how much you value power efficiency versus raw performance.&lt;/p&gt;

&lt;p&gt;For my money, the BMAX B1 Pro at €180 is the default answer. It runs 24/7, sips power, and handles everything I throw at it. But if you are building a serious lab with local AI, multiple VM clusters, and heavy CI workloads, the GMKTec K6 at €350 is the machine that grows with you.&lt;/p&gt;

&lt;p&gt;Whatever you pick: buy two. Redundancy beats raw performance every time in a homelab.&lt;/p&gt;




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

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

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

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




</description>
      <category>hardware</category>
      <category>homelab</category>
      <category>review</category>
    </item>
    <item>
      <title>Zero NetworkPolicies on Vault: How I Found the Biggest Gap in My Cluster and a GitOps Tracking Bug That Hid It</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 19 Jul 2026 10:43:16 +0000</pubDate>
      <link>https://dev.to/dwoitzik/zero-networkpolicies-on-vault-how-i-found-the-biggest-gap-in-my-cluster-and-a-gitops-tracking-bug-7kb</link>
      <guid>https://dev.to/dwoitzik/zero-networkpolicies-on-vault-how-i-found-the-biggest-gap-in-my-cluster-and-a-gitops-tracking-bug-7kb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/vault-zero-networkpolicy-argocd-tracking-gap/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;A cross-cutting isolation check across every namespace in my k3s cluster revealed that Vault — the trust root every ExternalSecret reads from — had zero NetworkPolicies. Any pod in any namespace, including a compromised Immich or a misconfigured application, could reach Vault's API on port 8200 with no network-layer restriction.&lt;/p&gt;

&lt;p&gt;The fix was straightforward. The investigation that followed uncovered a second, more insidious problem: a class of ArgoCD drift where merged git changes silently do nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Finding
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get networkpolicy &lt;span class="nt"&gt;-n&lt;/span&gt; vault
&lt;span class="c"&gt;# No resources found in vault namespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every other sensitive namespace had at least a basic ingress policy. Vault had nothing. The trust root — the service that every &lt;code&gt;ExternalSecret&lt;/code&gt; in the cluster resolves secrets from — was wide open at the network layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Matters
&lt;/h3&gt;

&lt;p&gt;The blast radius of a Vault compromise is the entire cluster. Every &lt;code&gt;ExternalSecret&lt;/code&gt; references a &lt;code&gt;ClusterSecretStore&lt;/code&gt; pointing at Vault. If Vault is compromised, every secret the cluster trusts is compromised. Network segmentation is the first line of defense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping the Real Traffic Pattern
&lt;/h2&gt;

&lt;p&gt;Before writing any policy, I mapped who actually talks to Vault:&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;# Who's calling Vault right now?&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; vault vault-0 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;cat&lt;/span&gt; /vault/logs/audit-backend.file-c-audit.log | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.request.connection.remoteAddress'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was simpler than expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;external-secrets namespace&lt;/strong&gt; — the External Secrets Operator's central controller calls Vault via the &lt;code&gt;ClusterSecretStore&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vault namespace&lt;/strong&gt; — &lt;code&gt;vault-unseal&lt;/code&gt; and &lt;code&gt;vault-0&lt;/code&gt; (same-namespace communication)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No other pod in the cluster calls Vault directly. ESO resolves centrally and writes plain Kubernetes Secrets into each app's namespace. So the policy only needs to allow cross-namespace access from &lt;code&gt;external-secrets&lt;/code&gt; — not from every app namespace.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/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-external-secrets&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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="c1"&gt;# all pods in vault namespace&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="s"&gt;Ingress&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Allow External Secrets Operator to reach Vault API&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="na"&gt;kubernetes.io/metadata.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;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
    &lt;span class="c1"&gt;# Allow same-namespace (vault-unseal, vault-0)&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;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;code&gt;external-secrets&lt;/code&gt; is the only cross-namespace caller. Individual app pods never touch Vault — ESO handles it centrally. This means the policy is narrow by design, not by guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live-Tested Before Committing
&lt;/h2&gt;

&lt;p&gt;This isn't a "write and hope" policy. I tested it live with selfHeal temporarily disabled:&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;# 1. Delete a live ExternalSecret's backing Secret&lt;/span&gt;
kubectl delete secret renovate-token &lt;span class="nt"&gt;-n&lt;/span&gt; external-secrets

&lt;span class="c"&gt;# 2. Verify ESO recreates it through the new policy&lt;/span&gt;
kubectl get secret renovate-token &lt;span class="nt"&gt;-n&lt;/span&gt; external-secrets
&lt;span class="c"&gt;# → recreated within 5s — proves ESO→Vault traffic works&lt;/span&gt;

&lt;span class="c"&gt;# 3. Verify a pod OUTSIDE the allowed namespaces is blocked&lt;/span&gt;
kubectl run test-pod &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  wget &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-O-&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 http://vault.vault.svc:8200/v1/sys/health
&lt;span class="c"&gt;# → timeout — proves the policy actually restricts&lt;/span&gt;

&lt;span class="c"&gt;# 4. Restore selfHeal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy works in both directions: legitimate traffic flows, unauthorized traffic is blocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Problem: ArgoCD Tracking Gap
&lt;/h2&gt;

&lt;p&gt;While investigating the Vault namespace, I found that &lt;code&gt;kubernetes/apps/network-policies.yml&lt;/code&gt; and &lt;code&gt;traefik-websockets.yml&lt;/code&gt; were loose files directly under &lt;code&gt;kubernetes/apps/&lt;/code&gt; — not in subdirectories.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;homelab-apps&lt;/code&gt; ApplicationSet generates Applications per &lt;strong&gt;subdirectory&lt;/strong&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;# homelab-apps ApplicationSet&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;generators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;directories&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes/apps/*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loose files under &lt;code&gt;kubernetes/apps/&lt;/code&gt; are never picked up. They're not subdirectories. No Application watches them.&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 if ArgoCD tracks these files&lt;/span&gt;
kubectl get applications &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.items[] | select(.spec.source.path | startswith("kubernetes/apps/")) | .spec.source.path'&lt;/span&gt;
&lt;span class="c"&gt;# → kubernetes/apps/*/ (only subdirectories)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Drift
&lt;/h3&gt;

&lt;p&gt;These files had &lt;code&gt;kubectl.kubernetes.io/last-applied-configuration&lt;/code&gt; annotations — they were applied by hand at some point, never reconciled against git since. Real drift had already happened:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;network-policies.yml&lt;/code&gt; still excluded Atlantis from &lt;code&gt;allow-intra-namespace&lt;/code&gt; even though Atlantis moved to its own LXC months ago&lt;/li&gt;
&lt;li&gt;A stale &lt;code&gt;allow-atlantis-ingress&lt;/code&gt; policy matched zero live pods — dead code, never pruned&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Fix: Self-Watching Application
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/apps/gitops-tracking-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;gitops-tracking&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;project&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;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://github.com/dwoitzik/homelab-infrastructure.git&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;kubernetes/apps&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;recurse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;exclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*/"&lt;/span&gt;
  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;selfHeal&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;This Application watches &lt;code&gt;kubernetes/apps/&lt;/code&gt; itself — including loose files. It's the same pattern as &lt;code&gt;monitoring/manifests-application.yml&lt;/code&gt;, but applied to the apps directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; This file includes itself in its own source path. That's intentional — it's a self-watching bootstrap. The first sync reconciles all previously orphaned files at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Orphaned Bootstrap Class
&lt;/h2&gt;

&lt;p&gt;This isn't just about two files. The sweep found &lt;strong&gt;18 of 41 live Applications&lt;/strong&gt; with their defining &lt;code&gt;.yml&lt;/code&gt; file untracked by any parent Application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kubernetes/system/*/  →  no ApplicationSet, no recurse generator
kubernetes/apps/*.yml →  ApplicationSet only watches subdirectories
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;kubernetes/apps/*&lt;/code&gt;, the subdirectory generator covers everything inside directories. But loose files at the root level fall through. For &lt;code&gt;kubernetes/system/*&lt;/code&gt;, nothing watches individual Application files at all — each system app is manually &lt;code&gt;kubectl apply&lt;/code&gt;'d.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Pattern
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# How to find orphaned Applications in any ArgoCD cluster&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;app &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;kubectl get applications &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'&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;ns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$app&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$app&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get application &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$ns&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.source.path}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;# Check if any generator covers this path&lt;/span&gt;
  &lt;span class="nv"&gt;covers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get applicationsets &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | &lt;span class="se"&gt;\&lt;/span&gt;
    jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;".items[].spec.generators[].directories[].path"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$covers&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ORPHANED: &lt;/span&gt;&lt;span class="nv"&gt;$ns&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;1. Network policies should be based on real traffic patterns, not assumptions.&lt;/strong&gt; I didn't guess which pods talk to Vault — I checked the audit logs. The result was narrower than expected: only &lt;code&gt;external-secrets&lt;/code&gt; needs cross-namespace access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Test policies live before committing.&lt;/strong&gt; Delete a Secret, confirm ESO recreates it. Run a test pod, confirm it's blocked. This catches both over-permissive and over-restrictive policies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. ArgoCD ApplicationSets have blind spots.&lt;/strong&gt; Directory generators don't watch loose files. If you have files directly under the watched path (not in subdirectories), they're orphaned. Either move them into subdirectories or create a tracking Application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Orphaned files drift silently.&lt;/strong&gt; The git edit happens, gets merged, and everyone assumes ArgoCD picked it up. It didn't. The only way to catch this is to audit which files are actually watched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; For understanding the full blast radius of a Vault compromise, &lt;a href="https://amzn.to/3QdWfkH" rel="noopener noreferrer"&gt;Zero Trust Networks by Evan Gilman and Doug Barth*&lt;/a&gt; covers the network segmentation principles behind "never trust, always verify." The ArgoCD tracking gap is a common GitOps blind spot — &lt;a href="https://amzn.to/4aUtkCs" rel="noopener noreferrer"&gt;GitOps and Kubernetes by Billy Yuen et al.*&lt;/a&gt; covers ApplicationSet generators and their failure modes in depth.&lt;/p&gt;







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

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

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

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




&lt;p&gt;&lt;em&gt;Vault is the trust root. If its namespace has no NetworkPolicies, you've built zero-trust everywhere except the one place that matters most. And if your GitOps tool isn't watching the files you think it's watching, the "git is the source of truth" guarantee doesn't apply to those files.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>gitops</category>
      <category>vault</category>
    </item>
    <item>
      <title>Renovate OOMKilled Three Times: Why the Fix Wasn't More Memory</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 17 Jul 2026 10:01:45 +0000</pubDate>
      <link>https://dev.to/dwoitzik/renovate-oomkilled-three-times-why-the-fix-wasnt-more-memory-14oh</link>
      <guid>https://dev.to/dwoitzik/renovate-oomkilled-three-times-why-the-fix-wasnt-more-memory-14oh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/renovate-oomkilled-terraform-hash-concurrency/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Renovate kept getting OOMKilled on the &lt;a href="https://amzn.to/4bv3yF1" rel="noopener noreferrer"&gt;BMAX Mini PC*&lt;/a&gt; running my entire k3s cluster. The Kubernetes CronJob would start, run for a while, and then die with exit code 137. The natural assumption: not enough memory. Bump the limit. Problem solved.&lt;/p&gt;

&lt;p&gt;Three rounds of bumps later — 2 GiB, then 3 GiB, then 4 GiB — it was still dying. The fix had nothing to do with memory limits. It had to do with what Renovate was actually doing with the memory it had.&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 Escalation
&lt;/h2&gt;

&lt;p&gt;Round 1: the CronJob was OOMKilled at 2 GiB. Every job, 3 retries each, all dying. &lt;code&gt;kubectl describe pod&lt;/code&gt; confirmed exit code 137, reason &lt;code&gt;OOMKilled&lt;/code&gt;. Bumped to 3 GiB container limit / 2304Mi V8 heap, keeping the same ~25% non-heap headroom ratio as the previous configuration.&lt;/p&gt;

&lt;p&gt;Round 2: OOMKilled again at 3 GiB. Same pattern. Checked for regressions — no commits landed between the last clean run and the first failure that meaningfully grew the dependency count. The internal-docs relocation merged after the failures started, and Renovate doesn't scan &lt;code&gt;.md&lt;/code&gt; files anyway. Read as ordinary run-to-run variance tipping an already-marginal limit over, not a new regression.&lt;/p&gt;

&lt;p&gt;Round 3: bumped to 4 GiB. This time, it landed at 3 GiB instead of 4 GiB — the edit was described but never actually made before merge. A separate PR corrected it to the intended 3 GiB → 4 GiB / 2304Mi → 3072Mi heap.&lt;/p&gt;

&lt;p&gt;Still OOMKilled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Diagnostic Run
&lt;/h2&gt;

&lt;p&gt;At this point, guessing at memory limits wasn't working. The approach shifted to isolating the spike instead of outrunning it.&lt;/p&gt;

&lt;p&gt;I ran Renovate off-cluster in an isolated diagnostic run with 12 GiB of headroom, a real GitHub token, real branches, and user authorization. The goal: measure the actual peak instead of guessing at it.&lt;/p&gt;

&lt;p&gt;The diagnostic run finished clean at exit 0. Peak memory usage: &lt;strong&gt;3.99 GiB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That confirmed this was never unbounded growth. The 2 GiB and then 4 GiB production kills both landed mid-spike, ~30-40 MiB past the ceiling. The cgroup limit sat too close to a single transient spike for GC to reclaim before the kill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause: Terraform Hash Concurrency
&lt;/h2&gt;

&lt;p&gt;The spike wasn't random. It was Renovate's Terraform manager SHA256-hashing all 14 platform builds of a major &lt;code&gt;hashicorp/aws&lt;/code&gt; provider bump (5.x → 6.53.0) to keep &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; valid.&lt;/p&gt;

&lt;p&gt;At the default &lt;code&gt;concurrentRequestLimit&lt;/code&gt; of 16, that's up to 16 provider zips buffered in memory simultaneously — each one a large binary being hashed for lock-file integrity. The spike is predictable: any Terraform provider upgrade that touches multiple platforms hits this.&lt;/p&gt;

&lt;p&gt;The fix wasn't more memory. It was flattening the spike at the source:&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;"terraform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"concurrentRequestLimit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cap concurrent Terraform hash operations at 2 for &lt;code&gt;releases.hashicorp.com&lt;/code&gt;. The spike shrinks because fewer provider zips are buffered simultaneously. The 4 GiB limit becomes genuine headroom instead of a near-miss ceiling, and the 6 GiB / 4608Mi heap becomes defense in depth — sized from the actual measured 3.99 GiB peak with real GC margin, not a blind guess.&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;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6Gi&lt;/span&gt;
  &lt;span class="na"&gt;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;V8_FLAG_INITIAL_HEAP_SIZE&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;4608"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CronJob stays suspended — this fix doesn't re-enable it. That's a separate decision once the fix is reviewed and validated in production.&lt;/p&gt;

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

&lt;p&gt;The first two bumps were the obvious response to OOMKilled: more memory. They were also wrong, because they treated the symptom (process needs more memory) instead of the cause (process is buffering too many large objects simultaneously).&lt;/p&gt;

&lt;p&gt;The diagnostic run with 12 GiB of headroom was the turning point. Instead of guessing at limits, I isolated the real peak. 3.99 GiB. Then the question became: why is the peak 3.99 GiB, and can I flatten it instead of accommodating it?&lt;/p&gt;

&lt;p&gt;&lt;code&gt;concurrentRequestLimit=2&lt;/code&gt; flattens it. The memory limit becomes a safety net, not the fix.&lt;/p&gt;

&lt;p&gt;This is the same pattern as tuning Kubernetes resource requests: you don't set requests based on what the app uses at peak, you set them based on what it needs to function normally, and you use limits as a ceiling for the spikes. In this case, the "normal" was far below the spike, and the spike was artificially inflated by a configuration knob nobody had touched.&lt;/p&gt;




&lt;p&gt;Right-sizing infrastructure isn't just a Kubernetes problem — Azure VM sizing, AKS node pool autoscaling, and Cosmos DB RU allocation all face the same trap: responding to peak usage with more capacity instead of understanding what's driving the peak.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Discord Voice Choppy? It Was Bufferbloat — Fixed with 51 Lines of Terraform</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 17 Jul 2026 09:58:12 +0000</pubDate>
      <link>https://dev.to/dwoitzik/discord-voice-choppy-it-was-bufferbloat-fixed-with-51-lines-of-terraform-d76</link>
      <guid>https://dev.to/dwoitzik/discord-voice-choppy-it-was-bufferbloat-fixed-with-51-lines-of-terraform-d76</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/mikrotik-wan-qos-bufferbloat-discord/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Discord voice was choppy. Not for me hearing others — for others hearing me. Robotic, garbled audio that came and went. Confirmed clean over mobile data, which meant the problem was my home network path, not Discord or the ISP.&lt;/p&gt;

&lt;p&gt;The investigation led to a textbook bufferbloat scenario on the &lt;a href="https://amzn.to/4w08b21" rel="noopener noreferrer"&gt;MikroTik RB5009*&lt;/a&gt;: zero QoS or AQM on the WAN interface, a 50 Mbit upload ceiling that's trivially easy to saturate, and RouterOS's default &lt;code&gt;pfifo&lt;/code&gt; queue doing nothing to prevent one bulk transfer from monopolizing the link against latency-sensitive UDP voice traffic.&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 Investigation
&lt;/h2&gt;

&lt;p&gt;First step: check what's actually running on the WAN. &lt;code&gt;/queue simple&lt;/code&gt; and &lt;code&gt;/queue tree&lt;/code&gt; were both empty. The MikroTik RB5009 was falling back to RouterOS's default &lt;code&gt;pfifo&lt;/code&gt; (plain FIFO, no active queue management) on &lt;code&gt;ether1&lt;/code&gt; (WAN).&lt;/p&gt;

&lt;p&gt;ISP profile is asymmetric cable: 1000 Mbit down, 50 Mbit up. Real measured value via repeated live speedtests — not taken from MySpeed's configured target, which happened to match but wasn't the authoritative source. A 50 Mbit upload ceiling is easy to saturate, and with no AQM, nothing stops the router's own send buffer from filling and inflating latency for everything sharing the link.&lt;/p&gt;

&lt;p&gt;The key question: what's actually saturating the upload? I traced the WAN-upload consumers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Garage/PBS backups&lt;/strong&gt;: LAN-local, don't cross WAN. Ruled out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PBS Google Drive offsite sync&lt;/strong&gt;: Already disabled (REL-051). Ruled out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image pulls&lt;/strong&gt;: Downloads, not uploads. Ruled out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare-tunneled remote Jellyfin/Immich streaming&lt;/strong&gt;: The realistic WAN-upload consumer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the traffic profile: mostly latency-insensitive bulk uploads (media streaming through Cloudflare), with Discord's UDP voice flow competing for the same 50 Mbit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Simple Queues, Not Queue Trees
&lt;/h2&gt;

&lt;p&gt;The obvious RouterOS QoS approach is mangle-mark + queue-tree: mark packets by type, apply queue-tree rules to the marks. The problem: &lt;strong&gt;fasttrack is enabled globally&lt;/strong&gt; (see &lt;code&gt;firewall_deterministic.tf&lt;/code&gt;), and fasttrack bypasses mangle marking for established connections. A mangle-mark + queue-tree design would silently stop protecting most real traffic — the very traffic that needs protection.&lt;/p&gt;

&lt;p&gt;An address-based Simple Queue sidesteps this entirely. It enforces at the interface's queuing layer, independent of fasttrack and marks. The &lt;code&gt;target&lt;/code&gt; is a plain IP-range match, no mangle dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: PCQ Per-Flow Fairness
&lt;/h2&gt;

&lt;p&gt;The actual fix is 51 lines of Terraform, with the comment block doing most of the heavy lifting:&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/qos.tf&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_queue_simple"&lt;/span&gt; &lt;span class="s2"&gt;"wan_egress_sqm"&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;"wan-egress-sqm"&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;max_limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"45M/900M"&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;"pcq-upload-default/pcq-download-default"&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;"SQM: cap WAN link under real measured ISP ceiling (1000/50 Mbit) + PCQ per-flow fairness so one bulk transfer can't fill the buffer and starve latency-sensitive traffic (Discord voice bufferbloat fix)"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;max_limit = "45M/900M"&lt;/code&gt; — 90% of the real ceiling, not 100%.&lt;/strong&gt; Capping below the ISP's own ceiling keeps this router's queue as the actual bottleneck instead of the ISP modem's. Without that margin, the real bottleneck (and its buffer) sits inside the ISP modem, outside this queue's control, and bufferbloat would still happen there instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;queue = "pcq-upload-default/pcq-download-default"&lt;/code&gt; — per-flow fairness.&lt;/strong&gt; PCQ round-robins per-flow, so one bulk transfer (the Cloudflare-tunneled Jellyfin/Immich streaming) gets its own sub-queue instead of monopolizing the link against Discord's UDP voice flow. This is the standard RouterOS-native SQM approach when CAKE/fq_codel isn't available — I checked this RouterOS build's queue types (&lt;code&gt;/queue type print&lt;/code&gt;): only &lt;code&gt;pfifo&lt;/code&gt;/&lt;code&gt;bfifo&lt;/code&gt;/&lt;code&gt;red&lt;/code&gt;/&lt;code&gt;sfq&lt;/code&gt;/&lt;code&gt;pcq&lt;/code&gt;/&lt;code&gt;mq-pfifo&lt;/code&gt;. No CAKE.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;target = ["10.0.0.0/16"]&lt;/code&gt; — all VLANs in one queue.&lt;/strong&gt; Deliberately not split per-VLAN yet. The goal right now is fixing the shared WAN bottleneck, not per-VLAN prioritization. All five VLANs (10.0.10-100.0/24) share this WAN link.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not mangle + queue-tree
&lt;/h2&gt;

&lt;p&gt;This deserves emphasis because it's the gotcha that would make this fix silently do nothing:&lt;/p&gt;

&lt;p&gt;Fasttrack is enabled globally on this router. Fasttrack processes established connections in the fast path, bypassing the mangle chain entirely. If I'd used mangle rules to mark packets and a queue-tree to shape them, the marking would never happen for established connections — which is every real traffic flow after the first packet. The queue-tree would only affect new, short-lived flows, not the sustained bulk transfers that cause bufferbloat.&lt;/p&gt;

&lt;p&gt;The Simple Queue approach works because it operates at the interface queuing layer, not the packet marking layer. It doesn't care whether fasttrack is enabled — it applies to all traffic on the target regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation
&lt;/h2&gt;

&lt;p&gt;Terraform: &lt;code&gt;fmt -check&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt;, &lt;code&gt;tflint&lt;/code&gt; all clean. The resource is straightforward — one &lt;code&gt;routeros_queue_simple&lt;/code&gt; with a comment that explains the reasoning.&lt;/p&gt;

&lt;p&gt;Not applied yet. Opening as a PR for &lt;code&gt;atlantis plan&lt;/code&gt; review per my explicit requirement to see the plan before any apply on the network stack. This is the gateway router — a misconfiguration here affects every device in the apartment.&lt;/p&gt;




&lt;p&gt;QoS at the network edge — bufferbloat mitigation, per-flow fairness, bandwidth shaping — is the same principle as Azure's traffic shaping with NSGs, Application Gateway rate limiting, and Azure Front Door priority routing. The tools differ, the problem is identical: latency-sensitive traffic competing with bulk transfers on a shared link.&lt;/p&gt;



</description>
      <category>networking</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Kubernetes Health Probes: The Host Header Trap That Restarts Healthy Pods</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 17 Jul 2026 09:54:04 +0000</pubDate>
      <link>https://dev.to/dwoitzik/kubernetes-health-probes-the-host-header-trap-that-restarts-healthy-pods-2m87</link>
      <guid>https://dev.to/dwoitzik/kubernetes-health-probes-the-host-header-trap-that-restarts-healthy-pods-2m87</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/kubernetes-health-probes-host-header-trap/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On a &lt;a href="https://amzn.to/4bv3yF1" rel="noopener noreferrer"&gt;BMAX Mini PC*&lt;/a&gt; running 20+ workloads across a three-node k3s cluster, I had about 20 long-lived services running without a single health probe. A process that hangs — not crashes, but stops responding — would go undetected forever. Kubernetes would keep routing traffic to an unresponsive pod because there's no mechanism to say "this pod is alive but broken."&lt;/p&gt;

&lt;p&gt;The fix sounded simple: add &lt;code&gt;livenessProbe&lt;/code&gt; and &lt;code&gt;readinessProbe&lt;/code&gt; to every deployment. The reality was six batches of probes, each live-tested against the running pods, and a gotcha on batch one that restarted a healthy pod because of a host-header mismatch nobody warned me about.&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 Host Header Trap
&lt;/h2&gt;

&lt;p&gt;Homepage was batch one. I added a standard &lt;code&gt;httpGet&lt;/code&gt; probe against &lt;code&gt;/api/healthcheck&lt;/code&gt; on port 3000. Curled it manually from a busybox pod: &lt;code&gt;200&lt;/code&gt;, probe works. Deployed it. Pod immediately entered a restart loop.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl describe pod&lt;/code&gt; showed 10 readiness failures and 1 liveness failure. The probe was failing because &lt;code&gt;HOMEPAGE_ALLOWED_HOSTS&lt;/code&gt; is set to &lt;code&gt;home.woitzik.dev&lt;/code&gt; plus loopback — a security setting that prevents host-header-based attacks. When kubelet sends an &lt;code&gt;httpGet&lt;/code&gt; probe, it sends the Pod IP as the &lt;code&gt;Host&lt;/code&gt; header. Homepage's Next.js host-validation middleware sees &lt;code&gt;10.0.20.147&lt;/code&gt; in the Host header, it's not in the allowlist, it returns &lt;code&gt;400&lt;/code&gt;. Kubelet treats &lt;code&gt;400&lt;/code&gt; as failure.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;Host&lt;/code&gt; header override on the probe, not loosening the allowlist:&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;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/healthcheck&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;3000&lt;/span&gt;
    &lt;span class="na"&gt;httpHeaders&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;Host&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/healthcheck&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;3000&lt;/span&gt;
    &lt;span class="na"&gt;httpHeaders&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;Host&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same issue hit Nextcloud (&lt;code&gt;NEXTCLOUD_TRUSTED_DOMAINS&lt;/code&gt; only permits &lt;code&gt;nextcloud.woitzik.dev&lt;/code&gt;) and Paperless (&lt;code&gt;PAPERLESS_ALLOWED_HOSTS&lt;/code&gt; is an explicit domain/IP allowlist). All three needed the &lt;code&gt;Host: localhost&lt;/code&gt; override. Without it, the probes fail on a security feature doing exactly what it's supposed to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app has any host-header validation, your kubelet probes will fail without this override.&lt;/strong&gt; This is the single most common gotcha in health probe configuration, and almost no tutorial mentions it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch 1: Authelia, Garage, Vaultwarden
&lt;/h2&gt;

&lt;p&gt;Started with the three highest-blast-radius services where the probe endpoint could be confirmed with high confidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authelia&lt;/strong&gt;: &lt;code&gt;/api/health&lt;/code&gt; on port 9091, returns &lt;code&gt;{"status":"OK"}&lt;/code&gt;. Already the exact endpoint blackbox-exporter scrapes externally, so this was a known-good path, not a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Garage&lt;/strong&gt;: TCP-only on the S3 port. No &lt;code&gt;wget&lt;/code&gt; or &lt;code&gt;curl&lt;/code&gt; in the image to verify an HTTP health path against live first. TCP probe it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vaultwarden&lt;/strong&gt;: Same — TCP-only, no HTTP health endpoint available in the image.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Authelia — HTTP probe, known-good endpoint&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/health&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9091&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="c1"&gt;# Garage — TCP probe, no HTTP healthz in the image&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tcpSocket&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;2335&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="c1"&gt;# Vaultwarden — TCP probe, same reasoning&lt;/span&gt;
&lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tcpSocket&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;8080&lt;/span&gt;
  &lt;span class="na"&gt;initialDelaySeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;periodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three were live-tested by temporarily pausing ArgoCD's selfHeal, applying the probes, confirming &lt;code&gt;READY 1/1&lt;/code&gt; with zero restarts, then restoring selfHeal. Not assumed, not deployed blind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch 2: Cloudflared, Gitea, Headscale, Home Assistant, Homepage
&lt;/h2&gt;

&lt;p&gt;Each endpoint was tested before writing the probe via port-forward or in-container curl against the running pod:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;cloudflared&lt;/strong&gt;: &lt;code&gt;/ready&lt;/code&gt; on its metrics port (20241). Reports actual tunnel connection count — catches a process that's alive but has lost its tunnel to Cloudflare's edge. Required adding &lt;code&gt;--metrics 0.0.0.0:20241&lt;/code&gt; so kubelet can reach it from outside the pod network namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gitea&lt;/strong&gt;: &lt;code&gt;/api/healthz&lt;/code&gt; → &lt;code&gt;{"status":"pass"}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;headscale&lt;/strong&gt;: &lt;code&gt;/health&lt;/code&gt; on the HTTP port → &lt;code&gt;{"status":"pass"}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;home-assistant&lt;/strong&gt;: &lt;code&gt;/manifest.json&lt;/code&gt; — public, no auth token needed unlike &lt;code&gt;/api/*&lt;/code&gt;, returns 200 once the frontend is serving&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;homepage&lt;/strong&gt;: &lt;code&gt;/api/healthcheck&lt;/code&gt; → &lt;code&gt;"up"&lt;/code&gt; (with the &lt;code&gt;Host: localhost&lt;/code&gt; override from the gotcha above)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Generous &lt;code&gt;initialDelaySeconds&lt;/code&gt; and &lt;code&gt;failureThreshold&lt;/code&gt; on each to avoid flapping during normal startup. Home Assistant especially can take 30+ seconds to fully initialize its integrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch 3: Keel, Mealie, MySpeed, SearXNG, Uptime Kuma
&lt;/h2&gt;

&lt;p&gt;All tested via Pod IP using a temporary busybox pod or a same-namespace pod's &lt;code&gt;wget&lt;/code&gt; — not &lt;code&gt;localhost&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;keel&lt;/strong&gt;: &lt;code&gt;/healthz&lt;/code&gt; → 200, empty body&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mealie&lt;/strong&gt;: &lt;code&gt;/api/app/about&lt;/code&gt; → public JSON, no auth needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;myspeed&lt;/strong&gt;: &lt;code&gt;/&lt;/code&gt; → 200 frontend HTML&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;searxng&lt;/strong&gt;: &lt;code&gt;/&lt;/code&gt; → 200 search page HTML, no host-validation issue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;uptime-kuma&lt;/strong&gt;: &lt;code&gt;/&lt;/code&gt; → 302 redirect to &lt;code&gt;/dashboard&lt;/code&gt; then 200. Kubelet's httpGet probe treats any &lt;code&gt;200 &amp;lt;= code &amp;lt; 400&lt;/code&gt; as success, so the redirect itself passes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson from batch one: always test probes via Pod IP, not &lt;code&gt;localhost&lt;/code&gt;. A probe that works with &lt;code&gt;curl localhost:3000&lt;/code&gt; inside the container might fail when kubelet hits the Pod IP, because the app sees a different Host header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch 4: Immich Stack
&lt;/h2&gt;

&lt;p&gt;Immich had zero probes across all four workloads — postgres, valkey, server, and ml. One logical stack, so all four fixed together:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;immich-postgres&lt;/strong&gt;: &lt;code&gt;pg_isready -U immich&lt;/code&gt; → accepting connections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;immich-valkey&lt;/strong&gt;: &lt;code&gt;valkey-cli ping&lt;/code&gt; → PONG&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;immich-server&lt;/strong&gt;: &lt;code&gt;/api/server/ping&lt;/code&gt; → &lt;code&gt;{"res":"pong"}&lt;/code&gt;, no auth required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;immich-ml&lt;/strong&gt;: &lt;code&gt;/ping&lt;/code&gt; → &lt;code&gt;pong&lt;/code&gt;. Longer &lt;code&gt;initialDelaySeconds&lt;/code&gt; since CLIP and face-detection models load into memory on startup before this endpoint responds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Batch 5: Nextcloud, Paperless, Open-WebUI
&lt;/h2&gt;

&lt;p&gt;The final batch, same Host header gotcha as Homepage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;postgres-nextcloud&lt;/strong&gt;: &lt;code&gt;pg_isready&lt;/code&gt; → accepting connections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redis-nextcloud&lt;/strong&gt;: &lt;code&gt;redis-cli ping&lt;/code&gt; → PONG&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nextcloud&lt;/strong&gt;: &lt;code&gt;/status.php&lt;/code&gt;, &lt;code&gt;Host: nextcloud.woitzik.dev&lt;/code&gt; override (NEXTCLOUD_TRUSTED_DOMAINS only permits that domain)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;postgres-paperless&lt;/strong&gt;: &lt;code&gt;pg_isready&lt;/code&gt; → accepting connections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;redis-paperless&lt;/strong&gt;: &lt;code&gt;redis-cli ping&lt;/code&gt; → PONG&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;paperless&lt;/strong&gt;: &lt;code&gt;/api/&lt;/code&gt;, &lt;code&gt;Host: docs.woitzik.dev&lt;/code&gt; override. Returns &lt;code&gt;302&lt;/code&gt; to &lt;code&gt;schema/view/&lt;/code&gt; which is a valid pass (&lt;code&gt;200 &amp;lt;= code &amp;lt; 400&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;paperless-gotenberg&lt;/strong&gt;: &lt;code&gt;/health&lt;/code&gt; → real component-status JSON&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;paperless-tika&lt;/strong&gt;: &lt;code&gt;/&lt;/code&gt; → 200 index page (no lighter healthz in this version)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;open-webui&lt;/strong&gt;: &lt;code&gt;/health&lt;/code&gt; → &lt;code&gt;{"status":true}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;paperless-gpt&lt;/strong&gt;: &lt;code&gt;/&lt;/code&gt; → 200 frontend HTML. No k8s Service exists for this one, but probes work via Pod IP regardless.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Every probe was live-tested against the actual running pod before committing. Not guessed from docs, not assumed from README files. The endpoint, the expected response, and the Host header behavior were all confirmed against the real deployment.&lt;/p&gt;

&lt;p&gt;For PostgreSQL instances, &lt;code&gt;pg_isready&lt;/code&gt; is the canonical liveness probe. For Redis/Valkey, &lt;code&gt;redis-cli ping&lt;/code&gt; or &lt;code&gt;valkey-cli ping&lt;/code&gt;. For HTTP services, find the lightest public endpoint that doesn't require authentication. If the app validates Host headers, add the override — don't widen the allowlist.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;initialDelaySeconds&lt;/code&gt; and &lt;code&gt;failureThreshold&lt;/code&gt; values vary per service. Home Assistant needs 30+ seconds. Immich ML needs time for model loading. Authelia is fast. Copy-pasting the same probe config across all deployments is how you get false-positive restart loops.&lt;/p&gt;




&lt;p&gt;Health probes in Kubernetes are the same principle as Azure's health monitoring: Application Gateway health probes, AKS node health, Cosmos DB connection validation — all require understanding what "healthy" means for the specific service, not a generic HTTP 200 check.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Zero NetworkPolicies on the Database Namespace: The Gap That Let Any Pod Reach Authelia's Postgres</title>
      <dc:creator>david</dc:creator>
      <pubDate>Fri, 17 Jul 2026 09:53:29 +0000</pubDate>
      <link>https://dev.to/dwoitzik/zero-networkpolicies-on-the-database-namespace-the-gap-that-let-any-pod-reach-authelias-postgres-1896</link>
      <guid>https://dev.to/dwoitzik/zero-networkpolicies-on-the-database-namespace-the-gap-that-let-any-pod-reach-authelias-postgres-1896</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/kubernetes-database-namespace-network-policies/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After locking down Vault's namespace with NetworkPolicies, I ran the same check on every other namespace. Everything runs on a &lt;a href="https://amzn.to/4bv3yF1" rel="noopener noreferrer"&gt;BMAX Mini PC*&lt;/a&gt; behind a &lt;a href="https://amzn.to/4w08b21" rel="noopener noreferrer"&gt;MikroTik RB5009*&lt;/a&gt; — one misconfigured NetworkPolicy and the wrong pod reaches Authelia's database. &lt;code&gt;kubectl get networkpolicy -n database&lt;/code&gt; returned empty. Zero policies. Any pod anywhere in the cluster could reach Authelia's PostgreSQL and Redis instances with no network-layer restriction.&lt;/p&gt;

&lt;p&gt;This is the same gap class as Vault — missed in that pass because the audit focused on external-facing trust roots first. The database namespace holds Authelia's actual session and configuration storage. If Authelia is the gate for every exposed service in the homelab, the database namespace is what Authelia trusts. A compromised workload in the apps namespace could brute-force session tokens directly at the Postgres level, bypassing Authelia's own API entirely.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The Traffic Audit
&lt;/h2&gt;

&lt;p&gt;Before writing any policy, I checked what actually talks to the database namespace. Guessing at traffic patterns is how you lock out a critical component and create a cascading failure.&lt;/p&gt;

&lt;p&gt;Real traffic sources, confirmed against the live cluster:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authelia (apps namespace)&lt;/strong&gt;: connects to &lt;code&gt;postgres-authelia-rw:5432&lt;/code&gt; (session/config storage) and &lt;code&gt;redis-authelia:6379&lt;/code&gt; (session cache). Confirmed via &lt;code&gt;authelia/configmap.yml&lt;/code&gt;'s storage and session host fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus (monitoring namespace)&lt;/strong&gt;: scrapes &lt;code&gt;:9187&lt;/code&gt; metrics from CNPG instances via the PodMonitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CNPG operator (cnpg-system namespace)&lt;/strong&gt;: calls each instance's status API on &lt;code&gt;:8000&lt;/code&gt; for health and failover decisions. Confirmed via the live pod's declared ports: &lt;code&gt;postgresql:5432&lt;/code&gt;, &lt;code&gt;metrics:9187&lt;/code&gt;, &lt;code&gt;status:8000&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Three sources, three ports. The policy follows directly:&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/postgres/network-policies.yml&lt;/span&gt;

&lt;span class="c1"&gt;# Default deny all ingress&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;default-deny-ingress&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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="s"&gt;Ingress&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;# Allow same-namespace traffic (CNPG replication, operator-to-instance)&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;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;database&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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="s"&gt;Ingress&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="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;# Authelia → Postgres + Redis&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;allow-from-apps&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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="s"&gt;Ingress&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="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&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;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;5432&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
        &lt;span class="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;6379&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;# Prometheus → CNPG metrics&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;allow-from-monitoring&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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="s"&gt;Ingress&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="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&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;9187&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="c1"&gt;# CNPG operator → instance status API&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;allow-from-cnpg-operator&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;database&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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="s"&gt;Ingress&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="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kubernetes.io/metadata.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cnpg-system&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;8000&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five policies. Each one corresponds to a real, verified traffic source. No guesswork.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Deliberately Left Alone
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;cnpg-system&lt;/code&gt; namespace — where the operator itself runs — does not get a default-deny in this pass. This was a conscious decision, not an oversight.&lt;/p&gt;

&lt;p&gt;The CNPG operator serves the admission webhook (&lt;code&gt;cnpg-webhook-service:443&lt;/code&gt;) that kube-apiserver calls for every &lt;code&gt;Cluster&lt;/code&gt; and &lt;code&gt;Backup&lt;/code&gt; CRD operation cluster-wide. In k3s, apiserver-to-pod traffic doesn't originate from a normal namespaced pod IP. A default-deny in &lt;code&gt;cnpg-system&lt;/code&gt; risks silently breaking webhook admission for all CNPG resources — every &lt;code&gt;Cluster&lt;/code&gt; and &lt;code&gt;Backup&lt;/code&gt; apply would fail.&lt;/p&gt;

&lt;p&gt;On top of that, the operator already had a flaky restart history: 299 restarts over 21 days, with the most recent about 2 hours before this audit. Adding a NetworkPolicy that might interfere with its admission webhook, on a component that's already unstable, is how you turn a monitoring gap into an outage.&lt;/p&gt;

&lt;p&gt;Flagged as a separate reliability issue. Not guessed at.&lt;/p&gt;

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

&lt;p&gt;The audit followed the same methodology as the Vault fix:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;kubectl get networkpolicy -n &amp;lt;namespace&amp;gt;&lt;/code&gt; — confirm it's empty&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl get pods -n &amp;lt;namespace&amp;gt;&lt;/code&gt; — identify what's running&lt;/li&gt;
&lt;li&gt;Check each pod's declared ports and cross-reference with the consumer's config&lt;/li&gt;
&lt;li&gt;Write policies that match real traffic, not assumed traffic&lt;/li&gt;
&lt;li&gt;Apply, test, verify — don't apply and hope&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key discipline: never write a NetworkPolicy based on what you think the traffic pattern is. Check the actual config files, check the live pod's ports, and verify the source pod exists in the source namespace before committing.&lt;/p&gt;




&lt;p&gt;Network segmentation at the Kubernetes layer — namespace-level NetworkPolicies, pod-level isolation, deny-by-default — is the same model as Azure NSGs at the subnet level, Hub-Spoke traffic flow, and Zero Trust network architecture. The scale changes, the principle doesn't.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>security</category>
    </item>
  </channel>
</rss>
