<?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>Your Vector DB Snapshots Are Landing on the Same Disk That Will Fail</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Wed, 15 Jul 2026 16:15:47 +0000</pubDate>
      <link>https://dev.to/futhgar/your-vector-db-snapshots-are-landing-on-the-same-disk-that-will-fail-1l59</link>
      <guid>https://dev.to/futhgar/your-vector-db-snapshots-are-landing-on-the-same-disk-that-will-fail-1l59</guid>
      <description>&lt;p&gt;Qdrant's default snapshot path is &lt;code&gt;./snapshots&lt;/code&gt;, relative to the storage directory. That one config default means every snapshot you create through the API lands on the same filesystem, same volume, same physical SSD as the live collection data. You run &lt;code&gt;POST /collections/memory/snapshots&lt;/code&gt;, you get a 200 back, you feel responsible and safe. You are neither. When that disk fails, the snapshot dies in the same instant as the data it was supposed to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;A snapshot API implies a safety mechanism. The mental model most of us carry is: live data is fragile, snapshots are the recovery path, so by definition they must live somewhere safer. Proxmox reinforces this instinct because VM snapshots and backups feel like separate things, and &lt;a href="https://dev.to/posts/proxmox-backup-server-incremental-backups-for-your-whole-cluster/"&gt;Proxmox Backup Server&lt;/a&gt; actually does ship your VM backups to a different box.&lt;/p&gt;

&lt;p&gt;Vector databases don't work that way out of the box. Qdrant, Chroma, and Milvus all treat snapshots as a serialization feature, not a disaster recovery feature. The snapshot endpoint answers the question "give me a portable copy of this collection" and writes the answer to local disk. Where that local disk lives is your problem.&lt;/p&gt;

&lt;p&gt;The expectation gap is worse in a homelab because the deployment is usually one container on one node with one bind mount:&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;# The setup that feels fine until it isn't&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;qdrant&lt;/span&gt;&lt;span class="pi"&gt;:&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;qdrant/qdrant:v1.13.4&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/nvme0/qdrant:/qdrant/storage&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="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6333:6333"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything under &lt;code&gt;/qdrant/storage&lt;/code&gt; sits on &lt;code&gt;nvme0&lt;/code&gt;. Collections, WAL, and the &lt;code&gt;snapshots/&lt;/code&gt; directory the API writes into. One mount point, one blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;Two separate failures hide in this setup, and the second one is sneakier than the first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure one: physical co-location
&lt;/h3&gt;

&lt;p&gt;Consumer NVMe drives in homelabs run hot, run constantly, and accumulate writes fast when a vector DB is doing HNSW index maintenance and WAL flushes underneath an agent that stores memories on every session. When the drive goes (wear-out, a flaky controller, or the classic SATA cable that worked fine for two years), you lose the collection and every snapshot of it simultaneously.&lt;/p&gt;

&lt;p&gt;The trap is that snapshots make you &lt;em&gt;stop&lt;/em&gt; worrying. You've automated a nightly snapshot job, the API returns success, &lt;code&gt;ls&lt;/code&gt; shows a growing list of &lt;code&gt;.snapshot&lt;/code&gt; files. Every observable signal says "protected." The only signal that says otherwise is &lt;code&gt;df&lt;/code&gt; output showing that the snapshots share a device with the data, and nothing ever prompts you to look at that. It's the same gap I wrote about with &lt;a href="https://dev.to/posts/longhorn-volume-health-monitoring-replication-and-capacity/"&gt;Longhorn volume health&lt;/a&gt;: the system reports healthy because it's checking the wrong property. A snapshot that exists is not the same as a snapshot that survives the failure you're snapshotting against.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failure two: logical incompatibility
&lt;/h3&gt;

