<?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: Guatu</title>
    <description>The latest articles on DEV Community by Guatu (@futhgar).</description>
    <link>https://dev.to/futhgar</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%2F3847021%2F5aa46faa-d8e6-4023-ad78-5a335f875d69.png</url>
      <title>DEV Community: Guatu</title>
      <link>https://dev.to/futhgar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/futhgar"/>
    <language>en</language>
    <item>
      <title>Prometheus Alerting Rules That Don't Cry Wolf</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 29 Jun 2026 16:15:49 +0000</pubDate>
      <link>https://dev.to/futhgar/prometheus-alerting-rules-that-dont-cry-wolf-5jj</link>
      <guid>https://dev.to/futhgar/prometheus-alerting-rules-that-dont-cry-wolf-5jj</guid>
      <description>&lt;p&gt;The fastest way to get a team to ignore Prometheus is to point an alert at &lt;code&gt;up == 0&lt;/code&gt; and ship it. Week one it catches a real outage and everyone's impressed. By week three it has fired a couple dozen times for scrape blips that healed themselves before anyone looked, and people have quietly built a mental filter that drops every page from that channel. The alert still fires. Nobody reads it.&lt;/p&gt;

&lt;p&gt;That's the failure mode I want to talk about: alerts that are technically correct and operationally useless. An alert you've trained yourself to ignore is worse than no alert, because it gives you the feeling of coverage without the substance.&lt;/p&gt;

&lt;p&gt;If you're running &lt;code&gt;kube-prometheus-stack&lt;/code&gt; or plain Prometheus and you've reached the point where your alert channel is mostly noise, this is for you. If you've ever gotten a page that said "GPU temperature high" followed by a 36-character UUID and zero indication of which physical card was cooking, you're exactly the audience. The fix isn't fewer alerts. It's alerts that know what they're talking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I reached for first (and why it didn't hold up)
&lt;/h2&gt;

&lt;p&gt;The instinct when you stand up monitoring is to alert on everything you can see. Scrape is failing? Page. CPU over 80%? Page. Memory climbing? Page. Disk filling? Page. It feels responsible. You're "covering your bases."&lt;/p&gt;

&lt;p&gt;What you actually build is a slot machine that pays out anxiety. Two specific mistakes show up over and over, and I've made both.&lt;/p&gt;

&lt;p&gt;The first is alerting without a &lt;code&gt;for:&lt;/code&gt; clause. A bare expression like &lt;code&gt;up == 0&lt;/code&gt; evaluates on every rule interval, and the instant a single scrape fails the alert goes active. Scrapes fail for boring reasons all the time: a pod gets rescheduled, a node has a half-second of network weather, an exporter restarts during a Helm upgrade. None of those are incidents. All of them page you.&lt;/p&gt;

&lt;p&gt;The second mistake is alerting on raw hardware identifiers with no human context. Most low-level exporters emit metrics keyed by opaque IDs. &lt;code&gt;nvidia_smi_temperature_gpu&lt;/code&gt; carries a &lt;code&gt;uuid&lt;/code&gt; label and nothing friendly. So your alert fires, you read it half-asleep, and now you're SSHing into nodes running &lt;code&gt;nvidia-smi -L&lt;/code&gt; to figure out which UUID maps to which card in which chassis. By the time you've found it, the alert has either resolved or escalated. Either way you've spent ten minutes doing a join in your head that Prometheus should have done for you.&lt;/p&gt;

&lt;p&gt;Both mistakes share a root cause. The alert describes a metric, not a situation. "&lt;code&gt;up&lt;/code&gt; is zero" and "the GPU is at 89 degrees" are facts. "The monitoring for Node A's backup exporter has been unreachable for ten minutes, and that node is the only one tagged backup-critical" is a situation. Situations are actionable. Facts are homework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixes, in order of how much noise they kill
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Make every alert survive five minutes before it fires
&lt;/h3&gt;

&lt;p&gt;This is the single highest-use change and it costs one line. The &lt;code&gt;for:&lt;/code&gt; clause tells Prometheus to hold an alert in the &lt;code&gt;pending&lt;/code&gt; state until the condition has been continuously true for that duration. Only then does it transition to &lt;code&gt;firing&lt;/code&gt; and hit your notification path.&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;groups&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;targets.rules&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ExporterDown&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;up{job=~"pve-exporter|unpoller|nvidia-gpu-exporter"} == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Exporter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.job&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;unreachable"&lt;/span&gt;
          &lt;span class="na"&gt;description&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="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.job&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;had&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;scrapes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10m+."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scrape blip that recovers in 30 seconds never leaves &lt;code&gt;pending&lt;/code&gt;. You never hear about it. A genuine outage that persists past ten minutes pages you, with the job and instance already in the message. The number isn't sacred: use &lt;code&gt;for: 2m&lt;/code&gt; for things that genuinely matter fast, &lt;code&gt;for: 15m&lt;/code&gt; for slow-burn conditions like disk fill. The point is that almost nothing on your infrastructure justifies paging on a single failed evaluation.&lt;/p&gt;

&lt;p&gt;If you take only one thing from this post, take this. Most "Prometheus is too noisy" complaints are really "we forgot the &lt;code&gt;for:&lt;/code&gt; clause" complaints.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Join hardware IDs to human names inside the alert expression
&lt;/h3&gt;

&lt;p&gt;PromQL can do the UUID-to-name lookup for you at evaluation time, so the name lands in the alert annotation instead of in your debugging session. The trick is a vector match with &lt;code&gt;group_left&lt;/code&gt;, multiplying your metric by an &lt;code&gt;_info&lt;/code&gt; series whose value is always &lt;code&gt;1&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nvidia_smi_temperature_gpu
  * on(uuid) group_left(name) nvidia_smi_gpu_info
  &amp;gt; 85
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nvidia_smi_gpu_info&lt;/code&gt; is a metric whose only job is to carry labels: it's &lt;code&gt;1&lt;/code&gt; for every GPU, tagged with &lt;code&gt;uuid&lt;/code&gt; and &lt;code&gt;name&lt;/code&gt;. Multiplying your temperature reading by &lt;code&gt;1&lt;/code&gt; doesn't change the value, but &lt;code&gt;group_left(name)&lt;/code&gt; copies the &lt;code&gt;name&lt;/code&gt; label from the info series onto the result. Now the alert knows it's a "Tesla P40" and not just a hex string.&lt;/p&gt;

&lt;p&gt;Wired into a rule:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GPUTemperatureHigh&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;nvidia_smi_temperature_gpu&lt;/span&gt;
            &lt;span class="s"&gt;* on(uuid) group_left(name) nvidia_smi_gpu_info&lt;/span&gt;
            &lt;span class="s"&gt;&amp;gt; 85&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;running&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hot"&lt;/span&gt;
          &lt;span class="na"&gt;description&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="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;({{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.uuid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}})&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;been&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;above&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;85°C&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;5m+."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page now reads "GPU Tesla P40 is running hot" with the temperature and the UUID still available for the deep dive. That one annotation turns a cry-wolf alert into one you can act on without leaving the notification. This same pattern works for any exporter that splits readings from metadata: node info series, disk SMART data, network interface names. If you're already wrestling with the realities of monitoring passthrough hardware, the &lt;a href="https://dev.to/posts/gpu-passthrough-on-proxmox-gotcha-guide/"&gt;GPU passthrough field guide&lt;/a&gt; covers why those IDs are so unstable in the first place.&lt;/p&gt;

&lt;p&gt;When you put these joined values on a dashboard, the same context discipline applies; I wrote about not drowning the signal in chrome in &lt;a href="https://dev.to/posts/grafana-dashboards-information-density-vs-readability/"&gt;Grafana dashboards: information density vs readability&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Suppress alerts for failures you already expect
&lt;/h3&gt;

&lt;p&gt;Not every failure is a problem. Velero is a good example: snapshot a Longhorn volume and it works, snapshot an NFS-backed volume and the CSI snapshot fails because the driver doesn't support it. That second failure is expected. It will happen on every backup run, forever, and alerting on it teaches people that backup alerts are noise, which is precisely the day your actual backup breaks and nobody notices.&lt;/p&gt;