&lt;p&gt;Suppose you dodge the physical problem. You copy snapshots to your NAS, the disk fails, you rebuild the node, and you restore. The restore succeeds. Then your MCP server starts throwing errors on every search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UnexpectedResponse: Status code 400 (Bad Request)
"Wrong input: Not existing vector name error: fast-nomic-embed-text-v1.5-q"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the second trap: the snapshot faithfully preserved a schema your tooling no longer speaks. &lt;code&gt;mcp-server-qdrant&lt;/code&gt; moved from unnamed (default) vectors to named vectors around v0.6.0. Collections created by older versions store points with a bare vector; newer versions query by a specific vector name derived from the embedding model, like &lt;code&gt;fast-nomic-embed-text-v1.5-q&lt;/code&gt;. If you run the server with &lt;code&gt;uvx&lt;/code&gt;, which pulls the latest release on invocation, the client upgrades itself silently while your data stays frozen at the old schema. The restored collection is byte-perfect and functionally useless.&lt;/p&gt;

&lt;p&gt;The difference in the collection config 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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Old&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;collection:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;unnamed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;default&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;vector&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;"params"&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;"vectors"&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;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cosine"&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;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;What&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;current&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;client&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;expects:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;named&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;vector&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;"params"&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;"vectors"&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;"fast-nomic-embed-text-v1.5-q"&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;"size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cosine"&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;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;Qdrant has no in-place rename for this. You can't patch a collection from unnamed to named vectors. The only path is a migration: read everything out, write everything into a new collection with the right schema.&lt;/p&gt;

&lt;p&gt;So the full failure mode is a chain. Snapshots co-located with data cover you against nothing physical. Snapshots moved off-disk cover you against hardware but not against schema drift. Real recovery needs both physical separation and a tested restore path against the &lt;em&gt;current&lt;/em&gt; client version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three layers, in order of how much pain they remove.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Put snapshots on a different device
&lt;/h3&gt;

&lt;p&gt;Qdrant exposes the snapshot path as config. Point it at a mount backed by a different physical disk, or better, network storage:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;qdrant&lt;/span&gt;&lt;span class="pi"&gt;:&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;qdrant/qdrant:v1.13.4&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;QDRANT__STORAGE__SNAPSHOTS_PATH=/qdrant/snapshots&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/nvme0/qdrant:/qdrant/storage&lt;/span&gt;        &lt;span class="c1"&gt;# live data: fast local NVMe&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/nas/qdrant-snapshots:/qdrant/snapshots&lt;/span&gt;  &lt;span class="c1"&gt;# snapshots: NFS to the NAS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: Qdrant builds snapshots with hard links where possible and needs a writable temp area, so snapshot creation over NFS is slower and briefly doubles disk usage during packaging. For collections in the single-digit-gigabyte range typical of agent memory, this is a non-issue. If it becomes one, snapshot to a second local disk and rsync from there.&lt;/p&gt;

&lt;p&gt;If you're on Kubernetes with Longhorn instead of a single container, the equivalent move is a separate PVC on a different storage class (or at minimum different replica placement) for the snapshot path. Same principle: the recovery artifact must not share a failure domain with the thing it recovers.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Ship snapshots off the node entirely
&lt;/h3&gt;

&lt;p&gt;A different disk in the same machine still shares a power supply, a mainboard, and your tendency to do risky things to that machine at 11pm. Get snapshots off the node:&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;# nightly-qdrant-snapshot.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;COLLECTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"memory"&lt;/span&gt;
&lt;span class="nv"&gt;QDRANT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://10.0.0.50:6333"&lt;/span&gt;

&lt;span class="c"&gt;# Trigger snapshot, capture the generated name&lt;/span&gt;
&lt;span class="nv"&gt;SNAP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QDRANT&lt;/span&gt;&lt;span class="s2"&gt;/collections/&lt;/span&gt;&lt;span class="nv"&gt;$COLLECTION&lt;/span&gt;&lt;span class="s2"&gt;/snapshots"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.result.name'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Pull it over HTTP and push to object storage&lt;/span&gt;
curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QDRANT&lt;/span&gt;&lt;span class="s2"&gt;/collections/&lt;/span&gt;&lt;span class="nv"&gt;$COLLECTION&lt;/span&gt;&lt;span class="s2"&gt;/snapshots/&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"/tmp/&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
rclone copy &lt;span class="s2"&gt;"/tmp/&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"minio:qdrant-snapshots/&lt;/span&gt;&lt;span class="nv"&gt;$COLLECTION&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"/tmp/&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Keep the node's snapshot dir from filling up&lt;/span&gt;
curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QDRANT&lt;/span&gt;&lt;span class="s2"&gt;/collections/&lt;/span&gt;&lt;span class="nv"&gt;$COLLECTION&lt;/span&gt;&lt;span class="s2"&gt;/snapshots/&lt;/span&gt;&lt;span class="nv"&gt;$SNAP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detail worth noticing: Qdrant lets you &lt;em&gt;download&lt;/em&gt; snapshots over the REST API, so the copy job doesn't need filesystem access to the Qdrant host at all. It can run anywhere that can reach port 6333, which makes it trivial to run as a Kubernetes CronJob or a container on a different node. Restore works the same way in reverse, with &lt;code&gt;PUT /collections/{name}/snapshots/recover&lt;/code&gt; accepting a URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Handle the schema, not just the bytes
&lt;/h3&gt;

&lt;p&gt;For the named-vector migration, snapshots don't help. You need to move the data through the API so it gets re-indexed under the new vector name. The scroll endpoint does the export side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;qdrant_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;QdrantClient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;qdrant_client.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Distance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VectorParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PointStruct&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://10.0.0.50:6333&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;VECTOR_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fast-nomic-embed-text-v1.5-q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vectors_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VECTOR_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;VectorParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Distance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COSINE&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scroll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;with_payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;with_vectors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# omitting this silently drops the vectors
&lt;/span&gt;        &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;PointStruct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VECTOR_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;points&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works when the embedding model hasn't changed, only the vector naming. If the model changed too (different dimensions, different quantization), the vectors themselves are stale and you have to re-embed from the payloads instead of copying vectors across. That's the strongest argument for always storing the source text in the payload: payloads are the durable asset, vectors are a derived cache you can rebuild.&lt;/p&gt;

&lt;p&gt;Afterward, verify the schema instead of assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://10.0.0.50:6333/collections/memory-v2 &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.result.config.params.vectors | keys'&lt;/span&gt;
&lt;span class="c"&gt;# ["fast-nomic-embed-text-v1.5-q"]  &amp;lt;- named vector present, client will be happy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And pin your MCP server version so the client can't drift out from under the data again. &lt;code&gt;uvx mcp-server-qdrant&lt;/code&gt; pulls latest; &lt;code&gt;uvx mcp-server-qdrant==0.7.1&lt;/code&gt; pulls what you tested against. Upgrade deliberately, with a migration plan, not as a side effect of a session starting.&lt;/p&gt;

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

&lt;p&gt;For a lot of homelab services, losing a disk means re-running an Ansible playbook and moving on. Vector databases are different because the data is &lt;em&gt;accumulated&lt;/em&gt;, not &lt;em&gt;declared&lt;/em&gt;. If your agents write session learnings, decisions, and episodic memory into Qdrant (the pattern behind my &lt;a href="https://dev.to/posts/building-karpathy-s-llm-wiki-a-production-homelab-implementation/"&gt;Karpathy-style LLM wiki&lt;/a&gt; and the recall layer in &lt;a href="https://dev.to/posts/cognitive-memory-for-agents-vector-search-vs-activation-based-recall/"&gt;vector search vs activation-based memory&lt;/a&gt;), then months of that accumulation lives in one collection. There's no Git repo to re-clone. The GitOps reflex of "the cluster is disposable, the repo is truth" fails exactly here, because the vector DB &lt;em&gt;is&lt;/em&gt; the truth for that data. Anyone building agent memory systems for real workloads (something I &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;help teams with&lt;/a&gt;) hits this eventually: the memory layer quietly becomes the most irreplaceable state in the whole stack, while getting the least backup attention.&lt;/p&gt;

&lt;p&gt;The general lessons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check where your snapshots physically land, today.&lt;/strong&gt; Not where you think they land. &lt;code&gt;df -h&lt;/code&gt; on the snapshot directory, compare the device against the data directory. This takes thirty seconds and is the whole first half of this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A backup you haven't restored is a hypothesis.&lt;/strong&gt; The 400 error above only surfaces when you actually recover a snapshot and run the current client against it. A quarterly restore drill into a scratch collection would catch schema drift months before it matters. Nobody does quarterly restore drills for their homelab vector DB, which is why pinning client versions is the practical substitute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snapshots and backups answer different questions.&lt;/strong&gt; A snapshot answers "give me a copy of this collection's current state." A backup answers "can I get my data back after the worst realistic failure." The first is a feature the database gives you. The second is a property of your whole setup, and no single API call provides it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payloads outlive vectors.&lt;/strong&gt; Embedding models change, quantization schemes change, vector naming conventions change. The source text in your payloads survives all of it. Design your collections so that a full re-embed from payloads is always possible, and half of these migration problems downgrade from "data loss" to "an afternoon of compute."&lt;/p&gt;