&lt;p&gt;The clean fix is to scope the alert to the targets you actually care about using a label match instead of trying to enumerate exceptions.&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CriticalVolumeSnapshotFailed&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;increase(velero_volume_snapshot_failure_total[1h]) &amp;gt; 0&lt;/span&gt;
          &lt;span class="s"&gt;and on(namespace, name)&lt;/span&gt;
            &lt;span class="s"&gt;kube_persistentvolumeclaim_info{storageclass="longhorn"}&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Snapshot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.namespace&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}/{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
          &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;backup-critical&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Longhorn&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;volume&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;snapshot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;30m+."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;and on(namespace, name) ...{storageclass="longhorn"}&lt;/code&gt; clause filters the failures down to volumes whose storage class is one you expect snapshots to work on. NFS volumes drop out of the result set entirely, so they can't fire. You've encoded "I know NFS snapshots fail and I don't care" directly into the rule, which is far better than a Slack message someone wrote in 2024 saying "just ignore the NFS ones."&lt;/p&gt;

&lt;p&gt;You can push the same idea further with explicit labels. Relabel your backup targets to carry &lt;code&gt;backup_critical="true"&lt;/code&gt; and only alert on those, so a dev-scratch volume failing to back up never wakes anyone. Storage alerts are their own rabbit hole, and the gap between "Longhorn says healthy" and "actually recoverable" is worth understanding before you decide what to alert on; that's &lt;a href="https://dev.to/posts/longhorn-volume-health-monitoring-replication-and-capacity/"&gt;Longhorn volume health&lt;/a&gt; in detail.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Separate "the service is down" from "the monitoring is broken"
&lt;/h3&gt;

&lt;p&gt;This is the distinction that fixes the worst class of false positive. The &lt;code&gt;up&lt;/code&gt; metric only tells you whether Prometheus could scrape the exporter. It says nothing about whether the exporter could reach the thing it's monitoring.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pve-exporter&lt;/code&gt; makes this concrete. It scrapes the Proxmox API on your behalf and returns a &lt;code&gt;pve_up&lt;/code&gt; metric per target. If the exporter's API token expires or loses permissions, the exporter itself is healthy and scrapeable (&lt;code&gt;up == 1&lt;/code&gt;), but it returns &lt;code&gt;pve_up == 0&lt;/code&gt; because it can't authenticate upstream. Alert on &lt;code&gt;up&lt;/code&gt; alone and you'll either miss the auth failure entirely or, worse, misread it as the node being down and start a recovery you don't need.&lt;/p&gt;

&lt;p&gt;Split it into two alerts that say two different things:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PVEExporterUnreachable&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;up{job="pve-exporter"} == &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Prometheus&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cannot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reach&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pve-exporter"&lt;/span&gt;
          &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;monitoring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;broken,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;necessarily&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;node."&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PVEAPIAuthFailing&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pve_up == 0 and on(instance) up{job="pve-exporter"} == &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pve-exporter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;up&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;but&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cannot&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;talk&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Proxmox&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API"&lt;/span&gt;
          &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Token&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;expired&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;or&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;lost&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;permissions.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Check&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exporter&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;credentials."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first alert means "I'm blind." The second means "my eyes work but the door is locked." Those need different responses, and naming them differently is what stops you from running the wrong runbook at the wrong hour. Designing alerts around the credential boundary like this is the same instinct behind keeping monitoring identities scoped and rotatable, which is part of why I treat &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;alerting trust as a consulting problem&lt;/a&gt; for production systems, not an afterthought.&lt;/p&gt;

&lt;p&gt;One trap to avoid here: don't write a thermal alert against &lt;code&gt;pve-exporter&lt;/code&gt;. As of v3.5.5 it doesn't expose temperature metrics at all, so a "node too hot" rule against that job produces a &lt;code&gt;no data&lt;/code&gt; state forever, which Grafana will happily render as a permanently confusing yellow tile. Alert on what the exporter actually emits, not what you wish it did.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Make sure your alert rules are even being scraped
&lt;/h3&gt;

&lt;p&gt;This is the silent killer, and it's specific to the Prometheus Operator world. You write a perfect &lt;code&gt;ServiceMonitor&lt;/code&gt;, your exporter is running, and no data ever appears. No alert ever fires. Everything looks deployed and nothing works.&lt;/p&gt;

&lt;p&gt;The cause is almost always a label mismatch. The Prometheus custom resource has a &lt;code&gt;serviceMonitorSelector&lt;/code&gt;, and &lt;code&gt;kube-prometheus-stack&lt;/code&gt; defaults it to match &lt;code&gt;release: &amp;lt;your-helm-release-name&amp;gt;&lt;/code&gt;. A &lt;code&gt;ServiceMonitor&lt;/code&gt; without that label is invisible to Prometheus. It exists, &lt;code&gt;kubectl get servicemonitor&lt;/code&gt; shows it, and Prometheus ignores it completely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring.coreos.com/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;ServiceMonitor&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;pve-exporter&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;   &lt;span class="c1"&gt;# MUST match the Prometheus serviceMonitorSelector&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;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pve-exporter&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&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="s"&gt;metrics&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check your Prometheus CR with &lt;code&gt;kubectl get prometheus -o yaml&lt;/code&gt; and look at &lt;code&gt;spec.serviceMonitorSelector&lt;/code&gt;. Whatever it matches, your &lt;code&gt;ServiceMonitor&lt;/code&gt; labels have to satisfy it. The same applies to &lt;code&gt;PrometheusRule&lt;/code&gt; objects via &lt;code&gt;ruleSelector&lt;/code&gt;: get the label wrong and your beautifully written alert rules are never loaded. Verify they made it by opening the Prometheus UI under Status → Rules. If your group isn't listed, the selector dropped it.&lt;/p&gt;

&lt;p&gt;While you're in there, one format gotcha. If you copied alert syntax from an old StackOverflow answer that uses &lt;code&gt;ALERT FooBar IF expr FOR 5m&lt;/code&gt;, that's the Prometheus 1.x DSL and it will not load in 2.x or 3.x. The current format is the YAML &lt;code&gt;groups:&lt;/code&gt; structure shown throughout this post. Mixing the two is a common reason rules silently fail validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these changes actually move the needle
&lt;/h2&gt;

&lt;p&gt;Every one of these fixes pushes alerts in the same direction: from describing a metric to describing a situation a human can act on.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;for:&lt;/code&gt; clause works because it converts instantaneous facts into sustained conditions. Infrastructure is noisy at the millisecond scale and calm at the multi-minute scale. By requiring a condition to persist, you're matching the alert's time horizon to the time horizon a human actually responds on. Nobody pages on a 200-millisecond blip because nobody can act on one.&lt;/p&gt;

&lt;p&gt;The PromQL joins work because the cost of context should be paid once, at rule-evaluation time, not every time a tired engineer reads the page. Prometheus already has the mapping from &lt;code&gt;uuid&lt;/code&gt; to &lt;code&gt;name&lt;/code&gt; sitting in an info metric. Doing the join in the rule means the expensive lookup happens automatically and the result is baked into the annotation. You move work from the human to the query engine, which is the whole point of having a query engine.&lt;/p&gt;

&lt;p&gt;Label-based suppression works because it encodes intent. "Ignore NFS snapshot failures" is a decision, and decisions that live in someone's head rot the moment that person goes on vacation. Putting the decision in the rule expression makes it durable, reviewable, and visible in the same place as the alert it governs.&lt;/p&gt;

&lt;p&gt;The target-versus-exporter split works because "I can't see" and "the thing is broken" demand different responses, and an alert that conflates them sends you down the wrong path under pressure. Naming the two states separately is cheap insurance against the expensive mistake of treating a permissions error like an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on the Grafana alerting overlap
&lt;/h2&gt;

&lt;p&gt;One source of duplicate pages worth flagging: you can define alerts as Prometheus/Mimir rules (the Ruler) and also define them in Grafana's unified alerting, and both can route to the same contact points. People wire up both without realizing it and then wonder why every incident pages twice.&lt;/p&gt;

&lt;p&gt;Pick one home for alert definitions. I keep mine as &lt;code&gt;PrometheusRule&lt;/code&gt; objects in Git alongside the rest of the GitOps config, because that's where the rest of the platform lives and code review catches bad expressions before they ship. Grafana then handles dashboards and visualization, not alert routing. If you prefer Grafana-managed alerts, that's a defensible choice, but run one or the other for any given signal. Two systems firing on the same metric is how you rebuild the noise problem you just spent a weekend fixing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently and what still bites
&lt;/h2&gt;

&lt;p&gt;If I were starting a monitoring stack from scratch, I'd write the first alert with a &lt;code&gt;for:&lt;/code&gt; clause and a join already in place, before I ever shipped a bare &lt;code&gt;up == 0&lt;/code&gt;. The bad habits are easy to form and annoying to unwind once a team has learned to distrust the channel.&lt;/p&gt;

&lt;p&gt;A few things that still catch me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exporters that get OOMKilled look like target outages.&lt;/strong&gt; &lt;code&gt;nvidia-gpu-exporter&lt;/code&gt; (v1.4.1) needs more memory than you'd guess, around 256Mi rather than the 64Mi you might set by reflex, because the NVIDIA runtime injects libraries into the container at start. Set the limit too low and it restarts in a loop, your GPU alerts flap, and you blame the GPU. The behavior ties back to how the runtime mounts things; the &lt;a href="https://dev.to/posts/nvidia-container-toolkit-why-the-default-runtime-matters/"&gt;NVIDIA Container Toolkit post&lt;/a&gt; explains why that injection happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;no data&lt;/code&gt; is not &lt;code&gt;OK&lt;/code&gt;.&lt;/strong&gt; An alert that can't evaluate because its metric vanished is a failure state, not a passing one. Use &lt;code&gt;absent()&lt;/code&gt; rules to catch metrics that should always exist and have gone missing, otherwise a deleted exporter reads as eternal calm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Severity is a routing decision, not a vibe.&lt;/strong&gt; Reserve paging severity for things that need a human awake right now. Everything else is a &lt;code&gt;warning&lt;/code&gt; that lands in a channel you read during business hours. If every alert is &lt;code&gt;critical&lt;/code&gt;, none of them are.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal was never zero alerts. It's that when one fires, you believe it. An alert channel people trust is worth more than one that's technically complete, because the complete-but-ignored channel catches nothing the moment it matters. Spend the extra line on &lt;code&gt;for:&lt;/code&gt;, the extra clause on the join, and the extra label on suppression. That's the difference between a monitoring system and a smoke detector with a dead battery that beeps at random.&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>alerting</category>
      <category>monitoring</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>SealedSecrets: Storing Secrets in Git Without the Risk</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sat, 27 Jun 2026 14:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/sealedsecrets-storing-secrets-in-git-without-the-risk-48cp</link>
      <guid>https://dev.to/futhgar/sealedsecrets-storing-secrets-in-git-without-the-risk-48cp</guid>
      <description>&lt;p&gt;A base64-encoded string in a Git repo is not a secret. It's plaintext with one extra step, and &lt;code&gt;echo "cGFzc3dvcmQ=" | base64 -d&lt;/code&gt; undoes that step in about a second. Plenty of teams discover this the hard way when a secret scanner flags their carefully "encoded" &lt;code&gt;data:&lt;/code&gt; block as a live database password sitting in the commit history, readable by anyone who ever cloned the repo.&lt;/p&gt;

&lt;p&gt;That's the core tension of GitOps. You want Git to be the single source of truth for everything in the cluster, but "everything" includes API keys, TLS private keys, and database passwords, and committing those in the clear is how breaches start. SealedSecrets resolves that tension with a small controller and a CLI, and it's the lightest-weight tool I know of that does the job properly. If you run ArgoCD or Flux and you're still keeping a &lt;code&gt;secrets/&lt;/code&gt; folder out of version control "for now," this is the post for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false starts I'd warn you about
&lt;/h2&gt;

&lt;p&gt;Before SealedSecrets clicks, most people cycle through the same set of wrong answers. I did too.&lt;/p&gt;

&lt;p&gt;The first instinct is to just base64-encode the value and call it encrypted. Kubernetes stores secret data as base64, so it &lt;em&gt;looks&lt;/em&gt; scrambled, and that visual scramble fools a surprising number of code reviews. Base64 is an encoding, not encryption. There is no key. Anyone with the repo has the secret. Cross this one off immediately.&lt;/p&gt;

&lt;p&gt;The second instinct, at least mine, was to reach for Vault. HashiCorp Vault (or its OpenBazaar-era forks) is a genuinely good piece of software, and if you're running a platform team with dynamic secrets, lease revocation, and audit requirements, it earns its keep. For a homelab or a small cluster, it's a second stateful system to run, unseal, back up, and babysit. You're now protecting your secrets manager more carefully than the secrets it holds. That's a lot of operational surface area to avoid committing a few API keys to Git.&lt;/p&gt;

&lt;p&gt;Then there's the git history problem, which trips people who try to fix this reactively. Say you already committed a plaintext secret last month. Deleting it from the current file does nothing. It lives forever in the history, retrievable with &lt;code&gt;git log -p&lt;/code&gt;. You'd have to rewrite history with &lt;code&gt;git filter-repo&lt;/code&gt; or BFG, force-push, and rotate the secret anyway because you have to assume it leaked. The lesson there is that secret management is a &lt;em&gt;preventative&lt;/em&gt; discipline. The tool has to make the wrong thing hard to do in the first place.&lt;/p&gt;

&lt;p&gt;SOPS with age or KMS keys is a legitimate alternative, and I'd put it second on my list. It encrypts files before they hit Git and decrypts them at apply time. The reason I land on SealedSecrets for Kubernetes-native GitOps is that it lives inside the cluster as a CRD, the decryption never leaves the controller, and the developer workflow is a single command. Different tradeoffs, both valid.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SealedSecrets actually works
&lt;/h2&gt;

&lt;p&gt;The model is asymmetric encryption, and once that clicks, everything else follows.&lt;/p&gt;

&lt;p&gt;You install a controller (Bitnami's &lt;code&gt;sealed-secrets-controller&lt;/code&gt;, typically into &lt;code&gt;kube-system&lt;/code&gt;). On startup it generates an RSA key pair. The &lt;strong&gt;public&lt;/strong&gt; key is freely distributable. The &lt;strong&gt;private&lt;/strong&gt; key never leaves the cluster. You then use the &lt;code&gt;kubeseal&lt;/code&gt; CLI to encrypt a normal Kubernetes Secret against that public key, producing a &lt;code&gt;SealedSecret&lt;/code&gt; custom resource. That resource is safe to commit, safe to put in a public repo, safe to paste in a Slack channel. The only thing in the universe that can decrypt it is the controller holding the matching private key.&lt;/p&gt;

&lt;p&gt;When the &lt;code&gt;SealedSecret&lt;/code&gt; lands in the cluster, the controller watches for it, decrypts the payload, and produces a regular &lt;code&gt;Secret&lt;/code&gt; with the same name and namespace. Your workloads consume that &lt;code&gt;Secret&lt;/code&gt; exactly as they always have. Nothing downstream knows the difference.&lt;/p&gt;

&lt;p&gt;Here's the workflow loop. Start with a normal secret manifest you generate locally and never commit:&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;# Generate a plain Secret manifest (stays on your machine, never committed)&lt;/span&gt;
kubectl create secret generic db-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-prod &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'example-not-a-real-password'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; secret.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then seal 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;# kubeseal fetches the controller's public cert and encrypts against it&lt;/span&gt;
kubeseal &lt;span class="nt"&gt;--format&lt;/span&gt; yaml &amp;lt; secret.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sealed-secret.yaml

&lt;span class="c"&gt;# Delete the plaintext, commit the sealed version&lt;/span&gt;
&lt;span class="nb"&gt;rm &lt;/span&gt;secret.yaml
git add sealed-secret.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is a &lt;code&gt;SealedSecret&lt;/code&gt; whose &lt;code&gt;encryptedData&lt;/code&gt; is an opaque blob:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitnami.com/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;SealedSecret&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;db-credentials&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;app-prod&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;encryptedData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AgBy3i4OJSWK+PiTySYZ...truncated...rO43cGDEq&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;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;db-credentials&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;app-prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That blob is what goes in Git. ArgoCD syncs the &lt;code&gt;SealedSecret&lt;/code&gt;, the controller unseals it into a real &lt;code&gt;Secret&lt;/code&gt;, and your app starts. The person writing the manifest never has to see the production value in plaintext, which is the zero-trust angle I'll come back to.&lt;/p&gt;

&lt;p&gt;One operational detail worth setting up early: seal offline using a saved public cert instead of hitting the cluster every time. Pull the cert once, commit it (it's public), and point &lt;code&gt;kubeseal&lt;/code&gt; at 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;# Fetch the public cert once&lt;/span&gt;
kubeseal &lt;span class="nt"&gt;--fetch-cert&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; pub-cert.pem

&lt;span class="c"&gt;# Seal against the saved cert, no cluster connection needed&lt;/span&gt;
kubeseal &lt;span class="nt"&gt;--cert&lt;/span&gt; pub-cert.pem &lt;span class="nt"&gt;--format&lt;/span&gt; yaml &amp;lt; secret.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sealed-secret.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because it lets you seal secrets in CI without giving the pipeline cluster access. A GitHub Actions job can encrypt a new secret during a PR using only the public cert, which pairs nicely with the kind of automated pipeline I described in &lt;a href="https://guatulabs.dev/posts/automating-infrastructure-with-opentofu-and-github-actions/" rel="noopener noreferrer"&gt;Infrastructure as Code, but Automated: OpenTofu and GitHub Actions&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scopes gotcha
&lt;/h2&gt;

&lt;p&gt;By default, a &lt;code&gt;SealedSecret&lt;/code&gt; is bound to both its name and its namespace. This is "strict" scope, and it's the right default, but it bites people who don't expect it. If you seal a secret for &lt;code&gt;app-prod&lt;/code&gt; and then try to reuse that same sealed blob in &lt;code&gt;app-staging&lt;/code&gt;, the controller refuses to decrypt it. Same if you rename it. The name and namespace are mixed into the encryption, so changing either invalidates the seal.&lt;/p&gt;

&lt;p&gt;You can loosen this when you genuinely need to:&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;# Namespace-wide: any name within the namespace can decrypt it&lt;/span&gt;
kubeseal &lt;span class="nt"&gt;--scope&lt;/span&gt; namespace-wide &lt;span class="nt"&gt;--format&lt;/span&gt; yaml &amp;lt; secret.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sealed.yaml

&lt;span class="c"&gt;# Cluster-wide: any name in any namespace can decrypt it&lt;/span&gt;
kubeseal &lt;span class="nt"&gt;--scope&lt;/span&gt; cluster-wide &lt;span class="nt"&gt;--format&lt;/span&gt; yaml &amp;lt; secret.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sealed.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My advice is to stay on strict scope unless you have a concrete reason not to. Loosening the scope trades blast radius for convenience. Cluster-wide in particular means anyone who can create a Secret object anywhere can pull your value out, which quietly undoes part of the boundary you set up with &lt;a href="https://guatulabs.dev/posts/kubernetes-rbac-building-least-privilege-service-accounts/" rel="noopener noreferrer"&gt;least-privilege service accounts&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody warns you about: this is a backup problem
&lt;/h2&gt;

&lt;p&gt;Here's the reframe that took me a while to internalize. SealedSecrets makes your &lt;em&gt;encryption&lt;/em&gt; essentially bulletproof. What it does instead is concentrate all your risk into one tiny object: the controller's private key. Lose that key, and every &lt;code&gt;SealedSecret&lt;/code&gt; in every repo becomes permanently undecryptable. The blobs are fine. The cluster that can read them is gone.&lt;/p&gt;

&lt;p&gt;Picture the disaster cleanly. Your cluster dies (a failed etcd restore, a wiped control plane, a homelab node that won't come back). You rebuild from scratch, install a fresh SealedSecrets controller, and point ArgoCD at your repo. ArgoCD dutifully applies every &lt;code&gt;SealedSecret&lt;/code&gt;. The new controller generated a &lt;em&gt;new&lt;/em&gt; key pair on install, so it cannot decrypt a single one of the blobs sealed against the old public key. Every secret-dependent workload fails to start. Your Git repo is intact, your manifests are perfect, and none of it works.&lt;/p&gt;

&lt;p&gt;The fix is to treat the private key as the single most important artifact in your infrastructure and back it up off-cluster. The keys live as Secrets in the controller's namespace, labeled so you can grab the full set in one shot:&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;# Back up the active sealing keys (THIS FILE CONTAINS THE PRIVATE KEYS)&lt;/span&gt;
kubectl get secret &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-l&lt;/span&gt; sealedsecrets.bitnami.com/sealed-secrets-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; sealed-secrets-master.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That output is the actual private key material, so it never goes near Git. It goes into a password manager or an encrypted, off-cluster backup. Restoring is the inverse: apply the key backup &lt;em&gt;before&lt;/em&gt; the controller fully reconciles, then restart it so it picks up the old keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the rebuilt cluster, restore keys then restart the controller&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; sealed-secrets-master.key
kubectl rollout restart deployment sealed-secrets-controller &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the controller holds both the old and new private keys, decrypts all your historical blobs, and your secrets come back to life. I wrote a dedicated walkthrough on exactly this lifecycle in &lt;a href="https://guatulabs.dev/posts/sealedsecrets-key-backup-don-t-lose-your-encryption-keys/" rel="noopener noreferrer"&gt;SealedSecrets Key Backup: Don't Lose Your Encryption Keys&lt;/a&gt;, because it's the one step that separates a working SealedSecrets setup from a time bomb.&lt;/p&gt;

&lt;p&gt;One thing that helps here: the controller rotates its key every 30 days by default, generating a fresh pair while keeping all the old ones for decryption. That's good for forward security, but it also means your key backup is never "done." A backup from two months ago is missing two rotations of keys. Either automate the backup on a schedule or pin the key and disable rotation deliberately, knowing the tradeoff. Stale backups are how people think they're safe right up until they aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GitOps cleanup reality check
&lt;/h2&gt;

&lt;p&gt;The other thing tutorials skip is what happens when you &lt;em&gt;remove&lt;/em&gt; or rotate a secret in a GitOps world, because ArgoCD's relationship with deleted resources is more complicated than "delete from Git, it disappears from the cluster."&lt;/p&gt;

&lt;p&gt;By default, the controller sets an owner reference on the &lt;code&gt;Secret&lt;/code&gt; it generates, pointing back to the &lt;code&gt;SealedSecret&lt;/code&gt;. That's the well-behaved case: delete the &lt;code&gt;SealedSecret&lt;/code&gt; and Kubernetes garbage-collects the generated &lt;code&gt;Secret&lt;/code&gt; along with it. Clean. The trouble starts when that chain is broken, or when you've turned off ArgoCD's pruning.&lt;/p&gt;

&lt;p&gt;If your ArgoCD &lt;code&gt;Application&lt;/code&gt; doesn't have automated pruning on, removing a &lt;code&gt;SealedSecret&lt;/code&gt; from Git leaves the object sitting in the cluster as an orphan. ArgoCD shows the app as synced because everything in Git exists in the cluster; it just won't remove the things that &lt;em&gt;used&lt;/em&gt; to be there. You end up with ghost &lt;code&gt;SealedSecrets&lt;/code&gt;, and sometimes the &lt;code&gt;Secrets&lt;/code&gt; they spawned, lingering long after you deleted them from the repo. To get the behavior most people actually want, turn on prune and self-heal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;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;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;      &lt;span class="c1"&gt;# delete cluster resources removed from Git&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;span class="c1"&gt;# revert manual drift back to Git state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be deliberate about &lt;code&gt;prune: true&lt;/code&gt;, though, because it's the same flag that creates the scarier failures. When ArgoCD prunes a whole application, it deletes resources in dependency order and respects finalizers, and that's where things get stuck. I've watched deletions hang on a PVC whose finalizer waits on a volume that's still attached, or an IngressRoute that won't go because something downstream holds a reference. The &lt;code&gt;SealedSecret&lt;/code&gt; deletes instantly; it's the stateful neighbors (PVCs, certificates, anything with a finalizer) that turn a clean delete into a half-removed application you have to untangle by hand. None of that is SealedSecrets' fault directly, but rotating or removing a secret often means touching the surrounding app, and that's when the orphans surface. The App-of-Apps structure I described in &lt;a href="https://guatulabs.dev/posts/gitops-for-homelabs-argocd-app-of-apps/" rel="noopener noreferrer"&gt;GitOps for Homelabs&lt;/a&gt; makes this more predictable by keeping deletion boundaries clear, but you still want to watch a prune happen the first few times rather than trusting it blindly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the right shape of tool
&lt;/h2&gt;

&lt;p&gt;The reason SealedSecrets earns a place in a GitOps stack comes down to where the trust boundary sits. The developer, the CI pipeline, and the Git host all handle only the encrypted blob. None of them can decrypt it. The only component that ever sees plaintext is the controller, which already runs inside the trust boundary of the cluster and is governed by RBAC. You've moved the secret out of every system that shouldn't have it and into the one place that already has to.&lt;/p&gt;

&lt;p&gt;That's a genuine zero-trust property, not marketing. A new engineer can submit a PR that adds a production secret without ever knowing its value, sealing it against the public cert from CI. Review it, merge it, and the controller is the only thing that ever decrypts it. Compare that to a model where someone has to know the real password to put it in the right place, and you can see why this is the better default for a team.&lt;/p&gt;

&lt;p&gt;It also stays lightweight in a way that matters operationally. One controller, one CRD, one CLI. No second clustered datastore to keep alive, no unseal ritual, no separate HA story. For homelabs and small-to-medium clusters, the cost-to-value ratio is hard to beat, and it composes cleanly with the rest of a Kubernetes-native stack like &lt;a href="https://guatulabs.dev/posts/cert-manager-cloudflare-dns-01-automated-tls-for-everything/" rel="noopener noreferrer"&gt;cert-manager handling your TLS&lt;/a&gt;. If you're designing a GitOps platform for a team and want the secret lifecycle and disaster-recovery story built in from the start rather than bolted on later, that's the kind of thing I help with through &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;infrastructure consulting&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;If I were setting this up fresh on a new cluster, here's what I'd do in order and what I'd watch for.&lt;/p&gt;

&lt;p&gt;Back up the private key on day one, before you seal anything. The temptation is to install the controller, seal a few secrets, and move on, treating the backup as a later task. That's exactly backwards. The key is most valuable and most likely to be forgotten right at the start. Automate the backup to an off-cluster, encrypted destination and verify the restore at least once, because an untested backup is a guess.&lt;/p&gt;

&lt;p&gt;Stay on strict scope and resist the urge to go cluster-wide for convenience. Every loosening of scope widens who can extract the value, and you rarely get a reminder of that decision until it matters.&lt;/p&gt;

&lt;p&gt;Treat removal as carefully as creation. Adding a &lt;code&gt;SealedSecret&lt;/code&gt; is the easy, well-lit path. Removing or rotating one drags in ArgoCD pruning, finalizers, and owner references, and that's where the orphaned resources hide. Turn on &lt;code&gt;prune&lt;/code&gt; and &lt;code&gt;selfHeal&lt;/code&gt; so Git stays authoritative, but watch the first few prunes and keep an eye on anything stateful nearby.&lt;/p&gt;

&lt;p&gt;Validate sealed manifests in CI before they reach main. A malformed &lt;code&gt;SealedSecret&lt;/code&gt; won't decrypt, and you'd rather catch that in a PR check than at sync time when a workload won't start. The same manifest-validation habit from &lt;a href="https://guatulabs.dev/posts/kubernetes-manifest-validation-catching-errors-before-merge/" rel="noopener noreferrer"&gt;Stop Merging Broken YAML&lt;/a&gt; applies cleanly here.&lt;/p&gt;

&lt;p&gt;The thing that surprised me most was how completely SealedSecrets shifts the problem. You go in worried about encryption strength and whether someone can crack the blob in your repo. You come out realizing the encryption was never the weak point. The weak point is a single RSA private key that, if you lose it, turns your perfectly version-controlled secrets into perfectly version-controlled garbage. Get the backup right, and the rest of the tool more or less takes care of itself.&lt;/p&gt;

</description>
      <category>sealedsecrets</category>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>secretsmanagement</category>
    </item>
    <item>
      <title>MetalLB on Bare Metal: LoadBalancer Without a Cloud Provider</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sat, 27 Jun 2026 12:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/metallb-on-bare-metal-loadbalancer-without-a-cloud-provider-43a1</link>
      <guid>https://dev.to/futhgar/metallb-on-bare-metal-loadbalancer-without-a-cloud-provider-43a1</guid>
      <description>&lt;p&gt;Deploy a &lt;code&gt;Service&lt;/code&gt; of type &lt;code&gt;LoadBalancer&lt;/code&gt; on a bare-metal cluster and &lt;code&gt;kubectl get svc&lt;/code&gt; hands you a permanent &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; in the EXTERNAL-IP column. No error, no event, no hint. The Service just sits there forever, waiting for an external IP that will never arrive, because nothing in your cluster is responsible for assigning one.&lt;/p&gt;

&lt;p&gt;That gap is the entire reason MetalLB exists. On AWS or GCP, the cloud controller manager watches for &lt;code&gt;LoadBalancer&lt;/code&gt; Services, calls the provider API, provisions an actual load balancer, and writes the IP back into the Service status. On bare metal there is no API to call. Kubernetes ships the abstraction but not the implementation, and you're left holding a Service type that does nothing until you supply the missing piece yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should care
&lt;/h2&gt;

&lt;p&gt;If you run Kubernetes on Proxmox VMs, on a stack of mini PCs, or on anything that isn't a managed cloud, you've hit this. The moment you try to expose &lt;code&gt;ingress-nginx&lt;/code&gt;, Traefik, a database, or an LLM inference endpoint to the rest of your LAN, you need a stable IP that lives outside the cluster and routes in. MetalLB is the most common answer, and the config has changed enough across versions that a 2021 tutorial will actively mislead you. This walks through MetalLB v0.15.x, the L2-versus-BGP decision, and the connectivity traps that make an IP &lt;em&gt;look&lt;/em&gt; assigned while traffic quietly goes nowhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I reached for first (and why it was wrong)
&lt;/h2&gt;

&lt;p&gt;Before MetalLB, the obvious-looking options all have a catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NodePort.&lt;/strong&gt; It works, technically. You get a port in the 30000-32767 range on every node, and you can hit &lt;code&gt;http://node-ip:31234&lt;/code&gt;. Then you try to use it for real and remember why nobody runs production this way: the ports are ugly, they're not 80/443, and you've now hardcoded a specific node's IP into whatever's calling the service. If that node reboots or drains, your "load balancer" is a single point of failure pointing at a machine that's gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;hostNetwork on the ingress controller.&lt;/strong&gt; Bind ingress-nginx directly to ports 80 and 443 on the host. This also works, and it's genuinely fine for a single-node setup. On a multi-node cluster it falls apart: now you need an external load balancer or DNS round-robin in front of the nodes to spread traffic, and you've moved the problem one layer up the stack instead of solving it. You also lose the clean &lt;code&gt;LoadBalancer&lt;/code&gt; Service abstraction that everything else in the ecosystem expects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assuming &lt;code&gt;spec.loadBalancerIP&lt;/code&gt; would just work.&lt;/strong&gt; I set &lt;code&gt;spec.loadBalancerIP: 10.0.0.210&lt;/code&gt; on a Service, expecting Kubernetes to honor it. Nothing happened. That field assigns an IP only if a controller is watching for it and acts on the request. Without MetalLB or a cloud provider, it's an inert string. Worse, in newer MetalLB versions that field is deprecated in favor of an annotation, so even after installing MetalLB the old syntax silently does nothing.&lt;/p&gt;

&lt;p&gt;The mistake underneath all three: treating &lt;code&gt;type: LoadBalancer&lt;/code&gt; as a feature Kubernetes provides, when it's really a contract Kubernetes defines and expects something else to fulfill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing MetalLB
&lt;/h2&gt;

&lt;p&gt;MetalLB has two pieces. A &lt;code&gt;controller&lt;/code&gt; Deployment handles IP allocation, watching Services and writing the assigned address into their status. A &lt;code&gt;speaker&lt;/code&gt; DaemonSet runs on every node and is responsible for actually announcing the IP to the network, either via ARP (L2 mode) or BGP.&lt;/p&gt;

&lt;p&gt;I install it with Helm so it fits a GitOps flow. If you run &lt;a href="https://guatulabs.dev/posts/gitops-for-homelabs-argocd-app-of-apps/" rel="noopener noreferrer"&gt;ArgoCD app-of-apps&lt;/a&gt;, this slots in as one more application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add metallb https://metallb.github.io/metallb
helm &lt;span class="nb"&gt;install &lt;/span&gt;metallb metallb/metallb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; metallb-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; 0.15.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Installing the chart does nothing visible on its own. MetalLB ships with no IP pool by default, which is deliberate: handing out addresses you didn't explicitly authorize would be a great way to start an IP conflict war with your DHCP server. You have to tell it which addresses it owns.&lt;/p&gt;

&lt;p&gt;That configuration moved to CRDs back in v0.13. If a guide tells you to edit a &lt;code&gt;ConfigMap&lt;/code&gt; named &lt;code&gt;config&lt;/code&gt; in the &lt;code&gt;metallb-system&lt;/code&gt; namespace, it predates that change and the syntax no longer applies. The two objects you need are &lt;code&gt;IPAddressPool&lt;/code&gt; and &lt;code&gt;L2Advertisement&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IPAddressPool&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;lan-pool&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;metallb-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;10.0.0.200-10.0.0.250&lt;/span&gt;  &lt;span class="c1"&gt;# carve this OUT of your DHCP range&lt;/span&gt;
  &lt;span class="na"&gt;autoAssign&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;L2Advertisement&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;lan-l2&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;metallb-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ipAddressPools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;lan-pool&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pool defines which addresses MetalLB may hand out. The &lt;code&gt;L2Advertisement&lt;/code&gt; says "announce these addresses using Layer 2." Without the advertisement object, MetalLB will assign an IP from the pool but never tell the network it exists, which produces one of the most confusing failure states: a Service with an EXTERNAL-IP that nothing on the LAN can reach. The pool without the advertisement is half a configuration.&lt;/p&gt;

&lt;p&gt;The address range matters. Those IPs must live in the same subnet as your nodes, and they must be outside whatever your DHCP server hands out. Pick a slice your router will never lease. A &lt;code&gt;.200-.250&lt;/code&gt; block at the top of a &lt;code&gt;/24&lt;/code&gt; is a common choice precisely because most DHCP pools stop well before there.&lt;/p&gt;

&lt;p&gt;Apply both, deploy a test Service, and the &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; finally resolves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get svc &lt;span class="nt"&gt;-n&lt;/span&gt; ingress-nginx
NAME                 TYPE           EXTERNAL-IP   PORT&lt;span class="o"&gt;(&lt;/span&gt;S&lt;span class="o"&gt;)&lt;/span&gt;
ingress-nginx        LoadBalancer   10.0.0.200    80:31...,443:32...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  L2 versus BGP: pick the simple one until you can't
&lt;/h2&gt;

&lt;p&gt;MetalLB runs in one of two modes, and most homelab and small-cluster setups want Layer 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;L2 mode&lt;/strong&gt; uses ARP for IPv4 (NDP for IPv6). One speaker pod becomes the leader for a given Service IP and answers ARP requests for it. When a host on the LAN asks "who has 10.0.0.200?", that node replies with its own MAC, traffic arrives at the node, and kube-proxy forwards it to a backend pod. The whole thing needs zero cooperation from your router or switch. It looks like a normal host claiming an IP, which is exactly what it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BGP mode&lt;/strong&gt; has each speaker peer with your router and advertise the Service IPs as routes. The router then load-balances across nodes via ECMP. This gives you real multi-node traffic distribution and faster failover, but it requires a router that speaks BGP and a willingness to manage routing config. That's a reasonable trade in a rack with a proper top-of-rack switch. In a homelab where the "router" is a consumer box, it's usually more than you want to take on.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;L2 (ARP/NDP)&lt;/th&gt;
&lt;th&gt;BGP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Router requirement&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Must speak BGP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traffic distribution&lt;/td&gt;
&lt;td&gt;One node per IP at a time&lt;/td&gt;
&lt;td&gt;ECMP across nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failover speed&lt;/td&gt;
&lt;td&gt;Seconds (ARP re-announce)&lt;/td&gt;
&lt;td&gt;Sub-second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup complexity&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Moderate to high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Homelabs, small clusters&lt;/td&gt;
&lt;td&gt;Datacenter, high throughput&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest caveat about L2: it is not true load balancing. For any single Service IP, all traffic lands on one node, the elected leader, and that node forwards internally. You get failover (if the leader dies, another speaker takes over and re-announces via gratuitous ARP) but not bandwidth aggregation. For a homelab serving an ingress controller or an Ollama API, that's completely fine. A single 10G node will saturate most home internet connections long before it becomes the bottleneck. If you genuinely need to spread inbound traffic across nodes, that's the line where BGP earns its complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why L2 mode actually works
&lt;/h2&gt;

&lt;p&gt;The mechanism is worth understanding, because it explains every weird failure you'll hit later.&lt;/p&gt;

&lt;p&gt;When the leader speaker announces a Service IP, it doesn't add the IP to a network interface in the usual sense. It answers ARP requests at the protocol level. A client on the LAN broadcasts an ARP query, the leading node responds with its MAC address, and the client's ARP table now maps the Service IP to that node's hardware address. Traffic flows to the node, hits &lt;code&gt;iptables&lt;/code&gt;/IPVS rules installed by kube-proxy, and gets DNAT'd to a pod endpoint.&lt;/p&gt;

&lt;p&gt;When the leader node goes down, the other speakers notice through MetalLB's memberlist-based election, a new leader is chosen, and it sends a gratuitous ARP. Gratuitous ARP is an unsolicited "hey, this IP is at my MAC now" broadcast that updates every device's ARP cache without them asking. That's the failover. It takes a few seconds for caches to update, which is why L2 failover is measured in seconds rather than milliseconds.&lt;/p&gt;

&lt;p&gt;The reason this is all Layer 2 and not Layer 3 is also the reason for the biggest constraint: ARP doesn't cross subnet boundaries. A router won't forward an ARP request from one VLAN into another. So the Service IP, the nodes, and the client all have to sit on the same broadcast domain. The instant you want a Service IP that lives on a different VLAN than your nodes, plain L2 mode stops being enough, and you're into either BGP or careful per-interface advertisement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The port-ownership trap
&lt;/h2&gt;

&lt;p&gt;Here's the failure that eats the most debugging time, and it's the one tutorials never warn you about. The IP gets assigned. &lt;code&gt;kubectl get svc&lt;/code&gt; shows the EXTERNAL-IP. You &lt;code&gt;telnet&lt;/code&gt; the port and it connects. Everything says "working." But the actual application returns connection resets or timeouts, and you can't figure out why a connection that &lt;em&gt;succeeds&lt;/em&gt; is also broken.&lt;/p&gt;

&lt;p&gt;A successful TCP handshake to a MetalLB IP proves exactly one thing: a node owns the IP and accepted the connection. It says nothing about whether a healthy backend pod exists behind it. The classic cause is &lt;code&gt;externalTrafficPolicy&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama&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;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LoadBalancer&lt;/span&gt;
  &lt;span class="na"&gt;externalTrafficPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Local&lt;/span&gt;  &lt;span class="c1"&gt;# the trap&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama&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;11434&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;11434&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;externalTrafficPolicy: Local&lt;/code&gt;, traffic is only delivered to pods on the same node that received it, and the source IP is preserved. The problem: MetalLB elects a leader node to announce the IP, but if your single Ollama pod is scheduled on a &lt;em&gt;different&lt;/em&gt; node, the leader has no local endpoint. Traffic arrives, finds nothing local to forward to, and gets dropped. The TCP connect still succeeds because the node owns the IP; the request just dies after that.&lt;/p&gt;

&lt;p&gt;MetalLB is supposed to handle this by only announcing from nodes that have a ready endpoint, but the interaction with a single-replica workload pinned to one node is genuinely confusing the first time you meet it. The fix, when you don't need to preserve the client source IP, is &lt;code&gt;externalTrafficPolicy: Cluster&lt;/code&gt;. That lets any node accept the traffic and forward it internally to wherever the pod actually runs, at the cost of an extra hop and a SNAT'd source address.&lt;/p&gt;

&lt;p&gt;The lesson generalizes past MetalLB: stop treating "the port is open" as "the service works." Verify the full path.&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. Did MetalLB assign an IP from the pool?&lt;/span&gt;
kubectl get svc &lt;span class="nt"&gt;-A&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;LoadBalancer

&lt;span class="c"&gt;# 2. Which node is announcing it? (look for the assigned IP)&lt;/span&gt;
kubectl logs &lt;span class="nt"&gt;-n&lt;/span&gt; metallb-system &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;component&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;speaker | &lt;span class="nb"&gt;grep &lt;/span&gt;10.0.0.200

&lt;span class="c"&gt;# 3. From ANOTHER host on the same VLAN, does the IP answer ARP?&lt;/span&gt;
arping &lt;span class="nt"&gt;-I&lt;/span&gt; eth0 10.0.0.200

&lt;span class="c"&gt;# 4. Does the backend actually respond, not just accept the connection?&lt;/span&gt;
curl &lt;span class="nt"&gt;-v&lt;/span&gt; http://10.0.0.200:11434/api/tags
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That step 3 is the one people skip. &lt;code&gt;arping&lt;/code&gt; confirms the IP is genuinely claimed at Layer 2 on your network, independent of anything Kubernetes thinks. If &lt;code&gt;arping&lt;/code&gt; gets no reply but &lt;code&gt;kubectl&lt;/code&gt; swears the IP is assigned, your speaker isn't announcing on the interface you expect, and you've found your problem before wasting an hour in application logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hybrid VLAN reality
&lt;/h2&gt;

&lt;p&gt;Most tutorials assume a flat network where every node has one NIC on one subnet. Real labs are segmented. You might want ingress traffic on one VLAN and management on another, with nodes carrying multiple interfaces.&lt;/p&gt;

&lt;p&gt;MetalLB v0.15.x handles this through interface and node selectors on the &lt;code&gt;L2Advertisement&lt;/code&gt;. You can scope a pool so it only ever announces on a specific NIC, which keeps an ingress IP from accidentally being advertised on your management network.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;L2Advertisement&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;ingress-vlan&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;metallb-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ipAddressPools&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-pool&lt;/span&gt;
  &lt;span class="na"&gt;interfaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;eth1&lt;/span&gt;          &lt;span class="c1"&gt;# only announce on the ingress-facing NIC&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelectors&lt;/span&gt;&lt;span class="pi"&gt;:&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;node-role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ingress&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where L2's same-subnet constraint bites hardest. Each pool's addresses still have to be reachable on the broadcast domain of the interface announcing them. You can segment which IPs go where, but you can't make ARP magically route across VLANs. If your design genuinely needs Service IPs that live on subnets your nodes don't touch, that's the signal to either bring those VLANs to the nodes as tagged interfaces or move to BGP and let the router do the routing it's built for.&lt;/p&gt;

&lt;h2&gt;
  
  
  MetalLB as the foundation, not the finish line
&lt;/h2&gt;

&lt;p&gt;A MetalLB IP is rarely the whole story. It's the stable entry point everything else builds on. The typical flow looks like this:&lt;/p&gt;

&lt;p&gt;External client → MetalLB IP → ingress controller Service → Ingress/IngressRoute → backend Service → pod.&lt;/p&gt;

&lt;p&gt;MetalLB hands &lt;code&gt;ingress-nginx&lt;/code&gt; or Traefik a single, stable LAN IP. Your ingress controller terminates TLS and routes by hostname to dozens of backends. You point a wildcard DNS record at that one MetalLB IP, and now &lt;code&gt;*.lab.example.com&lt;/code&gt; resolves to your cluster. This is also the precondition for automated certificates: &lt;a href="https://guatulabs.dev/posts/cert-manager-cloudflare-dns-01-automated-tls-for-everything/" rel="noopener noreferrer"&gt;cert-manager with a DNS-01 solver&lt;/a&gt; needs your ingress reachable at a known address before it can prove ownership and issue certs. MetalLB provides the address; cert-manager provides the identity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;Ingress&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;ollama&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cert-manager.io/cluster-issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt&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;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ollama.lab.example.com&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama-tls&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ollama.lab.example.com&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&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;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&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;ollama&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;11434&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That Ingress doesn't reference a MetalLB IP directly, and that's the point. It targets the ingress controller's Service, which &lt;em&gt;is&lt;/em&gt; the MetalLB-assigned IP. The same pattern extends upward to the Gateway API: a Gateway's listener address comes from a &lt;code&gt;LoadBalancer&lt;/code&gt; Service, so &lt;code&gt;gatewayAddresses&lt;/code&gt; in tools layered on top ultimately resolve to an IP MetalLB allocated. Get the bottom layer right and everything above inherits a stable address.&lt;/p&gt;

&lt;p&gt;If you want a Service to keep a fixed IP across restarts, annotate it (the deprecated &lt;code&gt;spec.loadBalancerIP&lt;/code&gt; is the trap here):&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;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metallb.io/loadBalancerIPs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.0.210&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;A few things I wish I'd internalized before the first install.&lt;/p&gt;

&lt;p&gt;Carve the IP pool out of DHCP on day one. The single most common MetalLB incident is an address conflict because the pool overlapped the DHCP lease range, and your router handed the same IP to a laptop. Decide the block, exclude it in your DHCP server, and document it. This is boring and it prevents a class of intermittent failures that are miserable to diagnose.&lt;/p&gt;

&lt;p&gt;Default to &lt;code&gt;externalTrafficPolicy: Cluster&lt;/code&gt; unless you have a specific reason to preserve source IPs. Source IP preservation matters for some applications, geo-logic, and rate limiting, but if you don't need it, &lt;code&gt;Local&lt;/code&gt; only buys you the single-node-endpoint trap. Reach for &lt;code&gt;Cluster&lt;/code&gt; first and switch to &lt;code&gt;Local&lt;/code&gt; deliberately when a workload demands the real client IP.&lt;/p&gt;

&lt;p&gt;Treat the &lt;code&gt;arping&lt;/code&gt; check as a permanent part of your toolkit, not a one-time debug step. Separating "is the pod running" from "is the network path open" is the difference between an hour of log-reading and a thirty-second confirmation. MetalLB sits exactly at the seam between Kubernetes and your physical network, so when it misbehaves, you have to test both sides independently.&lt;/p&gt;

&lt;p&gt;And keep the whole config in Git. The pool, the advertisement, and the per-Service annotations are small, declarative, and exactly the kind of thing that drifts when you edit it by hand at 11pm. Folding MetalLB into a GitOps repo alongside the rest of the cluster, the same way I treat &lt;a href="https://guatulabs.dev/posts/kubernetes-storage-on-bare-metal-longhorn-in-practice/" rel="noopener noreferrer"&gt;Longhorn for bare-metal storage&lt;/a&gt;, means the networking layer is as reproducible as everything else. Bare-metal Kubernetes is mostly the work of rebuilding the conveniences a cloud provider gave you for free, one controller at a time. If you're standing up this kind of infrastructure and would rather not rediscover every trap yourself, &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;that's the kind of work I do&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The surprise, looking back, was how little of the difficulty was MetalLB itself. The install is two Helm commands and two small CRDs. The hard part was everything around it: which subnet, which VLAN, which traffic policy, and the stubborn assumption that a connection accepting means a service working. Get those right and the &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; that started all of this never comes back.&lt;/p&gt;

</description>
      <category>metallb</category>
      <category>kubernetes</category>
      <category>baremetal</category>
      <category>networking</category>
    </item>
    <item>
      <title>OpenTofu State in MinIO: An S3-Compatible Backend for IaC</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sun, 21 Jun 2026 10:15:26 +0000</pubDate>
      <link>https://dev.to/futhgar/opentofu-state-in-minio-an-s3-compatible-backend-for-iac-155l</link>
      <guid>https://dev.to/futhgar/opentofu-state-in-minio-an-s3-compatible-backend-for-iac-155l</guid>
      <description>&lt;p&gt;A &lt;code&gt;.tfstate&lt;/code&gt; file on your laptop is a single point of failure with no redundancy and no audit trail. The first time a GitHub Actions runner applies against the same infrastructure you just touched locally, you get drift, a lock error, or the failure mode nobody warns you about: a perfectly clean apply that quietly tears down a container. None of that is a state-storage problem in isolation, but remote state is where you fix the storage half and where you have to start thinking about the destruction half.&lt;/p&gt;

&lt;p&gt;If you run infrastructure-as-code against a homelab or any self-hosted environment, you already have a perfectly good S3 endpoint sitting there: MinIO. You probably stood it up for backups. It can hold your OpenTofu state too, inside your own perimeter, with no Terraform Cloud account and no real AWS bill. The catch is that "S3-compatible" hides a few sharp edges, and pointing CI at your real compute changes the blast radius of a typo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should care
&lt;/h2&gt;

&lt;p&gt;This is for people running OpenTofu (or Terraform) against on-prem providers like Proxmox, where the resources under management are real boxes you'd rather not lose. If your state already lives in AWS S3 with DynamoDB locking and you're happy, you don't need this. The interesting territory is self-hosted: you want cloud-native state management without renting the cloud, and the provider you're driving can replace resources in ways a managed cloud rarely does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;p&gt;The obvious starting point is local state. It works, right up until it doesn't. You write your &lt;code&gt;.tf&lt;/code&gt; files, run &lt;code&gt;tofu apply&lt;/code&gt;, and the state lands in &lt;code&gt;terraform.tfstate&lt;/code&gt; next to your code. Then you add a CI pipeline, the runner starts from an empty checkout with no state, and OpenTofu happily plans to create everything you already created. Now you've got two sources of truth and a plan that wants to make duplicates.&lt;/p&gt;

&lt;p&gt;The next instinct is to gitignore the state and commit it anyway "just for CI," or to scp it into the runner. Both are bad. State holds resource IDs and, depending on your providers, secrets in plaintext. It does not belong in Git. So remote state it is, and since MinIO is already running, the S3 backend looks like a five-minute job.&lt;/p&gt;

&lt;p&gt;It is not a five-minute job the first time. My first MinIO backend block was copied from an AWS example with the endpoint swapped in:&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"opentofu-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"proxmox/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
    &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://minio.example.com"&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;tofu init&lt;/code&gt; rejected &lt;code&gt;endpoint&lt;/code&gt; as deprecated, and once I got past that, the AWS SDK underneath tried to reach &lt;code&gt;opentofu-state.minio.example.com&lt;/code&gt;. That hostname does not exist and the wildcard cert doesn't cover it, so I got TLS and DNS errors that looked like a MinIO problem and were actually an addressing-style problem. Then OpenTofu tried to call the AWS instance-metadata endpoint to figure out a region and credentials, which obviously goes nowhere on a bare-metal runner. Every error pointed at a different layer. None of them said "you forgot path-style addressing," which is what every single one of them meant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual solution
&lt;/h2&gt;

&lt;p&gt;Here's the backend block that actually works against MinIO with current OpenTofu. The shape of this changed across versions, so the keys matter:&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"opentofu-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"proxmox/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt; &lt;span class="c1"&gt;# arbitrary, but required by the SDK&lt;/span&gt;

    &lt;span class="nx"&gt;endpoints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://minio.example.com"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;use_path_style&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# bucket in the path, not the hostname&lt;/span&gt;
    &lt;span class="nx"&gt;use_lockfile&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# S3-native locking, no DynamoDB&lt;/span&gt;
    &lt;span class="nx"&gt;skip_credentials_validation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;skip_requesting_account_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;skip_metadata_api_check&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;skip_region_validation&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of those lines are the whole game. &lt;code&gt;endpoints = { s3 = ... }&lt;/code&gt; replaced the old top-level &lt;code&gt;endpoint&lt;/code&gt; attribute. &lt;code&gt;use_path_style = true&lt;/code&gt; is the rename of what older guides call &lt;code&gt;force_path_style&lt;/code&gt;. The four &lt;code&gt;skip_*&lt;/code&gt; flags stop the AWS SDK from trying to validate credentials, look up an account ID, hit the metadata service, or sanity-check the region against AWS's real region list. On MinIO every one of those checks either fails or wastes time.&lt;/p&gt;

&lt;p&gt;Credentials do not go in the block. Keep them out of &lt;code&gt;.tf&lt;/code&gt; files entirely and feed them through the standard AWS environment variables, which the S3 backend reads natively:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&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;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s1"&gt;'op://Infra/minio-opentofu/access-key'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&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;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s1"&gt;'op://Infra/minio-opentofu/secret-key'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
tofu init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That access key should not be your MinIO root user. Create a dedicated user with a policy scoped to exactly the one bucket OpenTofu touches. Least privilege here is the difference between "a leaked CI secret can read one state file" and "a leaked CI secret owns your entire object store." This is the same mindset I wrote about for &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;agent service accounts&lt;/a&gt;: give the automation the smallest grant that lets it do its job.&lt;/p&gt;

&lt;p&gt;The MinIO policy looks like this:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:ListBucket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:GetBucketLocation"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::opentofu-state"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:DeleteObject"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::opentofu-state/*"&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="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ListBucket&lt;/code&gt; and &lt;code&gt;GetBucketLocation&lt;/code&gt; are on the bucket itself. The object actions live on the &lt;code&gt;/*&lt;/code&gt; path. &lt;code&gt;DeleteObject&lt;/code&gt; is non-optional even though that surprises people: the lockfile is written before an operation and deleted after, so without delete you can acquire a lock and never release it. Apply it with the MinIO client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mc admin policy create &lt;span class="nb"&gt;local &lt;/span&gt;opentofu-state ./opentofu-state-policy.json
mc admin user add &lt;span class="nb"&gt;local &lt;/span&gt;opentofu-ci &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s1"&gt;'op://Infra/minio-opentofu/secret-key'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
mc admin policy attach &lt;span class="nb"&gt;local &lt;/span&gt;opentofu-state &lt;span class="nt"&gt;--user&lt;/span&gt; opentofu-ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Wiring it into CI
&lt;/h3&gt;

&lt;p&gt;In GitHub Actions, the MinIO credentials come from repository secrets and get injected as the AWS env vars the backend expects. The job stays small:&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="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;OpenTofu Init&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MINIO_ACCESS_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MINIO_SECRET_KEY }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tofu init&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;OpenTofu Plan&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MINIO_ACCESS_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.MINIO_SECRET_KEY }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tofu plan -detailed-exitcode -out=tfplan&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-detailed-exitcode&lt;/code&gt; is the piece most pipelines skip. It returns 0 for no changes, 2 for a non-empty plan, and 1 for an error. That lets you gate apply on human approval whenever the plan is non-empty, instead of letting every push to main run straight through to &lt;code&gt;apply -auto-approve&lt;/code&gt;. If your runner can't reach MinIO over the public internet, this is also where a Tailscale step earns its keep; I covered that access pattern in &lt;a href="https://guatulabs.dev/posts/tailscale-subnet-router-remote-access-without-traditional-vpn/" rel="noopener noreferrer"&gt;Tailscale subnet routers&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The safety net that actually matters
&lt;/h3&gt;

&lt;p&gt;Storing state remotely solves the "where does the truth live" problem. It does nothing for the "a successful apply destroyed something" problem, and on Proxmox that problem is real. The bpg and Telmate providers both have attributes marked &lt;code&gt;ForceNew&lt;/code&gt;. Change one of those on an LXC, the network bridge or the rootfs storage, and the provider cannot update in place. It plans a destroy followed by a create. Replacing an LXC means deleting the container and its filesystem, then building a fresh one. The data that lived on it does not come back.&lt;/p&gt;

&lt;p&gt;Read that plan by hand and you'd catch it instantly: &lt;code&gt;-/+ destroy and then create replacement&lt;/code&gt;. Run it under &lt;code&gt;-auto-approve&lt;/code&gt; in CI off a push to main, and the box is gone before anyone looks at the diff. Remote state didn't cause that, but remote state is what made unattended applies easy, so it's the moment to add guards. The first guard is a lifecycle block on anything stateful:&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_environment_container"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ... container config ...&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;prevent_destroy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;prevent_destroy = true&lt;/code&gt;, any plan that would delete or replace this resource fails at plan time with an error instead of proceeding. That's the behavior you want: the pipeline stops and shouts rather than quietly doing the destructive thing. When you genuinely need to replace the resource, you remove the block deliberately, in a reviewed commit, with your eyes open. The same defensive instinct shows up in provider-driven recreation elsewhere; I hit a version of it with hardware, where a card got replaced out from under me, in &lt;a href="https://guatulabs.dev/posts/gpu-passthrough-on-proxmox-gotcha-guide/" rel="noopener noreferrer"&gt;the GPU passthrough field guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it works
&lt;/h2&gt;

&lt;p&gt;Two mechanics are worth understanding instead of cargo-culting, because both will bite you again in other contexts.&lt;/p&gt;

&lt;p&gt;The path-style flag comes down to URL construction. AWS S3 prefers virtual-hosted-style addressing: the bucket becomes a subdomain, &lt;code&gt;bucket.s3.amazonaws.com&lt;/code&gt;. MinIO serves buckets path-style by default: &lt;code&gt;minio.example.com/bucket&lt;/code&gt;. When the SDK defaults to virtual-hosted style against MinIO, it builds &lt;code&gt;opentofu-state.minio.example.com&lt;/code&gt;, which has no DNS record and no matching certificate. Setting &lt;code&gt;use_path_style = true&lt;/code&gt; tells the SDK to keep the bucket in the path. Every confusing TLS-and-DNS error I got was this and only this, wearing four different costumes.&lt;/p&gt;

&lt;p&gt;State locking is the other one. The classic Terraform/OpenTofu S3 backend needed a DynamoDB table to hold a lock item, because plain S3 historically had no way to do an atomic "create this only if it doesn't already exist." Self-hosters without DynamoDB faked it with sidecar services or just turned locking off and prayed. Recent OpenTofu (1.10+) added &lt;code&gt;use_lockfile = true&lt;/code&gt;, which writes a &lt;code&gt;.tflock&lt;/code&gt; object next to your state and relies on S3 conditional writes (&lt;code&gt;PutObject&lt;/code&gt; with &lt;code&gt;If-None-Match&lt;/code&gt;) to make lock acquisition atomic. MinIO implements conditional writes, so the lock is honest: a second apply that arrives while the first holds the lock gets refused at the object-store level, not by a polite convention. No extra database, no extra moving part. That's why the same MinIO instance you use for &lt;a href="https://guatulabs.dev/posts/velero-minio-kubernetes-backup-strategy-for-bare-metal/" rel="noopener noreferrer"&gt;Velero Kubernetes backups&lt;/a&gt; can back your IaC state without bolting anything else on. One S3 layer, two jobs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prevent_destroy&lt;/code&gt; works because it intercepts the plan graph before execution. OpenTofu walks the dependency graph, decides each resource needs no-op, update, create, or replace, and a lifecycle guard converts any planned delete on that resource into a hard error. It is a plan-time veto, not a runtime one, which is exactly why it's safe in CI: nothing has touched real infrastructure yet when it fires.&lt;/p&gt;

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

&lt;p&gt;The version churn on the S3 backend is the thing that wasted the most of my time, and most of it is invisible until &lt;code&gt;init&lt;/code&gt; complains. &lt;code&gt;endpoint&lt;/code&gt; became &lt;code&gt;endpoints.s3&lt;/code&gt;. &lt;code&gt;force_path_style&lt;/code&gt; became &lt;code&gt;use_path_style&lt;/code&gt;. DynamoDB locking became &lt;code&gt;use_lockfile&lt;/code&gt;. If you're following a guide written for an older OpenTofu or for Terraform pre-1.10, half the keys are renamed and you'll burn an afternoon on deprecation warnings that don't clearly explain the replacement. Check the backend docs for your exact version before copying anyone's block, including mine.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prevent_destroy&lt;/code&gt; is a seatbelt, not an airbag, and it has a real gap: it only protects a resource that's still in your configuration. Delete the resource block from your &lt;code&gt;.tf&lt;/code&gt; files and the lifecycle rule goes with it, so the next plan cheerfully schedules the destroy with nothing left to veto it. Treat removing a stateful resource from config as a destructive operation in its own right, and review those diffs as carefully as you'd review an apply.&lt;/p&gt;

&lt;p&gt;The destruction risk reframed how I think about remote state entirely. Moving state into MinIO felt like a storage decision, but the real change is that you've made unattended applies cheap, and cheap unattended applies against real compute need guardrails that local-state workflows never forced you to build. &lt;code&gt;-detailed-exitcode&lt;/code&gt; to gate on human review, &lt;code&gt;prevent_destroy&lt;/code&gt; on anything holding data, and a habit of actually reading replacement plans matter more than the backend block itself. This is the part of the workflow that complements the GitHub Actions automation I described in &lt;a href="https://guatulabs.dev/posts/automating-infrastructure-with-opentofu-and-github-actions/" rel="noopener noreferrer"&gt;automating OpenTofu with GitHub Actions&lt;/a&gt;: the automation gets you speed, the guards keep the speed from being a liability.&lt;/p&gt;

&lt;p&gt;Credentials were the last lesson, and an easy one to get lazy about. The MinIO key OpenTofu uses should be scoped to one bucket, stored in a secret manager, and pulled into env vars at runtime rather than parked in a &lt;code&gt;.tf&lt;/code&gt; file or, worse, a shell history. If you've ever fought your shell over special characters in an automation token, you already know how these leak; I wrote about one such trap in &lt;a href="https://guatulabs.dev/posts/proxmox-api-tokens-bash-history-expansion-and-the-character/" rel="noopener noreferrer"&gt;Proxmox API tokens and the &lt;code&gt;!&lt;/code&gt; character&lt;/a&gt;. A scoped key plus a lifecycle guard turns the worst-case CI accident from "lost the cluster" into "lost one state file I can restore from a versioned bucket."&lt;/p&gt;

&lt;p&gt;That last point is the quiet upside of running this on MinIO instead of a SaaS backend. Turn on bucket versioning and your state file gets the same history your backups do, inside the same perimeter, under the same retention you already control. If you want help designing that kind of self-hosted IaC and storage layer for a production environment, that's the sort of thing I do through &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;GuatuLabs&lt;/a&gt;. The state of your infrastructure is some of the most sensitive data you have. Keeping it on hardware you own, behind credentials you scoped, with guards that refuse to delete the wrong box, is a reasonable thing to want, and it's maybe a weekend of work once you know which four flags actually matter.&lt;/p&gt;

</description>
      <category>opentofu</category>
      <category>minio</category>
      <category>infrastructureascode</category>
      <category>cicd</category>
    </item>
    <item>
      <title>CloudNativePG: Running PostgreSQL in Kubernetes Without the Pain</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Tue, 16 Jun 2026 00:15:32 +0000</pubDate>
      <link>https://dev.to/futhgar/cloudnativepg-running-postgresql-in-kubernetes-without-the-pain-32pj</link>
      <guid>https://dev.to/futhgar/cloudnativepg-running-postgresql-in-kubernetes-without-the-pain-32pj</guid>
      <description>&lt;p&gt;A CloudNativePG cluster that sits in &lt;code&gt;Setting up primary&lt;/code&gt; forever, with zero error events on the Cluster resource and a perfectly healthy operator, is one of the more frustrating ways to spend an afternoon. The operator says it's working. The pods never appear. And the actual cause has nothing to do with the database at all.&lt;/p&gt;

&lt;p&gt;Running stateful databases on Kubernetes used to be the thing everyone told you not to do. CloudNativePG (CNPG) changed that calculus for a lot of people, including me. It's a proper operator: it handles failover, backups, connection routing, and rolling upgrades through native Kubernetes primitives instead of bolting Postgres onto a StatefulSet and praying. If you run a hardened cluster with admission controllers, network policies, and least-privilege RBAC, this post is about the friction you'll hit that the quickstart never mentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should care
&lt;/h2&gt;

&lt;p&gt;If your cluster is vanilla, &lt;code&gt;kubectl apply&lt;/code&gt; the operator and a &lt;code&gt;Cluster&lt;/code&gt; manifest, and you're done in ten minutes. The CNPG docs are genuinely good for that path. This is for the rest of us: people running Kyverno or OPA Gatekeeper, self-signed cert chains, and the kind of policy-as-code setup where every workload has to justify its existence. That's where CNPG stops being a ten-minute install and starts being an integration project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;p&gt;The first instinct, when a CNPG cluster hangs, is to assume you got the database config wrong. So you go read your &lt;code&gt;Cluster&lt;/code&gt; manifest line by line. You check the storage class. You check that the PVC bound. You bump the operator log level and watch it cheerfully report that it's reconciling, over and over, with no complaints.&lt;/p&gt;

&lt;p&gt;Here's the trap: the CNPG operator doesn't run &lt;code&gt;initdb&lt;/code&gt; itself. It creates a Kubernetes &lt;strong&gt;Job&lt;/strong&gt; to bootstrap the primary. That Job spawns a Pod. And in a hardened cluster, the Pod is where everything dies, because your admission controller is judging it against policies the operator's own Pods were exempted from but the bootstrap Job was not.&lt;/p&gt;

&lt;p&gt;The mistake I see constantly is reading the wrong resource. People &lt;code&gt;kubectl describe cluster&lt;/code&gt; and &lt;code&gt;kubectl describe pod&lt;/code&gt; on the operator, find nothing, and conclude CNPG is broken. The events you need are on the &lt;strong&gt;Job&lt;/strong&gt; and on the Pod the Job tries to create. A blocked Pod creation shows up as an event on the Job's owning controller, not on the Cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The Cluster looks stuck here, but says nothing useful&lt;/span&gt;
kubectl get cluster &lt;span class="nt"&gt;-n&lt;/span&gt; databases
&lt;span class="c"&gt;# NAME       AGE   INSTANCES   READY   STATUS                    PRIMARY&lt;/span&gt;
&lt;span class="c"&gt;# pg-main    8m    3           0       Setting up primary&lt;/span&gt;

&lt;span class="c"&gt;# The real story is on the bootstrap Job's events&lt;/span&gt;
kubectl describe job &lt;span class="nt"&gt;-n&lt;/span&gt; databases pg-main-1-initdb


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a policy is the culprit, that describe output is where you'll finally see something like &lt;code&gt;admission webhook "validate.kyverno.svc" denied the request: validation error: every container must define resource limits&lt;/code&gt;. The bootstrap Job's Pod template didn't set CPU/memory limits, your &lt;code&gt;require-resource-limits&lt;/code&gt; policy rejected it, and the operator quietly retries forever because, from its perspective, it asked Kubernetes nicely and Kubernetes said no.&lt;/p&gt;

&lt;p&gt;I spent longer than I'd like to admit assuming the storage layer was at fault before I went and looked at the Job. The lesson stuck: when an operator hangs, find the resource the operator &lt;em&gt;creates&lt;/em&gt;, not the resource it &lt;em&gt;manages&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual solution
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Exempt CNPG lifecycle resources from blocking policies
&lt;/h3&gt;

&lt;p&gt;CNPG generates Jobs and Pods on your behalf, and you can't directly edit their pod templates the way you would a Deployment you wrote. So the fix isn't to add resource limits to the Job. It's to teach your policy engine that CNPG-owned resources are allowed to skip the rule that's blocking them.&lt;/p&gt;

&lt;p&gt;Every resource CNPG creates carries the &lt;code&gt;cnpg.io/cluster&lt;/code&gt; label. That label is your exclusion key. For Kyverno, add an &lt;code&gt;exclude&lt;/code&gt; block to the rule that's firing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kyverno.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;ClusterPolicy&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;require-resource-limits&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;validationFailureAction&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enforce&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;validate-resources&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pod"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="c1"&gt;# CNPG-managed Pods (instances + bootstrap Jobs) carry this label&lt;/span&gt;
              &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;cnpg.io/cluster&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;validate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Every&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;container&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;must&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;define&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;CPU&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;memory&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;limits."&lt;/span&gt;
        &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;?*"&lt;/span&gt;
                    &lt;span class="na"&gt;cpu&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a deliberately narrow exclusion. You're not disabling the policy. You're carving out resources that match a specific operator-owned label, which means a developer can't accidentally smuggle a limitless Pod past the gate by slapping a random label on it. If you want to be stricter, scope the exclusion to the &lt;code&gt;databases&lt;/code&gt; namespace as well so the label only grants an exemption where CNPG is actually allowed to run.&lt;/p&gt;

&lt;p&gt;The same idea applies to OPA Gatekeeper, just expressed differently: add the label to the constraint's &lt;code&gt;match.excludedNamespaces&lt;/code&gt; or write a &lt;code&gt;labelSelector&lt;/code&gt; exclusion in the constraint spec. The principle doesn't change. Match the operator's label, exempt the lifecycle resources, leave everything else under enforcement. I wrote about the general shape of this in &lt;a href="https://dev.to/posts/kyverno-admission-controllers-policy-as-code-that-actually-works/"&gt;Kyverno Admission Controllers: Policy-as-Code That Actually Works&lt;/a&gt;, and CNPG's &lt;code&gt;initdb&lt;/code&gt; Job is the cleanest real-world example I've found of policy breaking infrastructure in a way that's invisible until you know where to look.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Give the operator the RBAC it actually needs
&lt;/h3&gt;

&lt;p&gt;If you provision service accounts by hand instead of trusting the operator's defaults, remember that CNPG needs to manage Jobs, Pods, PVCs, Secrets, and Services on your behalf. A read-only or overly-scoped account will fail in the same silent way a policy block does: the reconcile loop runs, the create call gets a &lt;code&gt;403&lt;/code&gt;, and nothing visible happens.&lt;/p&gt;

&lt;p&gt;The operator's ClusterRole covers this out of the box. If you're tightening it, the non-obvious permissions are the ability to create and delete Jobs (for &lt;code&gt;initdb&lt;/code&gt; and restores) and to manage PVCs (for volume expansion and replica provisioning). Strip those and your cluster bootstraps fine until the first time it needs to scale or recover, then breaks. I go deeper on scoping accounts like this in &lt;a href="https://dev.to/posts/kubernetes-rbac-building-least-privilege-service-accounts/"&gt;Kubernetes RBAC: Building Least-Privilege Service Accounts&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Pin your PostgreSQL minor version away from 16.4
&lt;/h3&gt;

&lt;p&gt;There's a known regression in PostgreSQL 16.4 where the server can hit a segmentation fault under certain memory conditions on nodes with large amounts of RAM available. If you're running CNPG on beefy worker nodes (16GB+ of available memory is the trigger zone), this is exactly the kind of thing that looks like a CNPG bug, a storage bug, or a kernel OOM, when it's actually upstream Postgres.&lt;/p&gt;

&lt;p&gt;The fix is boring and effective: pin the image to a known-good minor and don't float the tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql.cnpg.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pg-main&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;databases&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;instances&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="c1"&gt;# Pin explicitly. Do not use a floating major-version tag in production.&lt;/span&gt;
  &lt;span class="na"&gt;imageName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/cloudnative-pg/postgresql:16.6&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;20Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;longhorn&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2Gi"&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;500m"&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2Gi"&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the memory &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt; are set to the same value. For a database, you almost never want Postgres getting throttled or evicted because a noisy neighbor ballooned and the scheduler decided your &lt;code&gt;requests&lt;/code&gt; were a polite suggestion. Equal requests and limits put the Pod in the Guaranteed QoS class, which is what you want for a stateful workload you can't afford to lose to memory pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Understand the three Services CNPG hands you
&lt;/h3&gt;

&lt;p&gt;This is the part that pays off long after install. For a cluster named &lt;code&gt;pg-main&lt;/code&gt;, CNPG creates a set of Services automatically, and each one routes to a different role:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Routes to&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg-main-rw&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current primary&lt;/td&gt;
&lt;td&gt;Writes, migrations, anything that mutates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg-main-ro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replicas only&lt;/td&gt;
&lt;td&gt;Read-only queries, reporting, analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg-main-r&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any instance (primary or replica)&lt;/td&gt;
&lt;td&gt;Reads where you don't care which node&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;-rw&lt;/code&gt; Service is the important one: when CNPG fails over, it repoints &lt;code&gt;-rw&lt;/code&gt; at the new primary. Your application doesn't need to know a failover happened. It keeps connecting to &lt;code&gt;pg-main-rw.databases.svc.cluster.local&lt;/code&gt; and the operator handles the rest. That's the entire value proposition of running Postgres under an operator instead of as a hand-rolled StatefulSet.&lt;/p&gt;

&lt;p&gt;For read/write splitting, point your app at two connection strings instead of one. Most ORMs and connection libraries support a primary/replica config:&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;# In your app's config or Secret&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;DATABASE_URL_PRIMARY&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;postgresql://app:$(PGPASSWORD)@pg-main-rw.databases.svc.cluster.local:5432/appdb"&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;DATABASE_URL_REPLICA&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;postgresql://app:$(PGPASSWORD)@pg-main-ro.databases.svc.cluster.local:5432/appdb"&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send &lt;code&gt;SELECT&lt;/code&gt;s that tolerate slight replication lag to &lt;code&gt;-ro&lt;/code&gt;, and send everything else to &lt;code&gt;-rw&lt;/code&gt;. The catch worth stating plainly: replicas are asynchronous by default, so a read immediately after a write can return stale data. If you need read-your-writes consistency for a given query, send it to &lt;code&gt;-rw&lt;/code&gt;. Don't blanket-route all reads to replicas and then act surprised when a user doesn't see the row they just created.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Connection SSL: the untrusted-certificate wall
&lt;/h3&gt;

&lt;p&gt;CNPG enables TLS by default and issues its own certificates through an internal CA. That's good for in-cluster security and annoying the first time a client refuses to connect because it doesn't trust the CA.&lt;/p&gt;

&lt;p&gt;The error you'll see from a client is some flavor of &lt;code&gt;SSL error: certificate verify failed&lt;/code&gt; or &lt;code&gt;self-signed certificate in certificate chain&lt;/code&gt;. The wrong reaction is to globally disable TLS on the cluster. The right reaction depends on who's connecting:&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;# In-cluster clients: trust CNPG's CA. The operator publishes it as a Secret.&lt;/span&gt;
kubectl get secret pg-main-ca &lt;span class="nt"&gt;-n&lt;/span&gt; databases &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;'{.data.ca\.crt}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ca.crt
&lt;span class="c"&gt;# Then point the client at it:&lt;/span&gt;
&lt;span class="c"&gt;# postgresql://...?sslmode=verify-full&amp;amp;sslrootcert=/etc/pg/ca.crt&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For clients that genuinely can't do certificate verification (some managed platforms and serverless backends only support a binary "SSL on/off" toggle and can't be handed a custom CA), you have two honest options. Either set &lt;code&gt;sslmode=require&lt;/code&gt; on the client, which encrypts the connection but skips CA verification, or terminate trust at a proxy you control. &lt;code&gt;sslmode=require&lt;/code&gt; is the pragmatic middle ground: you keep encryption in transit and drop only the identity check. It's not as strong as &lt;code&gt;verify-full&lt;/code&gt;, but it's a deliberate, documented tradeoff rather than turning TLS off entirely.&lt;/p&gt;

&lt;p&gt;Here's the quick reference I keep around for the &lt;code&gt;sslmode&lt;/code&gt; ladder:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;sslmode&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Encrypted?&lt;/th&gt;
&lt;th&gt;Verifies CA?&lt;/th&gt;
&lt;th&gt;Verifies hostname?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;disable&lt;/code&gt;&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;&lt;code&gt;require&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&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;&lt;code&gt;verify-ca&lt;/code&gt;&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;&lt;code&gt;verify-full&lt;/code&gt;&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;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Aim for &lt;code&gt;verify-full&lt;/code&gt; for anything in-cluster, where you control the CA distribution. Drop to &lt;code&gt;require&lt;/code&gt; only for external clients that can't be handed the CA, and never to &lt;code&gt;disable&lt;/code&gt;. If you're already running cluster-wide TLS automation, the CA-distribution problem is the same one cert-manager solves for ingress; I covered that workflow in &lt;a href="https://dev.to/posts/cert-manager-cloudflare-dns-01-automated-tls-for-everything/"&gt;cert-manager + Cloudflare DNS-01: Automated TLS for Everything&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Exposing pgAdmin without poking a hole in the cluster
&lt;/h3&gt;

&lt;p&gt;You'll eventually want a GUI to poke at the database. The pattern I'd reach for is pgAdmin4 in its own namespace, reachable through your existing ingress controller, never exposed directly. Keep it in a separate namespace from the database so your network policies can treat it as an external-ish client that's explicitly allowed to reach the &lt;code&gt;-rw&lt;/code&gt;/&lt;code&gt;-ro&lt;/code&gt; Services, rather than something that lives inside the data tier.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;Ingress&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;pgadmin&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;pgadmin&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Force HTTPS and lean on cert-manager for the cert&lt;/span&gt;
    &lt;span class="na"&gt;cert-manager.io/cluster-issuer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/ssl-redirect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="c1"&gt;# pgAdmin needs a bigger body size for imports/exports&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/proxy-body-size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;16m"&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;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pgadmin.example.com"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pgadmin-tls&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pgadmin.example.com&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&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;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&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;pgadmin&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put authentication in front of it. pgAdmin's own login is fine, but I'd add an ingress-level auth layer (OAuth proxy or basic auth) so a leaked pgAdmin password isn't a direct line to your database. And lock down the NetworkPolicy so only the pgAdmin namespace can reach the database Services. A database admin GUI on the public internet with default credentials is how clusters become someone else's crypto miner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it works
&lt;/h2&gt;

&lt;p&gt;The thing that finally made CNPG click for me is that it's not pretending Postgres is stateless. It embraces the fact that a database has a primary and replicas, that failover is a real event, and that bootstrapping is a one-time Job rather than a steady-state process. Every piece of the design maps a Postgres concept onto a native Kubernetes object you can inspect with &lt;code&gt;kubectl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's also why the failure modes are sneaky. The operator delegates the actual work to Jobs and Pods, so when an admission controller or RBAC rule blocks one of those, the operator has no good way to surface it beyond a stalled status. There's no exception thrown into your terminal. The reconcile loop is doing exactly what it's designed to do, which is keep trying, and "keep trying against a wall" looks identical to "working" until you go read the Job's events.&lt;/p&gt;

&lt;p&gt;The Service abstraction works because CNPG owns the failover decision and the endpoint update atomically. When it promotes a replica, it updates the &lt;code&gt;-rw&lt;/code&gt; Service's selector in the same control loop. There's no DNS TTL to wait out, no client-side failover logic to get wrong, no floating VIP to manage. Kubernetes Service routing was already solving "send traffic to whichever Pod currently has this role," and CNPG just plugs the primary/replica roles into that existing machinery. Running databases reliably on Kubernetes is the kind of platform-engineering work that separates a homelab toy from production infrastructure, and it's a chunk of what I do in &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;consulting engagements&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;The biggest shift was learning to debug the resources the operator creates, not the ones it manages. &lt;code&gt;kubectl describe cluster&lt;/code&gt; will lie to you by omission. The Job and its Pod tell the truth. If a CNPG cluster hangs in &lt;code&gt;Setting up primary&lt;/code&gt;, my first move now is straight to the bootstrap Job's events, and nine times out of ten it's a policy or RBAC denial, not a database problem.&lt;/p&gt;

&lt;p&gt;What surprised me was how much the hardened-cluster setup matters. Every CNPG tutorial assumes a permissive cluster, so the exact features that make a cluster production-grade (enforced resource limits, least-privilege RBAC, default-deny network policies) are the features that break the install. None of them are CNPG's fault. They're the cost of doing security right, and the fix is always a narrow, labeled exclusion rather than a blanket exception. If you run CNPG via GitOps, put those policy exclusions in the same ArgoCD app as the operator so they're never out of sync; the &lt;a href="https://dev.to/posts/gitops-for-homelabs-argocd-app-of-apps/"&gt;App-of-Apps pattern&lt;/a&gt; handles this cleanly.&lt;/p&gt;

&lt;p&gt;If I were starting over, I'd pin the PostgreSQL minor version from day one and treat floating tags as a production smell, set Guaranteed QoS on the database Pods before the first incident rather than after, and write the read/write split into the application from the start instead of routing everything at the primary and refactoring later. None of those are hard. They're just the kind of decision that's cheap to make early and expensive to retrofit once you have data and uptime to protect.&lt;/p&gt;

&lt;p&gt;CNPG genuinely delivers on running Postgres in Kubernetes without the pain, but only if you account for the cluster you actually have, not the empty one the docs assume. The operator is excellent. The integration with your security posture is the part you own.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>postgres</category>
      <category>cloudnativepg</category>
      <category>database</category>
    </item>
    <item>
      <title>Proxmox Backup Server: Incremental Backups for Your Whole Cluster</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 15 Jun 2026 18:15:32 +0000</pubDate>
      <link>https://dev.to/futhgar/proxmox-backup-server-incremental-backups-for-your-whole-cluster-1pd1</link>
      <guid>https://dev.to/futhgar/proxmox-backup-server-incremental-backups-for-your-whole-cluster-1pd1</guid>
      <description>&lt;p&gt;A full Proxmox cluster rebuild from scratch takes somewhere between a weekend and a week, depending on how much of your config lives in Git versus your head. The VMs and LXCs themselves, the ones with actual state in them, those are the things you can't reconstruct from memory. Proxmox Backup Server (PBS) exists specifically for this problem: deduplicated, incremental backups of your entire virtualization layer, with verification built in.&lt;/p&gt;

&lt;p&gt;If you're running a multi-node Proxmox cluster and your backup strategy is still "I'll just snapshot it manually before I do anything scary," this is the upgrade path. PBS slots into an existing cluster with surprisingly little friction, but the authentication model and a few operational quirks will trip you up if you don't know they're coming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use vzdump to NFS?
&lt;/h2&gt;

&lt;p&gt;The built-in &lt;code&gt;vzdump&lt;/code&gt; tool works. You can schedule backups to an NFS share and call it a day. I've seen plenty of homelabs run this way for years. The problem is what happens at scale.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;vzdump&lt;/code&gt; to a plain NFS target, every backup is a full copy. A 50 GB VM backed up daily for 30 days is 1.5 TB of storage, most of it identical data. PBS changes this fundamentally. It chunks the data, deduplicates across all backups (and across all VMs), and only transfers the changed chunks on subsequent runs. That 1.5 TB becomes something closer to 80-120 GB depending on churn rate.&lt;/p&gt;

&lt;p&gt;The other thing &lt;code&gt;vzdump&lt;/code&gt; alone doesn't give you is backup verification. PBS can mount and verify the integrity of every backup after it completes, checking that the data is actually restorable. That matters more than most people think. A backup you've never tested is just a hope.&lt;/p&gt;

&lt;p&gt;I initially tried running &lt;code&gt;vzdump&lt;/code&gt; backups to a Synology NFS share. It worked, but retention management was manual, dedup was nonexistent, and I had zero confidence that any given backup was actually restorable until I tried. PBS replaced all of that with a single integration point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PBS Placement Decision
&lt;/h2&gt;

&lt;p&gt;Before installing anything, you need to answer one question: where does PBS run?&lt;/p&gt;

&lt;p&gt;There are two reasonable options for a homelab:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1: PBS as a VM on the cluster itself.&lt;/strong&gt; Quick to set up, uses existing hardware, but your backup server lives on the infrastructure it's backing up. If you lose the node hosting PBS, you lose your backup target at the exact moment you need it most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2: PBS on a dedicated machine, physically separate from the cluster.&lt;/strong&gt; This is the correct answer for anything you actually care about. A small mini-PC with a large spinning disk, or even an old desktop with a few terabytes of storage, is enough. The key property is that it's not on the same failure domain as your cluster.&lt;/p&gt;

&lt;p&gt;I'd go with option 2 every time. A used mini-PC with a 4 TB drive costs less than the time you'll spend rebuilding a cluster from scratch. PBS itself is lightweight. It doesn't need much CPU or RAM. What it needs is disk space and network connectivity to your Proxmox nodes.&lt;/p&gt;

&lt;p&gt;If you're running PBS on a NAS via NFS (mounting the NAS storage into a PBS VM), be aware that deduplication performance degrades over NFS compared to local storage. PBS's chunked dedup store does a lot of random I/O, and NFS adds latency to every operation. Local disk or direct-attached storage is preferable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing PBS
&lt;/h2&gt;

&lt;p&gt;PBS installs like any other Debian-based system. Download the ISO from the Proxmox site, boot it, run through the installer. The whole process takes about 10 minutes.&lt;/p&gt;

&lt;p&gt;After installation, you'll access the web UI on port 8007:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://10.0.0.50:8007
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First thing to configure is a datastore, which is just a directory path where PBS will store backup chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the PBS host, create the datastore directory&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/backups/pbs-store

&lt;span class="c"&gt;# Add it via the CLI (or through the web UI under Storage &amp;gt; Datastore)&lt;/span&gt;
proxmox-backup-manager datastore create main-store /mnt/backups/pbs-store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The datastore is where all the deduplicated chunks live. PBS handles the internal structure. You don't need to think about the file layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding PBS as Storage in Proxmox VE
&lt;/h2&gt;

&lt;p&gt;On each Proxmox VE node (or once in a cluster, since storage config is shared), you add the PBS instance as a storage target. This is where the first gotcha lives.&lt;/p&gt;

&lt;p&gt;In the PVE web UI, go to Datacenter &amp;gt; Storage &amp;gt; Add &amp;gt; Proxmox Backup Server. You'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server address (the IP of your PBS host)&lt;/li&gt;
&lt;li&gt;Username and password (or API token)&lt;/li&gt;
&lt;li&gt;Datastore name&lt;/li&gt;
&lt;li&gt;Fingerprint (PBS uses a self-signed cert by default)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fingerprint is available on the PBS dashboard or via:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the PBS host&lt;/span&gt;
proxmox-backup-manager cert info | &lt;span class="nb"&gt;grep &lt;/span&gt;Fingerprint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a basic setup with username/password, this works out of the box. But if you're automating backup jobs or integrating with scripts, you'll want API tokens. And that's where things get interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API Token Authentication Trap
&lt;/h2&gt;

&lt;p&gt;If you've worked with &lt;a href="https://dev.to/posts/proxmox-api-tokens-bash-history-expansion-and-the-character/"&gt;Proxmox API tokens before&lt;/a&gt;, you know PVE uses the format &lt;code&gt;user@realm!tokenname&lt;/code&gt; with the secret passed as a separate header or parameter. PBS uses a similar but subtly different format, and the distinction will cost you hours if you don't catch it early.&lt;/p&gt;

&lt;p&gt;The token format for PBS authentication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# PVE token format (for reference)
&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;@&lt;span class="n"&gt;realm&lt;/span&gt;!&lt;span class="n"&gt;tokenname&lt;/span&gt;    (&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="n"&gt;passed&lt;/span&gt; &lt;span class="n"&gt;separately&lt;/span&gt;)

&lt;span class="c"&gt;# PBS token format in storage config
&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;@&lt;span class="n"&gt;realm&lt;/span&gt;!&lt;span class="n"&gt;tokenname&lt;/span&gt;    (&lt;span class="n"&gt;same&lt;/span&gt; &lt;span class="n"&gt;structure&lt;/span&gt;, &lt;span class="n"&gt;but&lt;/span&gt; &lt;span class="n"&gt;permission&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="n"&gt;differs&lt;/span&gt;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real trap isn't the format. It's privilege separation.&lt;/p&gt;

&lt;p&gt;When you create an API token in PBS, there's a checkbox labeled "Privilege Separation" that defaults to on. With privsep enabled, the token has its own independent permission set, completely separate from the user it belongs to. This means if your user &lt;code&gt;backup@pbs&lt;/code&gt; has &lt;code&gt;DatastoreBackup&lt;/code&gt; and &lt;code&gt;DatastoreAudit&lt;/code&gt; roles on the datastore, but you created the token with privsep on and didn't assign those same roles to the token specifically, the token will authenticate successfully but return empty results or 403 errors on actual operations.&lt;/p&gt;

&lt;p&gt;The fix:&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;# Create a user for backups&lt;/span&gt;
proxmox-backup-manager user create backup@pbs

&lt;span class="c"&gt;# Create a token WITHOUT privilege separation&lt;/span&gt;
proxmox-backup-manager user generate-token backup@pbs pve-integration &lt;span class="nt"&gt;--privsep&lt;/span&gt; 0

&lt;span class="c"&gt;# If you want privsep on (recommended for production), assign roles to the token directly&lt;/span&gt;
proxmox-backup-manager acl update / DatastoreBackup &lt;span class="nt"&gt;--auth-id&lt;/span&gt; backup@pbs!pve-integration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--privsep 0&lt;/code&gt; flag is the quick path for homelabs. The token inherits all permissions from its parent user. For a more locked-down setup, keep privsep on and explicitly grant the token the roles it needs. Either way, test the token before you walk away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify the token can actually list datastore contents&lt;/span&gt;
proxmox-backup-client list &lt;span class="nt"&gt;--repository&lt;/span&gt; &lt;span class="s1"&gt;'backup@pbs!pve-integration@10.0.0.50:main-store'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this returns an empty list (for a new datastore) or your existing backups, you're good. If it returns a 403 or permission error, check the privsep settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scheduling Backup Jobs
&lt;/h2&gt;

&lt;p&gt;With PBS added as a storage target in PVE, you schedule backups the same way you would any other &lt;code&gt;vzdump&lt;/code&gt; job. Datacenter &amp;gt; Backup &amp;gt; Add. Select your PBS storage, pick the VMs and LXCs to include, set the schedule.&lt;/p&gt;

&lt;p&gt;A reasonable starting configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Schedule:     daily at 02:00
Selection:    all VMs and LXCs
Mode:         snapshot (for running machines)
Retention:    keep-last=7, keep-weekly=4, keep-monthly=3
Compression:  zstd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a week of daily recovery points, a month of weekly snapshots, and three months of monthly archives. Because PBS deduplicates, the storage cost of this retention policy is a fraction of what you'd expect.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;snapshot&lt;/code&gt; mode is important. It creates a consistent point-in-time snapshot without stopping the VM. For most workloads this is fine. If you're running a database directly in a VM (not in Kubernetes), consider using the &lt;code&gt;stop&lt;/code&gt; mode or pre-freeze hooks to ensure filesystem consistency.&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;# You can also trigger a one-off backup via CLI&lt;/span&gt;
vzdump 100 &lt;span class="nt"&gt;--storage&lt;/span&gt; pbs-target &lt;span class="nt"&gt;--mode&lt;/span&gt; snapshot &lt;span class="nt"&gt;--compress&lt;/span&gt; zstd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Stale Lock File Problem
&lt;/h2&gt;

&lt;p&gt;Backup jobs will occasionally fail with an error like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;ERROR: backup of VM 101 failed - can't acquire lock '/var/lock/pve-manager/vzdump-101.lck'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This happens when a previous &lt;code&gt;vzdump&lt;/code&gt; process was interrupted (killed, node rebooted during backup, OOM, etc.) and didn't clean up its lock file. The fix is straightforward:&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 for stale lock files&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /var/lock/pve-manager/vzdump-&lt;span class="k"&gt;*&lt;/span&gt;.lck

&lt;span class="c"&gt;# Remove the stale lock (only if no vzdump process is actually running)&lt;/span&gt;
ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;vzdump
&lt;span class="c"&gt;# If no vzdump is running for that VMID:&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/lock/pve-manager/vzdump-101.lck
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a less obvious variant of this problem. On some nodes, the &lt;code&gt;/var/lock/pve-manager/&lt;/code&gt; directory itself can disappear after a reboot. This directory lives on a tmpfs and should be recreated by systemd-tmpfiles on boot. If it's missing:&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;# Recreate the lock directory&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/lock/pve-manager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make this persistent, verify that the tmpfiles configuration includes 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;# Check if the config exists&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /usr/lib/tmpfiles.d/pve-manager.conf
&lt;span class="c"&gt;# Should contain a line like:&lt;/span&gt;
&lt;span class="c"&gt;# d /var/lock/pve-manager 0755 root root -&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that file is missing or doesn't include the lock directory, create a drop-in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'d /var/lock/pve-manager 0755 root root -'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/tmpfiles.d/pve-manager.conf
systemd-tmpfiles &lt;span class="nt"&gt;--create&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;PBS has a built-in verification system that reads back every chunk in a backup and checks its integrity. This is the feature that separates "I have backups" from "I have backups I can actually restore from."&lt;/p&gt;

&lt;p&gt;Schedule verification jobs in the PBS web UI under Datastore &amp;gt; Verify Jobs. A good cadence is to verify the most recent backup daily and do a full verification of all backups weekly. Verification is I/O intensive but doesn't affect PVE operations since it runs on the PBS host.&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;# Manual verification via CLI&lt;/span&gt;
proxmox-backup-client verify &lt;span class="nt"&gt;--repository&lt;/span&gt; &lt;span class="s1"&gt;'backup@pbs@10.0.0.50:main-store'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If verification fails for a specific snapshot, PBS will flag it in the UI. Don't ignore these warnings. A failed verification means that backup may not be restorable.&lt;/p&gt;

&lt;h2&gt;
  
  
  PBS in the Context of a Full 3-2-1 Strategy
&lt;/h2&gt;

&lt;p&gt;PBS handles one layer of your backup stack: the hypervisor layer. VMs and LXCs, their disks, their configs. But if you're running Kubernetes on top of those VMs, there's application-level state that PBS backs up only indirectly.&lt;/p&gt;

&lt;p&gt;Consider the layers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What It Contains&lt;/th&gt;
&lt;th&gt;Backup Tool&lt;/th&gt;
&lt;th&gt;Recovery Speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hypervisor&lt;/td&gt;
&lt;td&gt;VM disks, LXC rootfs, configs&lt;/td&gt;
&lt;td&gt;PBS&lt;/td&gt;
&lt;td&gt;Full VM restore in minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;PV data, etcd, secrets&lt;/td&gt;
&lt;td&gt;Velero + MinIO&lt;/td&gt;
&lt;td&gt;Namespace-level restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitOps&lt;/td&gt;
&lt;td&gt;Manifests, Helm values, configs&lt;/td&gt;
&lt;td&gt;Git (ArgoCD)&lt;/td&gt;
&lt;td&gt;Re-sync from repo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PBS gives you the "bare metal to running VMs" recovery path. If a node dies, you restore the VMs to another node and they come up exactly as they were. But the Kubernetes workloads inside those VMs have their own state (persistent volumes, databases, application data) that benefits from &lt;a href="https://dev.to/posts/velero-minio-kubernetes-backup-strategy-for-bare-metal/"&gt;Velero-level backups&lt;/a&gt; running in parallel.&lt;/p&gt;

&lt;p&gt;The combination is what makes 3-2-1 actually work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Three copies&lt;/strong&gt;: live data + PBS backup + offsite copy (Synology, cloud bucket, second PBS instance)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two media types&lt;/strong&gt;: local SSD/NVMe (live) + HDD (PBS datastore)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One offsite&lt;/strong&gt;: PBS supports built-in sync to a remote PBS instance, or you can replicate the datastore to a NAS for geographic separation&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the GitOps layer, &lt;a href="https://dev.to/posts/gitops-for-homelabs-argocd-app-of-apps/"&gt;ArgoCD&lt;/a&gt; already handles the "config as code" part. You don't need to back up Kubernetes manifests the traditional way because they're already in Git. What you need to back up is the state that isn't in Git: persistent volumes, database contents, secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Garbage Collection and Datastore Maintenance
&lt;/h2&gt;

&lt;p&gt;PBS deduplicates by storing data as content-addressed chunks. When you prune old backups, the chunks aren't immediately deleted. They become unreferenced. Garbage collection (GC) is the process that identifies and removes unreferenced chunks to reclaim disk space.&lt;/p&gt;

&lt;p&gt;GC runs on a schedule within PBS. The default is usually fine, but keep an eye on the "Deduplication Factor" metric in the PBS dashboard. For a homelab with similar VMs (same base OS, similar packages), you'll typically see dedup factors between 3x and 8x. That means your backups are using 3-8x less space than the raw data size.&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 datastore status including dedup factor&lt;/span&gt;
proxmox-backup-manager datastore list

&lt;span class="c"&gt;# Manually trigger garbage collection&lt;/span&gt;
proxmox-backup-manager garbage-collection start main-store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your dedup factor is close to 1x, something is off. Either your VMs have very little data in common (unlikely if they're running the same distro), or the chunk size configuration isn't optimal for your workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring Backup Health
&lt;/h2&gt;

&lt;p&gt;PBS exposes metrics that you can pull into Grafana or any monitoring stack. The key things to watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Last backup timestamp per VM/LXC&lt;/strong&gt;: if a backup hasn't run in 24+ hours, something is broken&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup duration trends&lt;/strong&gt;: a backup that used to take 10 minutes and now takes 60 suggests disk issues or unexpected data growth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification status&lt;/strong&gt;: any failed verifications need immediate attention&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Datastore usage&lt;/strong&gt;: track the growth rate to predict when you'll need more storage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple monitoring approach is a cron job that checks for recent backups:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Check that every VM has a backup from the last 24 hours&lt;/span&gt;
&lt;span class="nv"&gt;CUTOFF&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'24 hours ago'&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

proxmox-backup-client list &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository&lt;/span&gt; &lt;span class="s1"&gt;'backup@pbs@10.0.0.50:main-store'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&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;'.[] | select(.backup_time &amp;lt; '&lt;/span&gt;&lt;span class="nv"&gt;$CUTOFF&lt;/span&gt;&lt;span class="s1"&gt;') | .backup_id'&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;vm&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARNING: &lt;/span&gt;&lt;span class="nv"&gt;$vm&lt;/span&gt;&lt;span class="s2"&gt; has no backup in the last 24 hours"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Test restores, not just backups.&lt;/strong&gt; At least once a quarter, pick a VM and restore it to a temporary location. Verify it boots, verify the data is intact. A backup system you've never restored from is a hypothesis, not a strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privilege separation on API tokens is the silent killer.&lt;/strong&gt; If your automated backups authenticate fine but return empty data or permission errors on operations, check privsep. This one issue probably accounts for half the "PBS isn't working" posts on the Proxmox forums.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate your failure domains.&lt;/strong&gt; PBS running as a VM on the cluster it's backing up is better than no backups, but only barely. The whole point of backups is surviving hardware failure. A dedicated, physically separate PBS host (even a cheap one) fundamentally changes your recovery posture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PBS handles the hypervisor layer, not the application layer.&lt;/strong&gt; If you're running Kubernetes, you still need something like Velero for PV snapshots and namespace-level restores. PBS gives you "get back to running VMs." Velero gives you "get back to running applications." Both are necessary. &lt;a href="https://dev.to/posts/building-production-homelab/"&gt;Building a production homelab&lt;/a&gt; is only half the work if you don't have a plan for when things go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deduplication makes aggressive retention policies cheap.&lt;/strong&gt; Don't be stingy with retention. The marginal cost of keeping an extra month of weekly snapshots is tiny after dedup. The value of having that three-month-old snapshot when you discover slow data corruption is enormous.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock file issues are operational, not architectural.&lt;/strong&gt; They're annoying, but they're just stale state from interrupted processes. Know where the lock files live, know how to check if a &lt;code&gt;vzdump&lt;/code&gt; is actually running, and clean up when needed. Don't let a stuck lock file make you think PBS itself is broken.&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>backups</category>
      <category>homelab</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>When Agents Should Stop: Designing Safety Boundaries That Work</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 15 Jun 2026 12:38:16 +0000</pubDate>
      <link>https://dev.to/futhgar/when-agents-should-stop-designing-safety-boundaries-that-work-8jg</link>
      <guid>https://dev.to/futhgar/when-agents-should-stop-designing-safety-boundaries-that-work-8jg</guid>
      <description>&lt;p&gt;An agent in my homelab posted "HEARTBEAT_OK" to the ops channel 47 times over one weekend. Every message was technically correct. The scheduled jobs were healthy, the agent verified them, and it reported in exactly like it was told to. By Monday morning I had muted the channel, which meant the one message that mattered (a failed backup verification) scrolled past unread sometime around 3 AM.&lt;/p&gt;

&lt;p&gt;That incident wasn't an alignment problem or a runaway loop. It was a stopping problem. The agent had no concept of "nothing to say," so it said something every time it woke up. Most agent safety writing focuses on preventing harmful actions. In practice, the boundary I've had to engineer most carefully is more mundane: teaching agents when to do nothing and exit quietly.&lt;/p&gt;

&lt;p&gt;If you run scheduled agents, autonomous loops, or anything where an LLM makes decisions on a timer, this is for you. The patterns below come from running multi-agent pipelines on my own infrastructure, and from the specific ways they've failed. I covered the theory in &lt;a href="https://dev.to/posts/three-layer-safety-autonomous-agents/"&gt;Three-Layer Safety for Autonomous Agents&lt;/a&gt;; this post is the operational follow-up, the part where theory meets a crash-looping gateway at 2 AM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping is a feature, not a failure state
&lt;/h2&gt;

&lt;p&gt;The core mistake I made early on: treating an agent that stops as an agent that failed. My first orchestration scripts retried everything. Agent exits without completing the task? Retry. Agent says it's blocked? Rephrase the prompt and retry. The result was agents that burned tokens grinding against problems they'd already correctly identified as unsolvable from inside the loop.&lt;/p&gt;

&lt;p&gt;What fixed it was giving agents a vocabulary for stopping. Mine boils down to three boundary types, all enforced outside the model:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Budget boundaries&lt;/strong&gt; cap what an agent can spend: iterations, tokens, wall-clock time. These are the easy ones, and most frameworks give you something here. The mistake is setting them as emergency brakes (high enough that they never trigger) instead of as scoping decisions. If a task should take 3 iterations, cap it at 5, not 50. A cap that triggers at 50 means you've already wasted 45 iterations of spend before learning anything. I also set budgets per stage rather than per pipeline: a global 30-minute cap on a five-stage pipeline tells you nothing about which stage ran away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progress boundaries&lt;/strong&gt; detect when the agent is still spending but no longer changing anything. This is the infinite-loop killer, and it's the one almost nobody implements. An agent can stay under every budget cap while making zero progress: rewriting the same file back and forth, re-running the same failing test with cosmetic tweaks. You detect this by hashing the observable state between iterations and stopping when the hash stops changing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reporting boundaries&lt;/strong&gt; define when the agent is allowed to speak. This is the HEARTBEAT_OK lesson: an agent that reports success on every run trains humans to ignore it. Silence on success, noise on failure. The inversion matters more than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configs
&lt;/h2&gt;

&lt;p&gt;Progress detection is the highest-value boundary, so start there. The wrapper below runs an agent task in a loop and kills it when two consecutive iterations produce identical state:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# agent-loop.sh: run an agent task with hard stop conditions&lt;/span&gt;
&lt;span class="nv"&gt;MAX_ITERATIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;span class="nv"&gt;previous_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAX_ITERATIONS&lt;/span&gt;&lt;span class="s2"&gt;"&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;run_agent_iteration &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TASK_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# your agent invocation here&lt;/span&gt;

  &lt;span class="c"&gt;# Hash everything the agent can change: working tree + state dir&lt;/span&gt;
  &lt;span class="nv"&gt;current_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;
    &lt;span class="o"&gt;{&lt;/span&gt; git diff&lt;span class="p"&gt;;&lt;/span&gt; git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;cat &lt;/span&gt;state/&lt;span class="k"&gt;*&lt;/span&gt;.json 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;sha256sum&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f1&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;$current_state&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$previous_state&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &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;"iteration &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; produced no state change, stopping"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;2   &lt;span class="c"&gt;# stopped at boundary, not failed&lt;/span&gt;
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nv"&gt;previous_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current_state&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  task_complete &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"hit iteration cap (&lt;/span&gt;&lt;span class="nv"&gt;$MAX_ITERATIONS&lt;/span&gt;&lt;span class="s2"&gt;) without completing"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
&lt;span class="nb"&gt;exit &lt;/span&gt;2


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 2 is doing real work there. I use a three-value contract for every agent wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 = done: task complete, verified
1 = failed: something broke, a human needs to look
2 = stopped: hit a boundary with partial progress, safe to resume


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The distinction between 1 and 2 is the whole point. A failure pages someone. A boundary stop writes a state file and waits for the next scheduled run, which picks up where the last one left off. Collapsing those into one exit code gives you either alert fatigue or silent data loss, depending on which direction you collapse them.&lt;/p&gt;

&lt;p&gt;Notice that all of this lives in the wrapper, not in the prompt. You can (and should) tell the agent about its budget in the prompt, because a model that knows it has two iterations left plans differently. But the prompt is advice. The wrapper is the boundary.&lt;/p&gt;

&lt;p&gt;Reporting boundaries live in the scheduler config. Here's the shape I use for scheduled agent jobs after the heartbeat incident:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nightly-health-check"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schedule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 6 * * *"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"task"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Verify backup jobs completed and volumes are healthy."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"notify"&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;"on_success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"silent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"on_failure"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"channel:#ops"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"on_boundary_stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"channel:#ops-low"&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;"deadman"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://hc.example.com/ping/nightly-health"&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;Two things to notice. Success is silent: the channel only gets a message when something needs a human. And the &lt;code&gt;deadman&lt;/code&gt; URL replaces the heartbeat message entirely: instead of the agent telling humans "I'm alive," it pings a dead-man's-switch endpoint (Healthchecks.io, or any self-hosted equivalent) that alerts only when the ping &lt;em&gt;stops&lt;/em&gt; arriving. Machines are good at noticing absence. Humans are terrible at it. Route the liveness signal to the machine and the failure signal to the human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: silence can hide breakage
&lt;/h2&gt;

&lt;p&gt;About a month after I made my agents quiet on success, a memory MCP server's tools started failing silently. Calls returned empty results instead of errors. The agents treated "no results" as "nothing to report" and exited cleanly, status 0, for eleven days. From the outside everything looked healthy: exit codes were green and the dead-man pings kept arriving, because the agent itself was running fine. Only the tools inside it were broken.&lt;/p&gt;

&lt;p&gt;The lesson: "silence on success" requires verifying success, not just the absence of an exception. My health-check agents now end every run with an assertion phase that demands positive evidence:&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;# Don't trust "no errors". Demand proof of work.&lt;/span&gt;
&lt;span class="nv"&gt;results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;query_memory_store &lt;span class="s2"&gt;"test-canary-record"&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="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$results&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &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;"canary record missing: memory store is lying to us"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plant a canary record you know exists, and fail loudly if the tooling can't find it. A tool that fails silently turns every downstream stop condition into a lie, because the agent is deciding "nothing to do" based on data it never received.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: validate config before the gateway eats it
&lt;/h2&gt;

&lt;p&gt;Stop conditions usually live in config files, which means they inherit every config-deployment failure mode. I learned this when I added a plausible-looking concurrency cap to an agent gateway's config. The key didn't exist in the schema. Older versions ignored unknown keys; the version I was running had switched to strict validation and rejected the whole file. The gateway crash-looped on restart, taking every scheduled agent down with it, including the ones whose job was to report that things were down.&lt;/p&gt;

&lt;p&gt;Strict validation is the right behavior (a typo'd &lt;code&gt;max_iteratons&lt;/code&gt; silently ignored is a budget cap that doesn't exist), but it means you treat agent config like any other production config: validate before reload, never after.&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;# Never restart a gateway on unvalidated config&lt;/span&gt;
agentctl validate &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/agent/gateway.json &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"config invalid, refusing to restart"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt;
systemctl restart agent-gateway


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your agent platform ships a &lt;code&gt;doctor&lt;/code&gt; or &lt;code&gt;validate&lt;/code&gt; subcommand, wire it into the deploy path and make the restart conditional on it passing. If it doesn't ship one, a JSON Schema check in CI is twenty minutes of work and saves you a crash-looped orchestrator. Same idea as &lt;a href="https://dev.to/posts/kubernetes-manifest-validation-catching-errors-before-merge/"&gt;validating Kubernetes manifests before merge&lt;/a&gt;, just pointed at your agent stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: a stopped agent must leave a note
&lt;/h2&gt;

&lt;p&gt;Early versions of my boundary stops just exited. The next scheduled run started from scratch, re-derived the same context, hit the same boundary, and exited again. Functionally an infinite loop, just with a 24-hour period and a cron job in the middle.&lt;/p&gt;

&lt;p&gt;Now every boundary stop writes a handoff file before exiting:&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;"stopped_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-08T03:12:44Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no_progress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iterations_used"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"progress_summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Identified failing PVC, replica rebuild blocked on node disk pressure"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"blocking_on"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"needs human: node disk cleanup or replica eviction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resume_hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"check node disk usage before retrying"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;


&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next run reads the handoff first. If &lt;code&gt;blocking_on&lt;/code&gt; names a human action and nothing in the environment has changed, it exits immediately at near-zero cost instead of re-deriving the same dead end. When the blocker clears, it resumes from the summary instead of from nothing. This one file turned boundary stops from an expensive pause into an actual checkpoint mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I considered and rejected
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Letting the model decide when to stop.&lt;/strong&gt; Tempting, because the model often &lt;em&gt;knows&lt;/em&gt; it's stuck. But a stop condition that lives inside the thing being bounded isn't a boundary, it's a suggestion. Models are also systematically optimistic that one more iteration will help. I let agents &lt;em&gt;request&lt;/em&gt; an early stop (which short-circuits the loop), but enforcement stays in the wrapper, outside the model's reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confidence thresholds.&lt;/strong&gt; Some frameworks stop when the model's self-reported confidence drops below a cutoff. I tried it; self-reported confidence was noise, uncorrelated with whether the next iteration helped. The state-hash check costs one &lt;code&gt;sha256sum&lt;/code&gt; and doesn't depend on the model grading its own homework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watchdog agents.&lt;/strong&gt; A second agent that monitors the first and decides whether to kill it. This works, and for high-stakes pipelines I still use a reviewer stage (the pattern shows up in &lt;a href="https://dev.to/posts/multi-agent-ai-systems-architecture-patterns/"&gt;Multi-Agent AI Systems: Architecture Patterns That Actually Work&lt;/a&gt;). But as a &lt;em&gt;stop&lt;/em&gt; mechanism it's expensive and introduces a new question: who stops the watchdog? Deterministic boundaries in the wrapper give you 90% of the value at roughly zero marginal cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lands
&lt;/h2&gt;

&lt;p&gt;Stopping is the cheapest safety mechanism you have, and it's the one most agent deployments skip because it doesn't feel like a feature. Nobody demos an agent exiting cleanly. But the boundaries above have prevented more incidents on my cluster than any prompt-engineering guardrail I've written: budget caps treated as scoping decisions instead of emergency brakes, state-hash progress detection, the 0/1/2 exit contract, silent success paired with loud failure and a machine-checked dead-man switch, and handoff files so a stop is a checkpoint instead of a discard.&lt;/p&gt;

&lt;p&gt;Reach for this the moment any agent runs without a human watching: scheduled jobs, overnight batch pipelines, CI agents. Building agent systems that run unattended against real infrastructure is part of what I help teams with at &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;GuatuLabs&lt;/a&gt;, and stop-condition design is reliably the piece nobody thought about before the first incident. If your ops channel has a recurring message in it right now that everyone has learned to scroll past, that's not a reporting feature. That's a stop condition nobody designed.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>agentsafety</category>
      <category>automation</category>
      <category>agentorchestration</category>
    </item>
    <item>
      <title>Network Policies with Calico: Default Deny and Namespace Isolation</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 15 Jun 2026 12:38:04 +0000</pubDate>
      <link>https://dev.to/futhgar/network-policies-with-calico-default-deny-and-namespace-isolation-1p63</link>
      <guid>https://dev.to/futhgar/network-policies-with-calico-default-deny-and-namespace-isolation-1p63</guid>
      <description>&lt;p&gt;A default-deny NetworkPolicy is five lines of spec. Those five lines will also kill DNS resolution for every pod they select, because an egress deny blocks UDP packets to kube-dns just as happily as it blocks the traffic you were actually worried about. The distance between "I understand network policies" and "I rolled out default deny without an outage" is mostly three blind spots: DNS, your ingress controller, and admission webhooks.&lt;/p&gt;

&lt;p&gt;Out of the box, Kubernetes runs a flat pod network. Every pod can open a connection to every other pod in the cluster, across namespaces, no questions asked. If you've already done the work of &lt;a href="https://guatulabs.dev/posts/kubernetes-rbac-building-least-privilege-service-accounts/" rel="noopener noreferrer"&gt;building least-privilege service accounts&lt;/a&gt;, a flat network is the same problem one layer down: identity is locked tight while the network is wide open. This post is about closing that gap with Calico on a bare-metal cluster (K8s 1.31, Calico 3.x), in an order that doesn't take the cluster down while you do it.&lt;/p&gt;

&lt;p&gt;One prerequisite worth stating plainly: the NetworkPolicy API objects exist in every cluster, but they do nothing unless your CNI enforces them. Calico does. If you're on a CNI without policy support, you can apply these manifests all day and traffic flows anyway, which is its own special category of false confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rollout that looks right and isn't
&lt;/h2&gt;

&lt;p&gt;The tempting approach goes like this: write one default-deny policy, template it across every namespace, apply, done. Security checkbox ticked before lunch.&lt;/p&gt;

&lt;p&gt;Here's the policy everyone starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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&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;team-a&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;# selects every pod in the 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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Egress&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The empty &lt;code&gt;podSelector&lt;/code&gt; selects all pods, and listing both policy types makes them isolated in both directions. Correct, minimal, and the moment it lands cluster-wide, three things break in a predictable order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure one: DNS dies first, and it dies slowly
&lt;/h3&gt;

&lt;p&gt;Every pod in a selected namespace loses the ability to resolve names, because queries to kube-dns in &lt;code&gt;kube-system&lt;/code&gt; are egress traffic like any other. The nasty part is the failure mode. Connections to a denied endpoint fail fast with a timeout you'll notice. DNS failures look different: each lookup waits out a 5-second timeout per attempt, multiplied by the search domain list your &lt;code&gt;ndots&lt;/code&gt; config generates. Apps get slow before they get broken, which sends you debugging application performance instead of network policy. I wrote about how the search domain expansion amplifies this in &lt;a href="https://guatulabs.dev/posts/wildcard-dns-ndots-5-the-tls-nightmare-and-how-to-fix-it/" rel="noopener noreferrer"&gt;the ndots:5 post&lt;/a&gt;; default deny turns every one of those expanded lookups into a 5-second black hole.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure two: your ingress controller can't reach anything
&lt;/h3&gt;

&lt;p&gt;Traffic from Traefik or ingress-nginx to your backend pods is just pod-to-pod traffic crossing a namespace boundary. Default deny on the application namespace blocks it, and every service behind the ingress starts returning 502s and 504s. The application pods are healthy, the Service endpoints are populated, readiness probes pass (kubelet probes come from the node, and Calico permits them). Everything looks green except the part where users reach it. This also bites cert-manager: an HTTP-01 challenge needs the ingress controller to reach the temporary solver pod, so default deny can silently stall certificate issuance long after the initial rollout.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure three: the webhook deadlock
&lt;/h3&gt;

&lt;p&gt;This is the one that turns a degraded cluster into a stuck one. Admission webhooks (Kyverno, cert-manager's webhook, anything with a &lt;code&gt;ValidatingWebhookConfiguration&lt;/code&gt;) receive calls from the API server. Deny ingress to the webhook pod and those calls time out. With &lt;code&gt;failurePolicy: Fail&lt;/code&gt;, the API server now rejects the operations that webhook gates, and the trap closes: the NetworkPolicy you're trying to apply to fix the problem is itself an API operation that flows through admission. You're locked out of the fix by the thing you broke.&lt;/p&gt;

&lt;p&gt;It gets worse if the policies are managed by automation. With a Kyverno generate rule or &lt;a href="https://guatulabs.dev/posts/gitops-for-homelabs-argocd-app-of-apps/" rel="noopener noreferrer"&gt;a GitOps controller&lt;/a&gt; syncing the policy, deleting the offending NetworkPolicy by hand buys you a few seconds before it's regenerated. You end up playing whack-a-mole against your own reconciliation loop while the cluster burns. The escape hatch is to pause the automation first (scale down Kyverno, disable ArgoCD auto-sync for that app), then remove the policy.&lt;/p&gt;

&lt;p&gt;A detail that matters here: API server traffic to webhooks often originates from the control plane host network, not from a pod you can match with a &lt;code&gt;podSelector&lt;/code&gt;. Allowing it means an &lt;code&gt;ipBlock&lt;/code&gt; rule for your control plane CIDR, or excluding webhook namespaces from default deny entirely. I do the latter.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rollout order that works
&lt;/h2&gt;

&lt;p&gt;The fix for all three failures is the same discipline: never apply a deny you haven't already written the allows for, and never apply it wider than you can watch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: one namespace, not the cluster
&lt;/h3&gt;

&lt;p&gt;Pick a single application namespace with low blast radius. Resist the urge to start cluster-wide; the whole point of the first namespace is to discover the flows you forgot existed. &lt;code&gt;kubectl get networkpolicy -A&lt;/code&gt; should stay boring while you learn.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: the baseline trio
&lt;/h3&gt;

&lt;p&gt;Default deny ships as a set of three policies applied together, in one &lt;code&gt;kubectl apply -f&lt;/code&gt; of one directory. The deny:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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&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;team-a&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="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DNS allow, which goes everywhere the deny goes, no exceptions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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-dns-egress&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;team-a&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="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="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;kube-system&lt;/span&gt;
          &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;k8s-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-dns&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;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UDP&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;53&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;53&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both protocols matter. DNS falls back to TCP for large responses, and an egress rule that only allows UDP produces intermittent failures that are miserable to track down.&lt;/p&gt;

&lt;p&gt;The intra-namespace and ingress-controller allow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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-baseline-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;team-a&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="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# any pod in this same namespace&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;span class="c1"&gt;# everything in the ingress controller's namespace&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;ingress&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;kubernetes.io/metadata.name&lt;/code&gt; label is the load-bearing trick here. Since K8s 1.22, every namespace carries it automatically with its own name as the value, which gives you a stable way to select namespaces without inventing and maintaining your own labeling scheme.&lt;/p&gt;

&lt;p&gt;With the trio applied, check behavior from inside the namespace before moving on:&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;# throwaway pod inside the locked-down namespace&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; team-a run probe &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:1.36 &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;span class="c"&gt;# inside the pod:&lt;/span&gt;
nslookup kubernetes.default                                  &lt;span class="c"&gt;# should answer instantly&lt;/span&gt;
wget &lt;span class="nt"&gt;-qO-&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; 2 http://api.team-b.svc.cluster.local           &lt;span class="c"&gt;# should time out&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fast DNS plus a slow, eventually-failing cross-namespace connection is the signature of a healthy baseline. Instant DNS failure means the allow-dns policy didn't land; an instant cross-namespace success means the deny didn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: log before you deny
&lt;/h3&gt;

&lt;p&gt;Calico's &lt;code&gt;Log&lt;/code&gt; rule action is the visibility tool the vanilla NetworkPolicy API doesn't have. Before tightening further, I put a logging policy behind the allows so I can see what the deny is about to catch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;projectcalico.org/v3&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;GlobalNetworkPolicy&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;log-unmatched&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;order&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4000&lt;/span&gt;                    &lt;span class="c1"&gt;# evaluated after everything else&lt;/span&gt;
  &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;projectcalico.org/name == 'team-a'&lt;/span&gt;
  &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Log&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Log&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the iptables dataplane, &lt;code&gt;Log&lt;/code&gt; uses the kernel LOG target, so dropped-candidate packets show up in the kernel log with a &lt;code&gt;calico-packet:&lt;/code&gt; prefix (configurable via &lt;code&gt;logPrefix&lt;/code&gt; in FelixConfiguration):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt; calico-packet


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats. Kernel logging is noisy, so treat this as a diagnostic you enable for hours, not a permanent fixture. And the eBPF dataplane doesn't support the &lt;code&gt;Log&lt;/code&gt; action, so if you've switched dataplanes this tool isn't available.&lt;/p&gt;

&lt;p&gt;This step is where "set and forget" turns into something closer to auditing. Run a logging policy for a day against a namespace before enforcing, and you find the flows nobody documented: the metrics scraper, the backup job, the sidecar that phones a service in another namespace.&lt;/p&gt;

&lt;p&gt;One class of flow deserves special mention: anything running with &lt;code&gt;hostNetwork: true&lt;/code&gt;. Node-level monitoring agents and some bare-metal ingress deployments source their traffic from the node's IP, not a pod IP, so &lt;code&gt;podSelector&lt;/code&gt; and &lt;code&gt;namespaceSelector&lt;/code&gt; rules never match them. If scraping or health checks break only after enforcement, this is usually why, and the fix is an &lt;code&gt;ipBlock&lt;/code&gt; rule covering your node CIDR rather than another selector you'll fight with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: the cluster-wide backstop
&lt;/h3&gt;

&lt;p&gt;Once the per-namespace pattern is proven, Calico's &lt;code&gt;GlobalNetworkPolicy&lt;/code&gt; enforces namespace isolation as a guardrail across every tenant namespace at once, with infrastructure explicitly carved out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;projectcalico.org/v3&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;GlobalNetworkPolicy&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;tenant-isolation-backstop&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;order&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;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
    &lt;span class="s"&gt;projectcalico.org/name not in&lt;/span&gt;
    &lt;span class="s"&gt;{"kube-system", "calico-system", "calico-apiserver",&lt;/span&gt;
     &lt;span class="s"&gt;"ingress", "argocd", "cert-manager", "kyverno"}&lt;/span&gt;
  &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# DNS keeps working even where namespace policies are missing&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow&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;UDP&lt;/span&gt;
      &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k8s-app == 'kube-dns'&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="nv"&gt;53&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k8s-app == 'kube-dns'&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="nv"&gt;53&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No explicit &lt;code&gt;Deny&lt;/code&gt; rule, and that's deliberate. In Calico, when at least one policy selects an endpoint and no rule allows the packet, the packet is dropped at the end of evaluation. The backstop selects everything outside the exclusion list, allows DNS, and lets the implicit deny do the rest.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;order: 3000&lt;/code&gt; is doing real work. Calico assigns Kubernetes NetworkPolicies an order of 1000, and lower order means earlier evaluation. An allow in a namespace's own policy terminates evaluation before the backstop is ever consulted. The backstop only catches traffic nothing else has claimed, which means namespaces with proper policies behave per their policies, and namespaces without any get isolation by default instead of the flat network.&lt;/p&gt;

&lt;p&gt;That exclusion list is the "infrastructure exclusion" pattern, and I'd argue it's the single most important decision in the whole rollout. The namespaces that run your CNI, your ingress, your GitOps controller, and your admission webhooks are the namespaces where a policy mistake costs you the ability to fix policy mistakes. Leave them out of automated enforcement. Write their policies by hand, later, one at a time, with the logging step in between.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: automate generation, with the same exclusions
&lt;/h3&gt;

&lt;p&gt;For new namespaces, a &lt;a href="https://guatulabs.dev/posts/kyverno-admission-controllers-policy-as-code-that-actually-works/" rel="noopener noreferrer"&gt;Kyverno generate rule&lt;/a&gt; stamps the baseline trio in automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kyverno.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;ClusterPolicy&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;generate-default-deny&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;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default-deny&lt;/span&gt;
      &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Namespace&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exclude&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;any&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;kinds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Namespace&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;names&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kube-system"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kube-public"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kube-node-lease"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
                      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calico-system"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ingress"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;argocd"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kyverno"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default-deny&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{request.object.metadata.name}}"&lt;/span&gt;
        &lt;span class="na"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;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="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;Egress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two operational notes. &lt;code&gt;synchronize: true&lt;/code&gt; is what creates the regeneration loop from failure three: hand-deleting the generated policy gets it recreated within seconds, so during an incident you pause the ClusterPolicy before touching its output. And Kyverno treats generate rules as effectively immutable: if the generated resource definition is wrong, plan on deleting and recreating the ClusterPolicy rather than patching it in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works
&lt;/h2&gt;

&lt;p&gt;The mental model that makes all of this predictable: Kubernetes NetworkPolicies are additive allow-lists with an implicit deny that activates the moment any policy selects a pod. There is no deny rule in the vanilla API. A pod selected by zero policies accepts everything; a pod selected by any policy accepts only what the union of matching policies allows. That's why the baseline trio works as a set: the deny policy flips the pod into isolated mode, and the other two define the allowed surface.&lt;/p&gt;

&lt;p&gt;Calico layers an ordered evaluation model on top. Policies are sorted by &lt;code&gt;order&lt;/code&gt;, rules within a policy run top to bottom, and the first &lt;code&gt;Allow&lt;/code&gt; or &lt;code&gt;Deny&lt;/code&gt; terminates evaluation. Kubernetes-native policies slot in at order 1000 (you can see the converted versions with &lt;code&gt;calicoctl get networkpolicy --all-namespaces&lt;/code&gt;, prefixed &lt;code&gt;knp.default.&lt;/code&gt;). Pods matched by no policy at all fall through to Calico's per-namespace profiles, which default to allow. That layering is exactly what makes the backstop-at-3000 pattern safe: specific intent at 1000 wins, the guardrail catches the remainder, and the logging policy at 4000 sees only what's about to die.&lt;/p&gt;

&lt;p&gt;Felix, Calico's per-node agent, also quietly saves you from the worst self-own. Its failsafe port list (SSH on 22, the API server on 6443, BGP on 179, etcd, Typha) is exempt from policy on host endpoints by default, so a bad policy can break your workloads without also locking you out of the nodes you need to fix it from. Don't shrink that list without a very specific reason.&lt;/p&gt;

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

&lt;p&gt;The failure modes are knowable in advance. DNS, ingress, and webhooks fail in that order every time, and writing the allows before the deny is cheaper in every way than discovering them from a monitoring graph. If a rollout plan doesn't mention &lt;code&gt;kube-dns&lt;/code&gt;, port 53, or &lt;code&gt;failurePolicy&lt;/code&gt;, it isn't done.&lt;/p&gt;

&lt;p&gt;Namespace-by-namespace beats cluster-wide, even though it feels slower. The first namespace takes a day because you're discovering undocumented flows. The tenth takes ten minutes because there's nothing left to discover. Going cluster-wide first inverts that: you discover everything at once, in production, with automation re-applying the breakage faster than you can remove it.&lt;/p&gt;

&lt;p&gt;Exclude infrastructure from automation permanently, not temporarily. Every system that can generate or sync policies (Kyverno, ArgoCD, your own scripts) should carry the same exclusion list for &lt;code&gt;kube-system&lt;/code&gt;, the CNI namespace, ingress, GitOps, and webhook namespaces. The asymmetry is stark: a missing policy in those namespaces costs you some security posture, while a wrong policy there costs you the control plane's ability to accept the fix.&lt;/p&gt;

&lt;p&gt;Logging is the difference between policy as guesswork and policy as engineering. The &lt;code&gt;Log&lt;/code&gt; action is crude (kernel log lines, iptables dataplane only), but it converts "why is this connection failing" from a hypothesis into a grep. I'd take crude visibility over elegant blindness in any network debugging session. This pattern, restrict by default and watch the boundary, is the same shape as the guardrails I build around &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;autonomous agent infrastructure&lt;/a&gt;: the deny is easy, and the engineering is in the observability that tells you what the deny will cost before you pay it.&lt;/p&gt;

&lt;p&gt;The thing the docs undersell is that default deny is a migration, not a manifest. The YAML is trivial. The work is the inventory of flows your cluster actually depends on, and you only get that inventory by watching one namespace at a time with the logs on.&lt;/p&gt;

</description>
      <category>calico</category>
      <category>networkpolicies</category>
      <category>kubernetes</category>
      <category>security</category>
    </item>
    <item>
      <title>Velero + MinIO: Kubernetes Backup Strategy for Bare Metal</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Wed, 10 Jun 2026 12:15:14 +0000</pubDate>
      <link>https://dev.to/futhgar/velero-minio-kubernetes-backup-strategy-for-bare-metal-25b9</link>
      <guid>https://dev.to/futhgar/velero-minio-kubernetes-backup-strategy-for-bare-metal-25b9</guid>
      <description>&lt;p&gt;I spent three hours staring at a &lt;code&gt;PartiallyFailed&lt;/code&gt; status in Velero, wondering why my backups were failing despite the logs claiming the S3 connection was healthy. The culprit wasn't the network or the credentials. It was a handful of NFS-backed persistent volumes that Velero was trying to snapshot using a CSI driver that didn't support them.&lt;/p&gt;

&lt;p&gt;If you're running Kubernetes on bare metal, you don't have the luxury of a "managed" backup service. You have to build the storage backend, the orchestration layer, and the recovery path yourself. Most of the documentation assumes you're pushing to AWS S3, but when you're running your own hardware, that's usually not the goal. You want your data on your own disks, under your own control.&lt;/p&gt;

&lt;h3&gt;
  
  
  The False Starts
&lt;/h3&gt;

&lt;p&gt;My first attempt was naive. I thought I could just install Velero, point it at a MinIO instance running inside the same cluster, and call it a day. This was a mistake for two reasons.&lt;/p&gt;

&lt;p&gt;First, backing up a cluster to a storage provider running &lt;em&gt;inside&lt;/em&gt; that same cluster is a circular dependency. If the cluster goes down, your backups are gone. I quickly moved MinIO to a separate set of machines to ensure the backup target lived outside the blast radius of the Kubernetes API.&lt;/p&gt;

&lt;p&gt;Second, I relied entirely on the "happy path" of CSI snapshots. I assumed that because I was using Longhorn for most of my stateful workloads, everything would just work. I forgot that I had a few legacy NFS mounts for shared configuration files. Velero tried to trigger a CSI snapshot on those NFS volumes, failed, and marked the entire backup as &lt;code&gt;PartiallyFailed&lt;/code&gt;. I spent an hour chasing "S3 timeout" errors when the real issue was a storage class mismatch.&lt;/p&gt;

&lt;p&gt;I also tried using the default Velero installation without specifying the S3 URL explicitly in the environment variables of the pod. I assumed the plugin would magically find MinIO if the credentials were correct. It didn't. I ended up with a loop of &lt;code&gt;403 Forbidden&lt;/code&gt; errors because Velero was trying to hit the actual AWS S3 endpoints instead of my local MinIO instance.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Actual Solution
&lt;/h3&gt;

&lt;p&gt;To get a reliable bare-metal backup strategy, you need three distinct layers: the S3-compatible target (MinIO), the orchestrator (Velero), and the control plane safety net (ETCD snapshots).&lt;/p&gt;

&lt;h4&gt;
  
  
  1. The Storage Backend (MinIO)
&lt;/h4&gt;

&lt;p&gt;I run MinIO on a separate set of bare-metal nodes. For the sake of this setup, I've created a dedicated bucket called &lt;code&gt;k8s-backups&lt;/code&gt; and a specific service account with read/write access to that bucket. &lt;/p&gt;

&lt;p&gt;Running MinIO outside the cluster is non-negotiable. If you have a power failure on your K8s rack and your backups are on the same rack, you haven't built a backup system: you've just built a very expensive way to lose your data twice.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Installing Velero with MinIO
&lt;/h4&gt;

&lt;p&gt;The trick here is the AWS plugin. Since MinIO uses the S3 API, we use the AWS provider but override the endpoint to point to the local MinIO server. &lt;/p&gt;

&lt;p&gt;I used the following command to deploy Velero 1.14 on K8s 1.31:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;velero &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provider&lt;/span&gt; aws &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--plugins&lt;/span&gt; velero/velero-plugin-for-aws:v1.14.0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bucket&lt;/span&gt; k8s-backups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret-file&lt;/span&gt; ./credentials-velero &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--use-volume-snapshots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--backup-destination-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;s3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--s3-url&lt;/span&gt; http://minio.example.com:9000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; velero
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;credentials-velero&lt;/code&gt; file is a standard AWS credentials format. To keep these secure and avoid committing them to Git, I use &lt;a href="https://guatulabs.dev/posts/sealedsecrets-key-backup-don-t-lose-your-encryption-keys/" rel="noopener noreferrer"&gt;SealedSecrets&lt;/a&gt; to manage the secrets across my environments.&lt;/p&gt;

&lt;p&gt;If you're deploying this via GitOps, I highly recommend using the official Helm chart but overriding the &lt;code&gt;configuration.s3Url&lt;/code&gt; value. This ensures that when you scale your cluster or move nodes, the backup configuration remains consistent.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Handling the "PartiallyFailed" Nightmare
&lt;/h4&gt;

&lt;p&gt;To stop Velero from trying to snapshot volumes that don't support it (like NFS), I had to be explicit. Labeling volumes to exclude them is a start, but the most effective way to handle a mixed-storage environment is to patch the backup schedule to ignore volume snapshots for specific workloads or to use Restic/Kopia for file-level backups.&lt;/p&gt;

&lt;p&gt;If you have a schedule that keeps failing due to incompatible PVs, you can disable snapshot volumes for that specific schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl patch schedule daily-cluster-backup &lt;span class="nt"&gt;-n&lt;/span&gt; velero &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"template":{"snapshotVolumes":false}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the volumes that actually need backing up (like my Longhorn volumes), I rely on the &lt;a href="https://guatulabs.dev/posts/kubernetes-storage-on-bare-metal-longhorn-in-practice/" rel="noopener noreferrer"&gt;Longhorn integration&lt;/a&gt;, which allows Velero to trigger native Longhorn snapshots.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. The ETCD Safety Net
&lt;/h4&gt;

&lt;p&gt;Velero is great for resources and PVs, but if your ETCD cluster completely collapses, you're in for a bad time. I don't trust a single tool for the control plane. I implemented a systemd timer on the control plane nodes to take raw ETCD snapshots every 24 hours.&lt;/p&gt;

&lt;p&gt;I use this unit file to handle the snapshot and a basic retention policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ETCD Snapshot Backup&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/etcdctl --endpoints=https://127.0.0.1:2379 &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;--cacert=/etc/kubernetes/pki/etcd/ca.crt &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;--cert=/etc/kubernetes/pki/etcd/server.crt &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;--key=/etc/kubernetes/pki/etcd/server.key &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;snapshot save /var/backups/etcd/etcd-snapshot-$(date +%Y%m%d).db&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPost&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/sh -c '/usr/bin/find /var/backups/etcd -type f -name "etcd-snapshot-*.db" -mtime +7 -exec rm -f {} &lt;/span&gt;&lt;span class="se"&gt;\;&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then use a simple cron job to rsync these &lt;code&gt;.db&lt;/code&gt; files to the MinIO server. This gives me a raw binary backup of the cluster state that is completely independent of the Velero operator.&lt;/p&gt;

&lt;h3&gt;
  
  
  Troubleshooting the Gap
&lt;/h3&gt;

&lt;p&gt;When things go wrong with Velero and MinIO, the errors are rarely helpful. You'll see &lt;code&gt;Backup failed&lt;/code&gt; in the high-level status, but the real gold is in the pod logs.&lt;/p&gt;

&lt;h4&gt;
  
  
  The "S3 Endpoint" Trap
&lt;/h4&gt;

&lt;p&gt;If you see &lt;code&gt;failed to get object: NoSuchBucket&lt;/code&gt; or &lt;code&gt;403 Forbidden&lt;/code&gt; despite having the right keys, check if Velero is actually hitting your MinIO server. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-n&lt;/span&gt; velero deployment/velero
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see requests going to &lt;code&gt;s3.amazonaws.com&lt;/code&gt;, your &lt;code&gt;--s3-url&lt;/code&gt; flag was ignored or overridden. This often happens when using Helm charts where the &lt;code&gt;configuration&lt;/code&gt; block isn't properly mapped to the deployment arguments.&lt;/p&gt;

&lt;h4&gt;
  
  
  Restic Metadata Corruption
&lt;/h4&gt;

&lt;p&gt;I hit a specific wall when I changed the bucket name in MinIO. I updated the Velero config, but my file-level backups (using Restic) started failing with:&lt;br&gt;
&lt;code&gt;error: repository is not initialized&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Restic stores metadata in the bucket itself. If you move buckets, you can't just point Velero to the new one; you have to migrate the restic repository or re-initialize it. I learned the hard way that Restic is less flexible than CSI snapshots for backend migration.&lt;/p&gt;

&lt;h4&gt;
  
  
  CSI Snapshot Timeouts
&lt;/h4&gt;

&lt;p&gt;In a multi-node Proxmox setup, I noticed that some backups would hang at the "snapshotting" phase. After digging into the Longhorn logs, I found that the snapshot was being created, but the CSI driver was timing out while waiting for the volume to reach a consistent state. The fix was increasing the &lt;code&gt;snapshotTimeout&lt;/code&gt; in the Velero configuration to 10 minutes, giving the storage layer enough breathing room to finalize the snapshot on larger volumes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deep Dive: Why This Architecture Works
&lt;/h3&gt;

&lt;p&gt;This architecture works because it acknowledges the reality of bare metal: things fail in ways the cloud hides from you. &lt;/p&gt;

&lt;p&gt;By using MinIO as an S3-compatible layer, I get the industry-standard API that Velero expects, but I keep the data on my own hardware. This removes the egress costs and latency associated with pushing terabytes of snapshot data to a public cloud provider.&lt;/p&gt;

&lt;p&gt;By separating the ETCD backups from the Velero backups, I've created two different recovery paths. If the Velero operator is broken, I can still restore ETCD to bring the API server back online. If the ETCD data is corrupted but the API is alive, I can use Velero to restore specific namespaces without nuking the entire cluster.&lt;/p&gt;

&lt;p&gt;The decision to use &lt;code&gt;snapshotVolumes: false&lt;/code&gt; on specific schedules is a pragmatic trade-off. I'd rather have a "successful" backup of my YAML manifests and secrets than a "partially failed" backup that tries (and fails) to snapshot a read-only NFS mount. I handle the NFS data separately via a simple &lt;code&gt;tar&lt;/code&gt; and &lt;code&gt;rsync&lt;/code&gt; pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Operational Lessons
&lt;/h3&gt;

&lt;p&gt;If I were to do this again from scratch, I would change a few things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Avoid MinIO in the same rack.&lt;/strong&gt; I have my MinIO nodes in a different physical power circuit. If a PDU fails, I don't want my backup target to go dark at the same time as my cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Kopia over Restic.&lt;/strong&gt; Velero has started supporting Kopia, which is generally faster and handles deduplication more efficiently. If you're starting fresh, go with Kopia.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate Restore Tests.&lt;/strong&gt; A backup is just a theoretical exercise until you've successfully restored it. I now run a monthly "fire drill" where I spin up a temporary single-node cluster and attempt to restore a single non-critical namespace from the MinIO bucket.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The biggest surprise was how much the "small things" matter. A missing &lt;code&gt;s3-url&lt;/code&gt; flag or a slightly misconfigured systemd timer can be the difference between a 10-minute recovery and a weekend spent rebuilding a cluster from Git manifests. &lt;/p&gt;

&lt;p&gt;For those building complex AI agent pipelines or industrial IoT systems, this level of redundancy is mandatory. When your agents are managing state across multiple databases and vector stores, a simple "git clone" of your manifests isn't a backup strategy. You need a consistent snapshot of the entire state, and Velero + MinIO is the most reliable way to achieve that on bare metal.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>velero</category>
      <category>minio</category>
      <category>baremetal</category>
    </item>
    <item>
      <title>Agent Glass-Break Patterns: Controlled Escalation for Production</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Wed, 10 Jun 2026 10:15:27 +0000</pubDate>
      <link>https://dev.to/futhgar/agent-glass-break-patterns-controlled-escalation-for-production-71m</link>
      <guid>https://dev.to/futhgar/agent-glass-break-patterns-controlled-escalation-for-production-71m</guid>
      <description>&lt;p&gt;I watched an autonomous ops agent attempt to "fix" a failing deployment by recursively deleting pods in a loop because it misinterpreted a &lt;code&gt;CrashLoopBackOff&lt;/code&gt; as a transient networking glitch. The agent had the permissions to do it, the logic to justify it, and absolutely no circuit breaker to stop it from taking down the entire namespace. It was a classic case of giving a tool a hammer and watching it treat the entire infrastructure like a nail.&lt;/p&gt;

&lt;p&gt;If you're running agents in production, you've probably realized that the standard "system prompt" safety is a joke. Telling an LLM "please be careful with the production database" is not a security boundary. You need a glass-break pattern: a way for agents to operate within a strict sandbox, but with a controlled, audited path to escalate privileges when a human approves it or a specific condition is met.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I tried first
&lt;/h3&gt;

&lt;p&gt;My first instinct was to lean on centralized identity. I tried routing every agent tool call through an Authentik-protected gateway. The idea was simple: the agent requests a tool, the gateway checks the session, and the action is authorized. &lt;/p&gt;

&lt;p&gt;It was a nightmare. The latency added by the OIDC handshake for every single tool call made the agent feel sluggish, and the integration overhead for low-sensitivity observability tools was absurd. I spent more time debugging JWT expiration and redirect loops than actually building agent capabilities. I was treating a low-sensitivity internal tool like a public-facing enterprise application.&lt;/p&gt;

&lt;p&gt;Then I tried the "Super-User" approach. I gave the agent a high-privilege service account but wrapped it in a complex set of Python decorators that checked for "safe" keywords in the arguments. This failed immediately. LLMs are too good at prompt injection and parameter manipulation. A simple &lt;code&gt;--force&lt;/code&gt; flag or a clever string concatenation bypassed my "safety" filters in minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Actual Solution: Controlled Escalation
&lt;/h3&gt;

&lt;p&gt;The fix was to move the security boundary from the application layer to the infrastructure and execution layer. I implemented a three-pronged approach: Network-level isolation for internal tools, &lt;code&gt;safeBins&lt;/code&gt; for execution control, and a manual escalation trigger.&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Infrastructure-Level Isolation
&lt;/h4&gt;

&lt;p&gt;Instead of forcing every internal tool through a heavy auth layer, I shifted to a LAN-only access model using Kubernetes &lt;code&gt;NetworkPolicy&lt;/code&gt;. This ensures that only the agent orchestrator can talk to the tool, and only from a specific subnet.&lt;/p&gt;

&lt;p&gt;For a tool like Agent Quest, I stripped out the Authentik dependency and locked it down at the pod level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;traefik-allow-egress-to-agentquest&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="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&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;Egress&lt;/span&gt;
  &lt;span class="na"&gt;egress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ipBlock&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;cidr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.0.140/32&lt;/span&gt; &lt;span class="c1"&gt;# The specific IP of the Agent Quest service&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;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4444&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes the auth overhead while ensuring that no one outside the cluster (or even in other namespaces) can trigger the tool. It aligns with the &lt;a href="https://guatulabs.dev/posts/privacy-routed-llm-inference-keeping-sensitive-data-out-of-the-cloud/" rel="noopener noreferrer"&gt;privacy-routed inference&lt;/a&gt; pattern of keeping sensitive traffic off the open wire.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Execution Control with safeBins
&lt;/h4&gt;

&lt;p&gt;For tools that actually execute shell commands, like &lt;code&gt;mcporter&lt;/code&gt;, I stopped relying on regex filters. I implemented a &lt;code&gt;safeBins&lt;/code&gt; pattern. This is essentially an allowlist of binaries and the specific flags they are permitted to use. &lt;/p&gt;

&lt;p&gt;If the agent tries to pass a flag not in the &lt;code&gt;allowedValueFlags&lt;/code&gt; list, the execution engine kills the process before it ever hits the shell.&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;"safeBins"&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;"mcporter"&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;"allowedValueFlags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--config"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--timeout"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"forbiddenFlags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--force"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--recursive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--delete-all"&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;"kubectl"&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;"allowedValueFlags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--dry-run=client"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-n"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"restrictedCommands"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"delete"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"patch"&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="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;This forces the agent to operate in a "read-only" or "safe-write" mode by default. If the agent needs to do something destructive, it cannot simply "decide" to do it; it must trigger the glass-break.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. The Glass-Break Escalation
&lt;/h4&gt;

&lt;p&gt;When the agent hits a &lt;code&gt;safeBins&lt;/code&gt; restriction or a &lt;code&gt;NetworkPolicy&lt;/code&gt; block, it triggers an escalation event. I integrated this with an n8n workflow that sends a Slack notification to me with the exact command the agent wants to run and the reasoning behind it.&lt;/p&gt;

&lt;p&gt;The workflow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Agent fails a &lt;code&gt;safeBins&lt;/code&gt; check.&lt;/li&gt;
&lt;li&gt;The error is caught by the orchestrator and pushed to an n8n webhook.&lt;/li&gt;
&lt;li&gt;n8n sends a message: &lt;em&gt;"Agent X wants to run &lt;code&gt;mcporter --force&lt;/code&gt;. Reason: 'Pod is stuck in Terminating'. Approve?"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;I click "Approve," which updates a temporary Redis key granting the agent a 5-minute window of escalated privileges.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why it works
&lt;/h3&gt;

&lt;p&gt;This works because it acknowledges that the LLM is an unreliable narrator. You cannot trust the agent to follow safety guidelines, but you can trust the Linux kernel and the Kubernetes API.&lt;/p&gt;

&lt;p&gt;By moving the constraints to the binary level (&lt;code&gt;safeBins&lt;/code&gt;) and the network level (&lt;code&gt;NetworkPolicy&lt;/code&gt;), we create a hard boundary. The agent can hallucinate all it wants, but it cannot execute a &lt;code&gt;--force&lt;/code&gt; flag if the execution wrapper doesn't allow it. &lt;/p&gt;

&lt;p&gt;Combining this with the &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;two-tier service account model&lt;/a&gt; ensures that even if the agent escalates, it's using a token with a strictly defined TTL. The "glass-break" isn't just a permission change; it's a temporary shift in the security posture of the system.&lt;/p&gt;

&lt;p&gt;For the MSAM (Model State Management) integration, I had to rewrite the server-side tools using FastMCP to support this. I used a specific &lt;code&gt;IngressRoute&lt;/code&gt; to ensure that the escalation triggers only came from trusted internal IPs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.containo.us/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IngressRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;msam-ingress&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;websecure&lt;/span&gt;
  &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Host(`msam.example.com`)&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rule&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;msam-service&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;443&lt;/span&gt;
          &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;insecureSkipVerify&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;h3&gt;
  
  
  Lessons Learned
&lt;/h3&gt;

&lt;p&gt;The biggest surprise was how much the agent actually &lt;em&gt;prefers&lt;/em&gt; these constraints. When the agent knows exactly what the boundaries are (because the error messages from &lt;code&gt;safeBins&lt;/code&gt; are explicit), it stops trying to guess and starts asking for help. It turns a "failure" into a "collaboration."&lt;/p&gt;

&lt;p&gt;If I were doing this again, I'd automate the memory index rebuilds more aggressively. I found that when I escalated an agent to fix a model registry mismatch (like the OpenClaw v2026.3.12 issue where &lt;code&gt;codex-5.4&lt;/code&gt; wasn't recognized), the agent often forgot that it had already tried a specific fix. I had to implement a &lt;code&gt;rebuild-memory-index.py&lt;/code&gt; script to ensure the agent's long-term memory was synced with the actual state of the registry after a glass-break event.&lt;/p&gt;

&lt;p&gt;A few caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; The human-in-the-loop part of the glass-break is a bottleneck. If you're in a high-availability environment, you'll need to define "Auto-Escalation" rules for low-risk tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity:&lt;/strong&gt; You're adding a layer of middleware between the agent and the tool. If your middleware crashes, your agent is blind. I run my orchestration layer with a strict &lt;code&gt;Recreate&lt;/code&gt; strategy on Kubernetes to avoid the split-brain issues I've seen with Ollama deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ultimately, production AI isn't about building the smartest agent. It's about building the most reliable cage for that agent to live in. The glass-break pattern allows the agent to be useful without giving it the keys to the kingdom.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>security</category>
      <category>mcpservers</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Grafana Dashboards: Information Density vs Readability</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 08 Jun 2026 10:15:13 +0000</pubDate>
      <link>https://dev.to/futhgar/grafana-dashboards-information-density-vs-readability-2j6k</link>
      <guid>https://dev.to/futhgar/grafana-dashboards-information-density-vs-readability-2j6k</guid>
      <description>&lt;p&gt;I spent three hours staring at a "Global Infrastructure" dashboard that took 12 seconds to load, only to realize I couldn't actually tell if my GPU nodes were throttling. I had roughly 40 panels on a single page, ranging from CPU steal percentages to disk IOPS and temperature sensors. It looked like a NASA control room, but it functioned like a legacy database query from 1998.&lt;/p&gt;

&lt;p&gt;If you're managing a multi-node cluster or a complex AI pipeline, the temptation is to put every single metric you can possibly scrape into one view. The logic is: "If it's on the screen, I can't miss it." In reality, when everything is highlighted, nothing is. You end up with a dashboard that is visually noisy and computationally expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Performance Wall
&lt;/h2&gt;

&lt;p&gt;Most people treat Grafana like a static webpage, but every panel is a live query. If you have 40 panels, you're hitting your Prometheus or VictoriaMetrics instance with 40 separate requests every time you refresh the page or change the time range. &lt;/p&gt;

&lt;p&gt;Grafana has internal concurrency limits. It doesn't just fire all 40 queries at once; it batches them. When you hit a certain density, you start seeing the "loading" spinners stagger. You'll see the top row pop in, then a three-second gap, then the middle row. This isn't just an annoyance. It's a signal that your dashboard design is fighting the underlying data source.&lt;/p&gt;

&lt;p&gt;I've seen this happen most often when people deploy a "thorough" community dashboard from a JSON export without pruning it. You get a beautiful layout, but it's querying metrics you don't even have exporters for, leading to a sea of "No Data" panels that still cost query time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Information Density vs. Cognitive Load
&lt;/h2&gt;

&lt;p&gt;There is a difference between a "dense" dashboard and a "cluttered" one. &lt;/p&gt;

&lt;p&gt;A dense dashboard uses a high ratio of data to pixels. It uses small, efficient visualizations (like Stat panels or Gauges) to show current state, and reserves large Time Series panels for trends. &lt;/p&gt;

&lt;p&gt;A cluttered dashboard is just a collection of every graph the engineer thought was "interesting" at the time of creation. &lt;/p&gt;

&lt;p&gt;The goal is to reduce the time between &lt;em&gt;looking&lt;/em&gt; at the screen and &lt;em&gt;understanding&lt;/em&gt; the state of the system. If I have to squint to see if a line is crossing a threshold because there are six other lines in the same color palette, the dashboard has failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Hierarchical Monitoring
&lt;/h2&gt;

&lt;p&gt;Instead of one "God Dashboard," I moved to a three-tier hierarchy. This separates the "Is it broken?" view from the "Why is it broken?" view.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 1: The Heartbeat (High Density, Low Detail)
&lt;/h3&gt;

&lt;p&gt;This is a single screen. No time series graphs. Only Stat panels and Gauges.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Goal:&lt;/strong&gt; Binary state. Green = OK, Red = Action Required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics:&lt;/strong&gt; Cluster-wide CPU/RAM usage, number of Pending pods, GPU temperature peaks, and API latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavior:&lt;/strong&gt; I keep this on a wall monitor. I don't want to see the "wiggle" of a graph; I want to see a red box if a node disappears.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tier 2: The Service View (Medium Density)
&lt;/h3&gt;

&lt;p&gt;This is where I use variables to filter by namespace or node. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Goal:&lt;/strong&gt; Identify the specific component failing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics:&lt;/strong&gt; Per-pod memory usage, network throughput, and request rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavior:&lt;/strong&gt; I use Grafana variables (&lt;code&gt;$node&lt;/code&gt;, &lt;code&gt;$namespace&lt;/code&gt;) so that one dashboard template serves 20 different services.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Tier 3: The Deep Dive (Low Density, High Detail)
&lt;/h3&gt;

&lt;p&gt;These are specialized dashboards for specific hardware or software.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Goal:&lt;/strong&gt; Root cause analysis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics:&lt;/strong&gt; GPU SM clock speeds, PCIe bus errors, or Longhorn volume replication lag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavior:&lt;/strong&gt; I only open these when Tier 1 or Tier 2 tells me something is wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Implementing the Architecture
&lt;/h2&gt;

&lt;p&gt;To make this work without manual overhead, I use a combination of Prometheus ServiceMonitors for auto-discovery and ConfigMaps for dashboard versioning.&lt;/p&gt;

&lt;p&gt;If you're running GPUs, you shouldn't be manually adding every GPU to a dashboard. Use the &lt;code&gt;nvidia-gpu-exporter&lt;/code&gt; and let Prometheus handle the labels.&lt;/p&gt;

&lt;p&gt;Here is how I deploy the exporter to ensure the metrics are clean and available for the hierarchical dashboards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DaemonSet&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;nvidia-gpu-exporter&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;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia-gpu-exporter&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia-gpu-exporter&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/your-org/nvidia-gpu-exporter:v1.4.1&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;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9835&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;NVIDIA_VISIBLE_DEVICES&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;all&lt;/span&gt;
      &lt;span class="na"&gt;runtimeClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To avoid the "manual update" nightmare, I store my dashboard JSONs in Git and deploy them via ConfigMaps. This allows me to prune unnecessary panels across the entire cluster at once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;ConfigMap&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;gpu-monitoring-dashboard&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;grafana_dashboard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dashboard.json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;{&lt;/span&gt;
      &lt;span class="s"&gt;"id": null,&lt;/span&gt;
      &lt;span class="s"&gt;"title": "GPU Health - Tier 2",&lt;/span&gt;
      &lt;span class="s"&gt;"panels": [&lt;/span&gt;
        &lt;span class="s"&gt;{&lt;/span&gt;
          &lt;span class="s"&gt;"type": "stat",&lt;/span&gt;
          &lt;span class="s"&gt;"title": "GPU Memory Usage",&lt;/span&gt;
          &lt;span class="s"&gt;"datasource": "Prometheus",&lt;/span&gt;
          &lt;span class="s"&gt;"targets": [&lt;/span&gt;
            &lt;span class="s"&gt;{&lt;/span&gt;
              &lt;span class="s"&gt;"expr": "sum(dcgm_fb_used) by (instance)"&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
          &lt;span class="s"&gt;]&lt;/span&gt;
        &lt;span class="s"&gt;},&lt;/span&gt;
        &lt;span class="s"&gt;{&lt;/span&gt;
          &lt;span class="s"&gt;"type": "timeseries",&lt;/span&gt;
          &lt;span class="s"&gt;"title": "GPU Temperature Trend",&lt;/span&gt;
          &lt;span class="s"&gt;"targets": [&lt;/span&gt;
            &lt;span class="s"&gt;{&lt;/span&gt;
              &lt;span class="s"&gt;"expr": "dcgm_temp"&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
          &lt;span class="s"&gt;]&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
      &lt;span class="s"&gt;]&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And to ensure Prometheus is actually picking up these metrics without me having to hardcode IPs, I use a &lt;code&gt;ServiceMonitor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring.coreos.com/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;ServiceMonitor&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;nvidia-gpu-exporter&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;release&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nvidia-gpu-exporter&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&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="s"&gt;metrics&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Gotchas of High-Density Design
&lt;/h2&gt;

&lt;p&gt;Even with a hierarchy, there are a few traps I fell into.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "Too Many Variables" Trap
&lt;/h3&gt;

&lt;p&gt;I once built a dashboard with six different dropdown variables (Cluster, Namespace, Pod, Container, Disk, GPU). Every time I changed one, Grafana had to re-evaluate every single panel. It felt like the browser was hanging. &lt;br&gt;
&lt;strong&gt;The Fix:&lt;/strong&gt; Limit your top-level variables. Use "chained" variables where the Pod dropdown only shows pods for the selected Namespace.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Color Palette Problem
&lt;/h3&gt;

&lt;p&gt;When you have 10 lines on one graph, Grafana's default colors start to repeat or become indistinguishable.&lt;br&gt;
&lt;strong&gt;The Fix:&lt;/strong&gt; Use "Overwrites" in the panel settings. Explicitly map a specific metric (e.g., &lt;code&gt;node_cpu_seconds_total{mode="iowait"}&lt;/code&gt;) to a specific color like bright orange. This removes the cognitive load of checking the legend every five seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Refresh Rate Death Spiral
&lt;/h3&gt;

&lt;p&gt;Setting a dashboard to "Auto-refresh: 5s" with 30 panels is a great way to DOS your own Prometheus instance.&lt;br&gt;
&lt;strong&gt;The Fix:&lt;/strong&gt; Tier 1 (Heartbeat) can refresh every 10-15 seconds. Tier 3 (Deep Dive) should be manual. There is no reason to auto-refresh a detailed GPU memory leak analysis every few seconds.&lt;/p&gt;

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

&lt;p&gt;The most important thing I learned is that a dashboard is a tool for decision-making, not a data dump. &lt;/p&gt;

&lt;p&gt;If you can't look at a dashboard for 5 seconds and tell me exactly what is wrong, it's too dense. I've spent too much time building "cool" dashboards that were useless in a 3 AM outage because I had to hunt through 15 panels to find the one metric that actually mattered.&lt;/p&gt;

&lt;p&gt;I've applied this same philosophy to my other infrastructure. For example, when dealing with &lt;a href="https://guatulabs.dev/posts/longhorn-volume-health-monitoring-replication-and-capacity/" rel="noopener noreferrer"&gt;Longhorn volume health&lt;/a&gt;, I stopped trying to track every single replica's sync state on one page. Instead, I created a "Health Score" (a single Stat panel) that only turns red when the aggregate health of the volume drops below 100%.&lt;/p&gt;

&lt;p&gt;If you're building out your own monitoring, start with the "Heartbeat" view. Ask yourself: "What is the one number that tells me I need to wake up?" Build that first. Everything else is just a deep dive for when things actually break.&lt;/p&gt;

&lt;p&gt;For those managing high-performance AI workloads, this becomes even more critical. Monitoring GPU power states and memory fragmentation requires a different level of granularity than monitoring a web server. If you're struggling to balance the noise of bare-metal Kubernetes with the need for precision, I've dealt with these exact trade-offs in my &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;infrastructure consulting&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Stop adding panels. Start deleting them.&lt;/p&gt;

</description>
      <category>grafana</category>
      <category>prometheus</category>
      <category>monitoring</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Edge Computing for IIoT: When to Process at the Source</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Fri, 05 Jun 2026 16:15:13 +0000</pubDate>
      <link>https://dev.to/futhgar/edge-computing-for-iiot-when-to-process-at-the-source-57bc</link>
      <guid>https://dev.to/futhgar/edge-computing-for-iiot-when-to-process-at-the-source-57bc</guid>
      <description>&lt;p&gt;My first attempt at a remote vibration monitoring system ended with a network switch that couldn't handle the throughput and a cloud bill that made me question my life choices. I was streaming raw high-frequency accelerometer data from several machines directly to a central cluster, thinking that "centralized visibility" was the gold standard. It wasn't. I had created a massive bottleneck where a 100ms network spike would cause gaps in the data, making it impossible to detect the very transient faults I was looking for.&lt;/p&gt;

&lt;p&gt;If you're building industrial systems, the temptation is to push everything to a central dashboard as fast as possible. But in IIoT, the distance between the sensor and the compute is where most projects fail. You either drown in noise or you lose the signal because the network dropped a packet.&lt;/p&gt;

&lt;p&gt;I spent a few months thinking that more bandwidth was the answer. I upgraded switches, tweaked MTU settings, and tried to optimize the MQTT payloads. I assumed the problem was the pipe. The reality was that I was trying to move the mountain to the geologist instead of just sending the geologist to the mountain.&lt;/p&gt;

&lt;p&gt;The shift happened when I stopped treating the edge as a "dumb relay" and started treating it as a first-class compute node. I moved the FFT (Fast Fourier Transform) and initial anomaly detection to the source. Instead of sending 10kHz of raw voltage, I started sending a health score and a set of peak frequencies every few seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Architecture: Local Inference and the Privacy Hard-Wall
&lt;/h3&gt;

&lt;p&gt;Once I moved basic signal processing to the edge, the next challenge was intelligence. I wanted an operator to be able to ask a local terminal, "Why is the XYZ-7000 vibrating?" without that query, and the sensitive machine telemetry attached to it, leaving the factory floor.&lt;/p&gt;

&lt;p&gt;This is where the "privacy hard-wall" comes in. I implemented a system where the edge node handles the data synthesis and uses a local LLM to generate the answer. The raw telemetry never leaves the local subnet; only the synthesized natural language answer goes to the central log.&lt;/p&gt;

&lt;p&gt;For this to work, I had to move away from the "cloud-first" mindset. I deployed local inference on the edge nodes using Ollama, but I quickly hit a wall with model capability. I tried &lt;code&gt;qwen2.5:14b-instruct&lt;/code&gt; for tool calling to fetch documentation and real-time stats. It failed miserably. It would hallucinate flags, forget the JSON structure, or simply loop.&lt;/p&gt;

&lt;p&gt;I found that for reliable tool calling in an industrial context, where a wrong command could theoretically trigger a physical action or a security breach, you need a larger context window and better reasoning. I bumped the requirements to &lt;code&gt;qwen3:30b&lt;/code&gt; (or equivalent) as the minimum for any node handling autonomous tool orchestration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation: Securing the Edge Agent
&lt;/h3&gt;

&lt;p&gt;If you're putting an AI agent at the edge to interact with industrial hardware, you cannot give it a raw shell. You need a strict allowlist and a way to ensure that the model doesn't accidentally execute &lt;code&gt;rm -rf /&lt;/code&gt; because it misinterpreted a "cleanup" request.&lt;/p&gt;

&lt;p&gt;I use a configuration-driven approach for tool restriction. In my &lt;code&gt;openclaw.json&lt;/code&gt; (or similar agent config), I define &lt;code&gt;safeBinProfiles&lt;/code&gt;. This ensures the agent can only use specific flags for specific binaries.&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;"safeBinProfiles"&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;"knowledge.sh"&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;"minPositional"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxPositional"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"allowedValueFlags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--list"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"deniedFlags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"--raw"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--export"&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="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;By denying &lt;code&gt;--raw&lt;/code&gt; and &lt;code&gt;--export&lt;/code&gt;, I prevent the agent from dumping the entire local knowledge base into the chat context, which is a primary vector for data exfiltration.&lt;/p&gt;

&lt;p&gt;Another practical hurdle was PATH resolution. I noticed the agent would often fail to call tools because it didn't have the full environment context of my user shell. The allowlist would reject the call because the binary wasn't in a "trusted" directory. I solved this by symlinking my industrial toolset into a dedicated, read-only bin directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a trusted bin directory for the agent&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/iiot-tools/bin

&lt;span class="c"&gt;# Symlink the specific tool to ensure PATH resolution passes the allowlist&lt;/span&gt;
&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /home/operator/scripts/knowledge.sh /opt/iiot-tools/bin/knowledge.sh

&lt;span class="c"&gt;# Update the agent's environment to point here&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/iiot-tools/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Routing and Fallbacks
&lt;/h3&gt;

&lt;p&gt;In a production environment, hardware fails. If the GPU on the edge node dies, you can't just have the system stop working. However, you also can't just failover to GPT-4, because that violates the privacy hard-wall I mentioned earlier.&lt;/p&gt;

&lt;p&gt;I implemented a tiered fallback strategy. If the primary high-performance model (running on a dedicated GPU) is unavailable, the system falls back to a smaller, CPU-bound model on the same node.&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;"model.fallbacks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"ollama/qwen3:30b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; 
    &lt;/span&gt;&lt;span class="s2"&gt;"ollama/qwen2.5:14b-instruct"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; 
    &lt;/span&gt;&lt;span class="s2"&gt;"ollama/phi3:mini"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off here is that the &lt;code&gt;phi3:mini&lt;/code&gt; fallback won't be able to do complex tool calling. I handle this by having the agent detect which model is currently active. If it's on a fallback model, it switches from "Autonomous Mode" (tool calling) to "Read-Only Mode" (answering based on cached data).&lt;/p&gt;

&lt;p&gt;For the actual data retrieval, I use a query-based system rather than a search-based system. Instead of letting the LLM search through files, I use a wrapper script:&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;# The agent calls this instead of reading files directly&lt;/span&gt;
knowledge.sh query &lt;span class="s2"&gt;"What is the warranty period for the XYZ-7000?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This script handles the RAG (Retrieval-Augmented Generation) internally and returns a synthesized answer. This keeps the raw documents hidden from the LLM's direct sight, adding another layer of security. This approach is similar to how I handle &lt;a href="https://guatulabs.dev/posts/privacy-routed-llm-inference-local-models-for-sensitive-data/" rel="noopener noreferrer"&gt;Privacy-Routed LLM Inference&lt;/a&gt; in my other projects.&lt;/p&gt;

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

&lt;p&gt;The reason this beats the "cloud-central" approach is simple: physics.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; Processing a vibration spike at the edge takes microseconds. Sending it to the cloud, waiting for a trigger, and sending a command back takes hundreds of milliseconds. In a CNC machine, that's the difference between a controlled stop and a broken tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth:&lt;/strong&gt; A single 3-axis accelerometer sampling at 20kHz generates a massive amount of data. By performing the FFT at the source, I reduce the data footprint by 99%, sending only the magnitudes of the significant frequency bins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security:&lt;/strong&gt; By keeping the "intelligence" local, the attack surface is limited to the local network. There's no API key sitting in a cloud environment that can be leaked to grant access to the factory floor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This architecture also makes &lt;a href="https://guatulabs.dev/posts/condition-based-vs-time-based-maintenance-making-the-switch" rel="noopener noreferrer"&gt;Condition-Based Maintenance&lt;/a&gt; actually viable. You can't do true condition-based maintenance if your "condition" is dependent on the stability of your WAN connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons Learned
&lt;/h3&gt;

&lt;p&gt;If I had to do this again, I'd spend more time on the hardware abstraction layer. I spent too long writing scripts for specific sensor models. I should have implemented a standardized data format (like Sparkplug B) from day one.&lt;/p&gt;

&lt;p&gt;I also learned that "Edge" is a spectrum. Some things belong on the microcontroller (interrupts, basic filtering), some on the gateway (FFT, local LLM routing), and some in the cluster (long-term trend analysis, fleet-wide health scoring). Trying to put everything on the gateway just creates a different kind of bottleneck.&lt;/p&gt;

&lt;p&gt;The biggest surprise was the model capability gap. I really thought the 14B models would be enough for simple tool calling. They aren't. If you're building an agent that actually controls things or fetches critical data, don't skimp on the VRAM. Get the 30B+ models or you'll spend more time debugging hallucinations than actually monitoring your equipment.&lt;/p&gt;

&lt;p&gt;Finally, the "privacy hard-wall" isn't just about security, it's about trust. Operators are hesitant to use AI tools when they think their every mistake is being uploaded to a corporate cloud for review. When they know the data stays on the machine, they actually use the tools.&lt;/p&gt;

&lt;p&gt;This local-first approach is what allows for a clean &lt;a href="https://guatulabs.dev/posts/equipment-health-scoring-one-number-your-operators-actually-check/" rel="noopener noreferrer"&gt;Equipment Health Score&lt;/a&gt;. Instead of a dashboard with 500 blinking lights, the edge node calculates the score locally and sends one single integer to the cloud. The operator sees a "72," knows it's trending down, and asks the local agent for the reason—all without a single packet of raw telemetry ever leaving the building.&lt;/p&gt;

</description>
      <category>iiot</category>
      <category>edgecomputing</category>
      <category>localllms</category>
      <category>industrialautomation</category>
    </item>
  </channel>
</rss>