&lt;p&gt;The fix costs one config line and a cron job. The failure costs every memory your agents ever stored. That's about as lopsided as risk math gets.&lt;/p&gt;

</description>
      <category>qdrant</category>
      <category>vectordatabases</category>
      <category>backups</category>
      <category>disasterrecovery</category>
    </item>
    <item>
      <title>Eviction Without Deletion: Running an ACT-R Decay Policy for Agent Memory</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/eviction-without-deletion-running-an-act-r-decay-policy-for-agent-memory-36hi</link>
      <guid>https://dev.to/futhgar/eviction-without-deletion-running-an-act-r-decay-policy-for-agent-memory-36hi</guid>
      <description>&lt;p&gt;An agent's recall gets &lt;em&gt;worse&lt;/em&gt; as its memory grows. Not slower, worse. Past a certain store size, top-K retrieval starts handing back memories that are semantically near the query and useless for the task, because a vector index has no idea whether something is relevant now or just looked relevant six weeks ago. The store fills up, the noise floor rises, and the agent quietly gets dumber while your dashboard says "memory: healthy."&lt;/p&gt;

&lt;p&gt;If you run a long-lived agent with a persistent memory store, anything backed by a vector DB you keep appending to, this is coming for you. The append-only log feels safe. You never lose anything. But "never lose anything" and "always retrieve the right thing" pull in opposite directions, and past a few thousand entries the second one loses. I wrote about the theory side of this in &lt;a href="https://dev.to/posts/cognitive-memory-for-agents-vector-search-vs-activation-based-recall/"&gt;Cognitive Memory for Agents: Vector Search vs Activation-Based Recall&lt;/a&gt;; this post is the production plumbing for the same idea.&lt;/p&gt;

&lt;p&gt;The thesis is simple to state and annoying to accept: you should evict memories from the active retrieval path without deleting them. Reduce their &lt;em&gt;activation&lt;/em&gt; so they stop surfacing, but keep the row so a future query can bring them back. Cognitive scientists have a model for exactly this, and it's older than most of the databases you'd run it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What deletion actually costs you
&lt;/h2&gt;

&lt;p&gt;My first instinct was the obvious one. The store is too big, so prune it. I reached for the three standard tools and each one failed in its own way.&lt;/p&gt;

&lt;p&gt;TTL expiry was first. Delete anything older than N days. This is fine for genuine ephemera and a disaster for everything else, because "old" is not the same as "irrelevant." An agent debugging a rare storage failure wants the note it wrote about that failure four months ago, precisely because the failure is rare. TTL deletes exactly the memories whose value is that they're old and specific. Once the row is gone, the agent can't re-learn it from retrieval. It's just gone.&lt;/p&gt;

&lt;p&gt;LRU eviction was next, the classic cache move: drop the least-recently-used entries when you hit a size cap. Better, because at least it keys on access instead of raw age. It still hard-deletes, though, and it has no notion of importance. A memory accessed once but load-bearing (a security constraint, a hard-won config fix) gets evicted the same as idle chatter, just because nothing happened to touch it this week. LRU treats a critical fact and a stale log line identically.&lt;/p&gt;

&lt;p&gt;Size caps were the third and worst. "Keep the newest 5,000 memories" turns your agent into a goldfish with a fixed horizon. The moment the store saturates, every new memory silently costs you an old one, and you have no control over which. You've built forgetting with no model of what's worth forgetting.&lt;/p&gt;

&lt;p&gt;The pattern underneath all three: they conflate &lt;em&gt;storage management&lt;/em&gt; with &lt;em&gt;memory management&lt;/em&gt;. Deleting a row frees disk. It also destroys the option to ever retrieve that context again. In an agent, retrieval is re-learning. If you delete, you don't just evict from context, you amputate the ability to recover. Storage is cheap. That option is not.&lt;/p&gt;

&lt;p&gt;So the real requirement is a way to make a memory stop showing up in normal retrieval while keeping the row intact, and a way for it to come back on its own if it becomes relevant again. That's decay, not deletion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Borrowing the forgetting curve from ACT-R
&lt;/h2&gt;

&lt;p&gt;ACT-R (Adaptive Control of Thought, Rational) is a cognitive architecture from John Anderson's group at CMU that models how human memory decides what's accessible. The part I care about is the &lt;em&gt;base-level activation&lt;/em&gt; equation, which predicts how retrievable a memory is from nothing but its history of use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;B_i = ln( Σ  t_j^(-d) )   for j = 1..n uses
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it slowly. For each time the memory was used, you take the time since that use, raise it to a negative power &lt;code&gt;d&lt;/code&gt; (the decay rate, canonically 0.5), and sum. Recent uses contribute large terms; ancient uses contribute tiny ones. Then take the log. What falls out is a single scalar that rises with &lt;em&gt;frequency&lt;/em&gt; (more uses, more terms) and with &lt;em&gt;recency&lt;/em&gt; (recent uses dominate the sum), and decays as a power law when a memory sits untouched.&lt;/p&gt;

&lt;p&gt;The power law matters. A pure exponential (&lt;code&gt;e^(-λt)&lt;/code&gt;, the form you'll see in half the "memory decay" blog snippets) forgets on a single timescale and drives old memories to zero fast. Anderson and Schooler showed back in 1991 that human forgetting, and the actual statistics of when information is needed again in the real world, follow a power law with a long tail. Old-but-frequently-used memories keep a floor of activation instead of dropping off a cliff. That long tail is the whole point: it's what lets a rare-but-important memory stay reachable for months.&lt;/p&gt;

&lt;p&gt;Storing every access timestamp per memory doesn't scale, so ACT-R has an optimized form that assumes uses are spread roughly evenly across the memory's lifetime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;B_i ≈ ln( n / (1 - d) ) - d * ln(L)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you only need two numbers per memory: &lt;code&gt;n&lt;/code&gt;, the use count, and &lt;code&gt;L&lt;/code&gt;, the age since creation. Two scalar columns. That's it. This is the version that survives contact with a production database, because you can compute it in SQL with &lt;code&gt;ln()&lt;/code&gt; and an epoch subtraction, no per-access history table required.&lt;/p&gt;

&lt;p&gt;Retrieval activation is base level plus a term for how well the memory matches the current query. In ACT-R that's spreading activation; in our world it's the vector similarity you already compute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A_i = B_i + importance_i + w * sim(query, memory_i)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;importance_i&lt;/code&gt; is a static base weight you assign when writing the memory (a safety rule gets a boost, a passing observation gets zero). &lt;code&gt;w&lt;/code&gt; tunes how much semantic match counts against usage history. Set &lt;code&gt;w&lt;/code&gt; high and you're close to plain vector search; set it low and history dominates. The knob exists precisely so stale-but-similar junk can't win on similarity alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it in Postgres
&lt;/h2&gt;

&lt;p&gt;I run this on pgvector inside Postgres (CloudNativePG on the cluster), because I want the decay math and the ANN search in the same query engine. Here's the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- pgvector store with ACT-R decay metadata; rows are never deleted&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;agent_memory&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;embedding&lt;/span&gt;    &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;importance&lt;/span&gt;   &lt;span class="nb"&gt;real&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- static base weight&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;last_used_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;use_count&lt;/span&gt;    &lt;span class="nb"&gt;integer&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tier&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'hot'&lt;/span&gt;  &lt;span class="c1"&gt;-- 'hot' | 'cold'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;agent_memory&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;agent_memory&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four metadata fields carry the whole policy: &lt;code&gt;importance&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;use_count&lt;/code&gt;, and &lt;code&gt;tier&lt;/code&gt;. No history table, no separate cache. Retrieval computes activation inline and re-ranks by it, filtering to the hot tier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;use_count&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
         &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;greatest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epoch&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
         &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;importance&lt;/span&gt;
         &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;activation&lt;/span&gt;   &lt;span class="c1"&gt;-- $1 = query vector&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;agent_memory&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'hot'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;activation&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;8&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;&amp;lt;=&amp;gt;&lt;/code&gt; is pgvector's cosine distance, so &lt;code&gt;1 - (embedding &amp;lt;=&amp;gt; $1)&lt;/code&gt; is cosine similarity, weighted here by &lt;code&gt;2.0&lt;/code&gt;. The decay constant &lt;code&gt;0.5&lt;/code&gt; is inlined for clarity; parameterize it in real code. HNSW does the approximate search, the arithmetic re-ranks the candidates by activation. One round trip.&lt;/p&gt;

&lt;p&gt;If you'd rather keep the math in the app, the Python is short and easier to unit-test:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;DECAY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;          &lt;span class="c1"&gt;# ACT-R d; 0.5 is the canonical default
&lt;/span&gt;&lt;span class="n"&gt;SIM_WEIGHT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;     &lt;span class="c1"&gt;# semantic match vs. usage history
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;base_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;use_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DECAY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ACT-R optimized base-level learning (Anderson, 1993)
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;use_count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-inf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;use_count&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;decay&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;decay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;activation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;base_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;use_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;importance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;SIM_WEIGHT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one rule that makes or breaks this: reinforce on &lt;em&gt;use&lt;/em&gt;, not on retrieval. When the agent actually uses a memory in its reasoning for a turn, bump its counters. If you bump every candidate the ANN search returned, everything stays hot forever and you've rebuilt the append-only log with extra steps.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- called with the ids the agent actually acted on this turn, not every hit&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;agent_memory&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;use_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;use_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;last_used_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single distinction, use versus retrieval, is what lets activation carry real signal. Retrieval is cheap and noisy; use is the agent voting with its behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Soft eviction: cold, not gone
&lt;/h2&gt;

&lt;p&gt;Eviction is a nightly sweep, not a delete. A Kubernetes CronJob recomputes base-level activation for every row and flips the tier based on a threshold stricter than the retrieval one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;agent_memory&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;use_count&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;greatest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epoch&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;importance&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;            &lt;span class="c1"&gt;-- eviction threshold&lt;/span&gt;
    &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'cold'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'hot'&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cold memories keep their row, their embedding, their history. They just drop out of the default hot query. Your working retrieval set stays small and clean, which is the entire goal: less clutter for the vector search to trip over, fewer tokens shipped to the model, lower latency and cost per turn.&lt;/p&gt;

&lt;p&gt;Re-activation is where "without deletion" earns its keep. When a hot search comes back thin (too few candidates clear the retrieval threshold &lt;code&gt;τ&lt;/code&gt;), the agent runs a fallback query that drops the &lt;code&gt;tier = 'hot'&lt;/code&gt; filter and searches cold storage too. Any cold memory that surfaces and actually gets used gets its &lt;code&gt;use_count&lt;/code&gt; incremented by the same update above. That bumps its base-level activation, and the next nightly sweep flips it back to hot on its own. The memory came back from the cold, because you never destroyed it. No human re-import, no restore from backup, no lost context.&lt;/p&gt;

&lt;p&gt;This is the layer where a decay policy plugs into a bigger design. In &lt;a href="https://dev.to/posts/six-layer-memory-architecture-for-claude-code/"&gt;the six-layer memory architecture I run for Claude Code&lt;/a&gt;, ACT-R decay governs the lower, high-churn layers where most of the volume and most of the noise live, while the top layers (pinned instructions, explicit skills) stay outside the decay path entirely. And a memory that keeps re-activating is a signal in its own right: something the agent reaches for repeatedly is a candidate to promote into a durable &lt;a href="https://dev.to/posts/building-agent-skills-a-pattern-for-discoverable-capabilities/"&gt;agent skill&lt;/a&gt; rather than leaving it to fight for space in the general store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why decay beats deletion
&lt;/h2&gt;

&lt;p&gt;The reason this works isn't just "we kept the rows." It's that activation is a better relevance model than similarity alone, and a better eviction signal than age alone.&lt;/p&gt;

&lt;p&gt;Similarity answers "is this memory &lt;em&gt;about&lt;/em&gt; the query." Activation adds "and has it earned a place in working memory," folding in how often and how recently the agent found it useful. That second question is what filters the semantically-close-but-stale results that pure vector search can't distinguish. You're not fighting the noise floor by making the store smaller through deletion, you're lowering it by making irrelevant memories quiet. Same rows on disk, cleaner signal at query time.&lt;/p&gt;

&lt;p&gt;The power-law tail is what makes non-destructive eviction viable. Because old-but-used memories decay slowly instead of falling to zero, a memory can sit cold for weeks and still be one good re-activation away from hot. You get to run a tight working set (cheap, fast, low-noise) without paying the deletion tax of losing rare knowledge. High precision on the hot path, high recall available on demand through the cold path. You stop trading one for the other.&lt;/p&gt;

&lt;p&gt;There's a reliability angle too. An agent stuck re-deriving the same wrong conclusion is often being fed the same stale context every loop. Decay gives outdated memories a way to fade out of the retrieval set instead of hammering the model every turn, which pairs with the deliberate stopping conditions I covered in &lt;a href="https://dev.to/posts/three-layer-safety-autonomous-agents/"&gt;Three-Layer Safety for Autonomous Agents&lt;/a&gt;. Fresh, relevant context in; quiet, decayed context out; fewer self-reinforcing loops. This kind of memory hygiene is exactly the sort of thing I end up building for clients running agents past the prototype stage, and it's part of what the &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;agent infrastructure work at GuatuLabs&lt;/a&gt; is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tune, and what bit me
&lt;/h2&gt;

&lt;p&gt;Start with &lt;code&gt;d = 0.5&lt;/code&gt;. It's the canonical ACT-R value and a sane default, but decay rate is workload-dependent. An agent whose facts churn fast (live system state, market data) wants faster forgetting, a higher &lt;code&gt;d&lt;/code&gt;. An agent accumulating durable knowledge wants slower. Change one variable at a time and watch what falls out of the hot tier; it's easy to over-forget and not notice until recall quietly degrades.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SIM_WEIGHT&lt;/code&gt; is the knob people get wrong. Set it too high and you're back to plain vector search with decorative math on top. Set it too low and a memory the agent hasn't touched recently can't surface even when it's a dead-on semantic match, which defeats re-activation. I keep similarity dominant enough that a strong match can pull a cold memory back, but not so dominant that history stops mattering. Tune it against real retrieval logs, not intuition.&lt;/p&gt;

&lt;p&gt;The two thresholds are separate on purpose. The retrieval &lt;code&gt;τ&lt;/code&gt; decides what surfaces in a live query; the eviction threshold decides what goes cold overnight. Keep eviction stricter (more negative) than retrieval, so there's a buffer band of memories that are dim but not yet cold. If the two thresholds are equal, memories flap between hot and cold on every sweep and your working set thrashes.&lt;/p&gt;

&lt;p&gt;Multi-agent setups need one more decision: shared decay or isolated. If several agents write to one store, one agent's heavy use of a memory keeps it hot for everyone, which is either exactly what you want (a swarm converging on shared knowledge) or a privacy and relevance leak (one agent's context polluting another's working set). I default to per-agent &lt;code&gt;use_count&lt;/code&gt; and a shared embedding, so activation is personal but the underlying content is deduplicated. The tradeoffs there deserve their own treatment, and they line up with the boundaries in &lt;a href="https://dev.to/posts/multi-agent-ai-systems-architecture-patterns/"&gt;Multi-Agent AI Systems&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The thing that genuinely surprised me: cold storage barely costs anything and having it changes how you write memories. Once eviction is reversible, the pressure to be clever about what to keep evaporates. You stop agonizing over "is this worth remembering" and just write it, because a bad memory doesn't rot the store, it decays out and sits harmless in cold until it's relevant or never. Deletion made every write a permanent commitment. Decay made writes cheap and forgetting safe, which is the right way around. The one real cost is discipline on reinforcement: the day you start bumping counters on retrieval instead of use, the whole policy collapses into an append-only log wearing a lab coat. Keep that line clean and the rest holds.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>agentmemory</category>
      <category>actr</category>
      <category>cognitivememory</category>
    </item>
    <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>
  </channel>
</rss>
