<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: david</title>
    <description>The latest articles on DEV Community by david (@dwoitzik).</description>
    <link>https://dev.to/dwoitzik</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3933869%2F1fb8aa5b-2239-46a7-bf78-b5352809883c.png</url>
      <title>DEV Community: david</title>
      <link>https://dev.to/dwoitzik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dwoitzik"/>
    <language>en</language>
    <item>
      <title>The .gitleaks-baseline.json That Suppressed Live Production Secrets</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 13 Jul 2026 09:35:43 +0000</pubDate>
      <link>https://dev.to/dwoitzik/the-gitleaks-baselinejson-that-suppressed-live-production-secrets-528a</link>
      <guid>https://dev.to/dwoitzik/the-gitleaks-baselinejson-that-suppressed-live-production-secrets-528a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/gitleaks-baseline-suppressing-live-secrets/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A previous article here covered &lt;a href="https://dev.to/blog/gitleaks-secret-scanning-homelab-remediation/"&gt;setting up gitleaks for homelab secret scanning&lt;/a&gt; - the setup, the pre-commit hook, getting CI to fail on new commits that contain secrets. The setup was correct. The tool was running. The CI was green. And it had been quietly suppressing a live production credential for months.&lt;/p&gt;

&lt;p&gt;This is the follow-on story: not about getting gitleaks running, but about the specific way a baseline file breaks the guarantees you think you have once it's in place.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What a Baseline File Does (and Is Supposed to Do)
&lt;/h2&gt;

&lt;p&gt;When gitleaks first runs on an existing repo, it finds every secret-shaped string in the full git history - including secrets that were introduced years ago, rotated long since, and are completely inert. Flagging those in CI creates noise that causes developers to tune out gitleaks entirely, which is worse than not having it.&lt;/p&gt;

&lt;p&gt;The baseline workflow is the standard answer: run gitleaks on the current state, export all findings to a JSON file, commit that file to the repo, and tell gitleaks to suppress any finding that already appears in the baseline. Future commits that introduce &lt;em&gt;new&lt;/em&gt; secrets still fail; old known-inert findings don't.&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 baseline from current HEAD&lt;/span&gt;
gitleaks detect &lt;span class="nt"&gt;--report-format&lt;/span&gt; json &lt;span class="nt"&gt;--report-path&lt;/span&gt; .gitleaks-baseline.json

&lt;span class="c"&gt;# Tell gitleaks to use it&lt;/span&gt;
gitleaks detect &lt;span class="nt"&gt;--baseline-path&lt;/span&gt; .gitleaks-baseline.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assumption embedded in this workflow: findings that appear in the baseline are inert. They were there before the baseline was generated; they've been there; they're known.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Assumption That Broke It
&lt;/h2&gt;

&lt;p&gt;The baseline was generated at a point when the repo contained Garage's &lt;code&gt;rpc_secret&lt;/code&gt; and &lt;code&gt;admin_token&lt;/code&gt; committed in a YAML file. Those were real production values - the cluster was live, using those exact secrets - but the baseline suppression treated them as "known, reviewed, not a problem."&lt;/p&gt;

&lt;p&gt;The commit that introduced them had happened a few weeks before the baseline was generated. The baseline capture happened to include them. From that point forward, gitleaks considered them baseline findings and did not alert on them.&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;.gitleaks-baseline.json&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;it&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;actually&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;contained&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;"Description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generic API Key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"StartLine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Match"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rpc_secret: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;REDACTED&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;"Secret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REDACTED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"File"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kubernetes/system/garage/garage.yml"&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="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 finding was correctly identified as a secret. It just wasn't being acted on because the baseline said it was known.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Finding Was Caught
&lt;/h2&gt;

&lt;p&gt;The finding surfaced during a security audit pass - specifically, a manual re-review of the baseline file itself to check what it was suppressing, not a gitleaks run. The review question was: "for each finding in this baseline, has the secret actually been rotated, or did we just suppress it?"&lt;/p&gt;

&lt;p&gt;Garage's &lt;code&gt;rpc_secret&lt;/code&gt; and &lt;code&gt;admin_token&lt;/code&gt; had not been rotated. The values in the baseline were the same values currently running in production. The baseline was suppressing an active, live credential committed to a public repository.&lt;/p&gt;

&lt;p&gt;The audit entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SEC-013: Garage rpc_secret and admin_token committed to public repo
Status: CRITICAL - values unrotated since 2026-06-03 commit
Suppressed by: .gitleaks-baseline.json (line 47, line 89)
Action: rotate both via Vault, force ExternalSecret resync, restart Garage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Remediation
&lt;/h2&gt;

&lt;p&gt;Rotation via Vault (where the secrets were already supposed to live, per the ExternalSecret setup):&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;# Write new secrets to Vault&lt;/span&gt;
vault kv patch secret/garage &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;rpc_secret&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;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;admin_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Force ExternalSecret to re-sync&lt;/span&gt;
kubectl annotate externalsecret garage-secret &lt;span class="nt"&gt;-n&lt;/span&gt; system &lt;span class="se"&gt;\&lt;/span&gt;
  force-sync&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--overwrite&lt;/span&gt;

&lt;span class="c"&gt;# Verify new secret picked up&lt;/span&gt;
kubectl get secret garage-secret &lt;span class="nt"&gt;-n&lt;/span&gt; system &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.rpc_secret}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Restart Garage to pick up new RPC secret&lt;/span&gt;
kubectl rollout restart deployment/garage &lt;span class="nt"&gt;-n&lt;/span&gt; system

&lt;span class="c"&gt;# Verify RPC handshake succeeded on new secret&lt;/span&gt;
kubectl logs &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;garage &lt;span class="nt"&gt;-n&lt;/span&gt; system | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"rpc&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;handshake&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;connect"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After rotation, the baseline file was updated to remove the (now-rotated) findings. The new baseline only contains findings where the secret is confirmed rotated or confirmed not a real secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Structural Fix: Baseline Review as Part of the Rotation Workflow
&lt;/h2&gt;

&lt;p&gt;The root problem isn't that baseline files exist - they're genuinely useful. The problem is that "add to baseline" and "rotated and safe" are two completely different states that the workflow treats identically. Every finding in a baseline should have a documented reason it's suppressed, and "it was already there when I set up gitleaks" is not a sufficient reason.&lt;/p&gt;

&lt;p&gt;The updated workflow:&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 auto-add to baseline without a verified status&lt;/span&gt;
&lt;span class="c"&gt;# Each entry in .gitleaks-baseline.json now has a comment in the adjacent audit doc:&lt;/span&gt;
&lt;span class="c"&gt;# - ROTATED: secret value changed, old value in history is inert&lt;/span&gt;
&lt;span class="c"&gt;# - FALSE_POSITIVE: not actually a secret (test value, placeholder, etc.)&lt;/span&gt;
&lt;span class="c"&gt;# - PENDING_ROTATION: known issue, rotation scheduled, date logged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last category - PENDING_ROTATION - is the honest version of what the original baseline was doing. The difference is that it's explicit, it has a date, and it shows up in the audit document as an open finding rather than a silently-closed one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The concrete check to run against any existing gitleaks baseline:&lt;/strong&gt;&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;# For each secret in the baseline, check if it's still in the live cluster:&lt;/span&gt;
&lt;span class="c"&gt;# 1. Get the secret value from the baseline&lt;/span&gt;
&lt;span class="c"&gt;# 2. Check Vault / ExternalSecret / live Secret for the current value&lt;/span&gt;
&lt;span class="c"&gt;# 3. If they match → not rotated, baseline is suppressing a live credential&lt;/span&gt;
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].Secret'&lt;/span&gt; .gitleaks-baseline.json | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;secret&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;"Checking: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;secret&lt;/span&gt;:0:8&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
  &lt;span class="c"&gt;# grep your Vault / k8s secrets for this value&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This check doesn't fit neatly into automated CI - it requires access to the live secret values, not just the git history. It belongs in a periodic manual audit, not a commit hook. But it's the only check that answers the actual question: not "does gitleaks find new secrets" but "are the secrets it's ignoring actually safe to ignore."&lt;/p&gt;




&lt;p&gt;The same class of problem appears in Azure at scale: Azure Policy compliance reports show a resource as "Compliant" because it was exempted when the policy was first assigned, not because the underlying condition was remediated. The exemption and the fix look identical in the dashboard. Periodic review of exemptions - exactly like periodic review of a gitleaks baseline - is the only way to tell the difference.&lt;/p&gt;



</description>
      <category>security</category>
      <category>gitops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Redis Killed Nextcloud and Nobody Noticed for Hours</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 06 Jul 2026 14:08:48 +0000</pubDate>
      <link>https://dev.to/dwoitzik/redis-killed-nextcloud-and-nobody-noticed-for-hours-579b</link>
      <guid>https://dev.to/dwoitzik/redis-killed-nextcloud-and-nobody-noticed-for-hours-579b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/redis-rdb-persistence-no-pvc-kubernetes-outage/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Nextcloud outage looked like a capabilities problem at first. Kubernetes had just received a &lt;code&gt;securityContext&lt;/code&gt; hardening pass — &lt;code&gt;readOnlyRootFilesystem: true&lt;/code&gt; across most containers — and the timing fit. Pods were up, they passed readiness checks, but PHP requests were hanging and returning zero bytes. The capabilities hardening had broken things before. It was a reasonable assumption.&lt;/p&gt;

&lt;p&gt;It was wrong. The capabilities issue was real, but it wasn't what took Nextcloud down. This is the Redis story.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The Setup That Created the Problem
&lt;/h2&gt;

&lt;p&gt;Nextcloud's session data and file lock cache both run through a Redis instance deployed as a sidecar-style container in the same manifest. That Redis container had no &lt;code&gt;PersistentVolumeClaim&lt;/code&gt; — it was intentionally ephemeral, purely in-memory, with sessions expected to survive only as long as the pod did.&lt;/p&gt;

&lt;p&gt;The problem: Redis doesn't know it's running without a PVC. Its default configuration ships with persistence enabled:&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="n"&gt;save&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;   &lt;span class="c"&gt;# write RDB snapshot if 1 key changed in the last hour
&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;  &lt;span class="c"&gt;# write RDB snapshot if 100 keys changed in the last 5 minutes
&lt;/span&gt;&lt;span class="n"&gt;save&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt; &lt;span class="c"&gt;# write RDB snapshot if 10000 keys changed in the last minute
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nextcloud is an active application. It easily generates 100 session and lock writes within 300 seconds of normal use. When the 300-second threshold triggered, Redis attempted to write a snapshot — and hit a read-only filesystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed opening the temp RDB file temp-repl-16307.rdb (in server root dir /data)
for saving: Read-only file system
Background saving error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error alone would be logged and ignored. Redis would retry on the next threshold. The issue is what happens &lt;em&gt;after&lt;/em&gt; enough background saves fail: Redis enables its &lt;code&gt;stop-writes-on-bgsave-error&lt;/code&gt; safeguard, which is &lt;code&gt;yes&lt;/code&gt; by default, and which means exactly what it says — &lt;strong&gt;Redis stops accepting write commands entirely&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Logs Were Misleading
&lt;/h2&gt;

&lt;p&gt;The Redis log showed the RDB error. But Redis was still running, still responding to pings, still reachable via the network — just refusing writes. From Nextcloud's PHP perspective, the Redis session handler was calling &lt;code&gt;SET&lt;/code&gt; and getting back a Redis error object instead of &lt;code&gt;OK&lt;/code&gt;. PHP's error handling in that path doesn't surface a visible 500 — it hangs the request or returns an empty response, depending on the session lock timeout.&lt;/p&gt;

&lt;p&gt;The result from outside: pages loaded up to the authentication stage, then returned nothing. Not a 500, not a timeout message — nothing. That looks exactly like what &lt;code&gt;capabilities.drop: [ALL]&lt;/code&gt; does when it breaks an entrypoint: the process starts, gets partway through initialization, then stops.&lt;/p&gt;

&lt;p&gt;The actual diagnostic path that found 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;# Redis container still running, pod shows Running — check the logs directly&lt;/span&gt;
kubectl logs nextcloud-&amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nt"&gt;-c&lt;/span&gt; redis-nextcloud &lt;span class="nt"&gt;--previous&lt;/span&gt;

&lt;span class="c"&gt;# Key line:&lt;/span&gt;
&lt;span class="c"&gt;# "Background saving error" / "MISCONF Redis is configured to save RDB snapshots,&lt;/span&gt;
&lt;span class="c"&gt;# but it's currently not able to persist on disk."&lt;/span&gt;

&lt;span class="c"&gt;# Confirm Redis is refusing writes:&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; nextcloud-&amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nt"&gt;-c&lt;/span&gt; redis-nextcloud &lt;span class="nt"&gt;--&lt;/span&gt; redis-cli SET test_key test_val
&lt;span class="c"&gt;# → (error) MISCONF Redis is configured to save RDB snapshots...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the actual Redis error surfaced, the fix was immediate and obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Invoke redis-server Directly with Persistence Off
&lt;/h2&gt;

&lt;p&gt;The fix used the same pattern already in place for the cluster's other ephemeral Redis instances (immich-redis, valkey) — invoke &lt;code&gt;redis-server&lt;/code&gt; directly with the relevant flags rather than relying on the image's default 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;# Before: redis-nextcloud relied on image defaults (persistence on)&lt;/span&gt;
&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-nextcloud&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;redis:8&lt;/span&gt;

&lt;span class="c1"&gt;# After: explicit command disabling both RDB and AOF&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;redis-nextcloud&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;redis:8&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis-server&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--save"&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="c1"&gt;# empty string disables all save thresholds&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--appendonly"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same change applied to paperless's Redis, which had the same no-PVC configuration. The authelia Redis was left untouched — it actually has a real PVC (&lt;code&gt;kubernetes/system/redis&lt;/code&gt;) so its &lt;code&gt;--appendonly yes&lt;/code&gt; configuration is intentional and works correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Asymmetry That Makes This Hard to Catch
&lt;/h2&gt;

&lt;p&gt;Redis with persistence enabled and no PVC doesn't fail immediately. It runs, accepts connections, handles traffic, and appears healthy in every Kubernetes signal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pod status: &lt;code&gt;Running&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Readiness probe: passing (Redis responds to TCP or &lt;code&gt;PING&lt;/code&gt; checks)&lt;/li&gt;
&lt;li&gt;ArgoCD application: &lt;code&gt;Synced/Healthy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Restart count: 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure only manifests after the first persistence threshold is hit — which for the &lt;code&gt;300 100&lt;/code&gt; threshold (100 writes in 5 minutes) could be minutes into normal operation, or could be hours if traffic is light. After that threshold fires and the first failed RDB save triggers &lt;code&gt;stop-writes-on-bgsave-error&lt;/code&gt;, the failure is silent at the Redis level and confusing at the application level.&lt;/p&gt;

&lt;p&gt;The version of this that would have prevented the outage:&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;# Before deploying any Redis instance, check whether it has a PVC:&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 20 &lt;span class="s2"&gt;"redis"&lt;/span&gt; kubernetes/apps/nextcloud/nextcloud.yml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"persistentVolumeClaim&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;claimName"&lt;/span&gt;
&lt;span class="c"&gt;# If nothing → persistence must be explicitly disabled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or more defensively: any Redis container without a PVC in its volume list gets &lt;code&gt;--save "" --appendonly no&lt;/code&gt; as a matter of policy, not an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Shows Up in Hardened Clusters More Than Default Ones
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;readOnlyRootFilesystem: true&lt;/code&gt; security hardening is what surfaced this. Without it, Redis would have written its RDB snapshots to the container's writable root filesystem — wasteful, potentially filling the node's ephemeral storage, but functionally harmless in the short term. The write would succeed, &lt;code&gt;stop-writes-on-bgsave-error&lt;/code&gt; would never trigger, and the outage would never happen.&lt;/p&gt;

&lt;p&gt;In that sense, the security hardening exposed a pre-existing misconfiguration that was only dormant because the filesystem was writable. The hardening was correct. The Redis configuration was wrong, and the hardening just removed the thing that had been quietly absorbing the mistake.&lt;/p&gt;




&lt;p&gt;The same scenario applies to any Redis instance in Kubernetes where sessions, cache, or ephemeral data don't need to survive a pod restart — which, in a cluster with properly-sized application replicas, is most of them. If the Redis deployment doesn't have a PVC, it should have &lt;code&gt;--save "" --appendonly no&lt;/code&gt; as explicit arguments rather than inheriting defaults designed for a server with durable storage.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>redis</category>
      <category>homelab</category>
    </item>
    <item>
      <title>The Disaster Recovery Runbook Nobody Had Actually Run</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 06 Jul 2026 14:03:52 +0000</pubDate>
      <link>https://dev.to/dwoitzik/the-disaster-recovery-runbook-nobody-had-actually-run-2mke</link>
      <guid>https://dev.to/dwoitzik/the-disaster-recovery-runbook-nobody-had-actually-run-2mke</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/backup-restore-test-never-verified-network-conflict/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;DISASTER-RECOVERY.md&lt;/code&gt; had a Tier 1 procedure: restore the latest Proxmox Backup Server snapshot for a given container, boot it, confirm the application comes back up with real data. It was written carefully, reviewed, and had sat untested for months. Writing a recovery runbook and running a recovery are two different acts of verification, and only one of them had actually happened.&lt;/p&gt;

&lt;p&gt;This is what running it for the first time found.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The Test, Designed to Be Safe
&lt;/h2&gt;

&lt;p&gt;The container chosen was &lt;code&gt;ct-srv-atlantis-01&lt;/code&gt; — not because it was low-stakes exactly, but because its data (Terraform plan history, lock state) is reconstructible if something went wrong, unlike, say, Nextcloud's actual file storage.&lt;/p&gt;

&lt;p&gt;The test design: restore the latest PBS snapshot to a scratch VMID, distinct from the running original, boot it there, verify it comes up with its Docker stack auto-starting and real data present, then destroy the scratch instance. The live original was never touched, never stopped, never at risk.&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;# Restore to a new, unused VMID -- not overwriting the original&lt;/span&gt;
proxmox-backup-client restore &amp;lt;snapshot-id&amp;gt; &lt;span class="nt"&gt;--target-vmid&lt;/span&gt; 9901

&lt;span class="c"&gt;# Original ct-srv-atlantis-01 keeps running the entire time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the correct way to test a restore procedure without creating the exact outage you're testing recovery from. The design held up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotcha the Documentation Never Mentioned
&lt;/h2&gt;

&lt;p&gt;The restored container came up. Its Docker stack auto-started correctly, per the systemd unit configuration already in place. Its data was intact — Terraform state, PR history, everything as expected.&lt;/p&gt;

&lt;p&gt;Then it started fighting with the original over the network.&lt;/p&gt;

&lt;p&gt;PBS restores duplicate the source's configuration exactly, which includes its static IP and MAC address. The scratch container ct-9901 booted with the identical IP and MAC as the still-running &lt;code&gt;ct-srv-atlantis-01&lt;/code&gt;. Two devices claiming the same IP and MAC on the same VLAN produces exactly the failure mode you'd expect — ARP conflicts, unpredictable routing of traffic to whichever host answered last, and depending on switch/router ARP table behavior, either host could silently lose connectivity for a period.&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;# What actually needs to happen before booting a restored scratch instance:&lt;/span&gt;
&lt;span class="c"&gt;# 1. Restore to new VMID (already being done)&lt;/span&gt;
&lt;span class="c"&gt;# 2. Before first boot: change the network config to avoid IP/MAC collision&lt;/span&gt;
pct &lt;span class="nb"&gt;set &lt;/span&gt;9901 &lt;span class="nt"&gt;-net0&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eth0,bridge&lt;span class="o"&gt;=&lt;/span&gt;vmbr0,ip&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;scratch-ip&amp;gt;/24,hwaddr&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;new-mac&amp;gt;
&lt;span class="c"&gt;# 3. Only then start it&lt;/span&gt;
pct start 9901
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This wasn't caught by planning, only by actually running the test and watching the original container's connectivity briefly degrade. No amount of reading the PBS documentation surfaces this — it's not a PBS bug, it's an inherent property of what "restore" means: a byte-for-byte duplicate of the source, network identity included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Finding: A Runbook Step That Would Have Failed
&lt;/h2&gt;

&lt;p&gt;While writing up this test, a second problem surfaced — not from testing, but from cross-checking Tier 1's other listed recovery path against a separate finding from the same week (REL-051, covered separately). The runbook's fallback for "total PBS storage loss" pointed at the Google Drive offsite sync as the recovery source.&lt;/p&gt;

&lt;p&gt;That offsite sync had been deliberately disabled months earlier — the destination Google Drive account didn't have enough free space for the datastore, so the sync was turned off rather than left to fail daily. The runbook still described it as an active recovery tier. Following it during a real total-loss event would have sent someone down a dead path while genuinely searching for a way to restore.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- DISASTER-RECOVERY.md, before --&amp;gt;&lt;/span&gt;
&lt;span class="gu"&gt;### Tier 1: Total PBS Storage Loss&lt;/span&gt;
Restore from the Google Drive offsite backup: [procedure]

&lt;span class="c"&gt;&amp;lt;!-- After --&amp;gt;&lt;/span&gt;
&lt;span class="gu"&gt;### Tier 1: Total PBS Storage Loss&lt;/span&gt;
&lt;span class="gs"&gt;**No working recovery path currently exists for this scenario.**&lt;/span&gt;
Google Drive offsite sync is deliberately disabled (insufficient storage
quota for this datastore's chunk format — see docs/backup-strategy.md).
This is a known, deferred gap, not an oversight.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Documenting "this doesn't work" is a strictly better outcome than documenting a procedure that looks complete but silently fails during the one moment it matters. A runbook that's honest about its gaps is more useful in an actual incident than one that's optimistically wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Documented" and "Tested" Are Different Claims
&lt;/h2&gt;

&lt;p&gt;The instinct after writing a recovery procedure is to consider the job done — the steps are correct, they were reviewed, they match how the backup tool is supposed to work. But "the steps are theoretically correct" and "these steps, run against a real host, actually produce a working restore" are different claims, and only the second one matters during an actual incident.&lt;/p&gt;

&lt;p&gt;The IP/MAC conflict specifically would only ever be discovered by running the restore against infrastructure that has other live devices on the same network — which is exactly the situation a real disaster recovery event is in. A test performed in isolation (a lab VLAN with nothing else running) wouldn't have found it either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The concrete practice this argues for:&lt;/strong&gt; any documented recovery procedure gets a scheduled dry run, on a cadence that matches how often the underlying infrastructure changes — new network segments, new backup targets, new container images. A runbook that hasn't been executed in the last few months should be treated as unverified, regardless of how carefully it reads.&lt;/p&gt;




&lt;p&gt;The same gap shows up in Azure disaster recovery plans built around ASR (Azure Site Recovery) or storage account geo-redundancy: the failover procedure is documented, the RTO/RPO numbers are in the compliance doc, and nobody has actually triggered a failover drill against a resource with the same network topology as production. The IP/MAC duplication issue here has a direct Azure analogue — a failed-over VM retaining its original NIC configuration can conflict with resources still running in the primary region during a partial failover test. Test the actual failover, not just the plan for one.&lt;/p&gt;



</description>
      <category>backup</category>
      <category>proxmox</category>
      <category>homelab</category>
    </item>
    <item>
      <title>kubectl Said Everything Was Correct. Traefik 404'd Anyway.</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 29 Jun 2026 11:09:55 +0000</pubDate>
      <link>https://dev.to/dwoitzik/kubectl-said-everything-was-correct-traefik-404d-anyway-360c</link>
      <guid>https://dev.to/dwoitzik/kubectl-said-everything-was-correct-traefik-404d-anyway-360c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/traefik-endpointslice-vs-endpoints-kubernetes/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Jellyfin's k3s Deployment had no GPU passthrough — pure software transcoding on a cluster with no GPU access. Moving it to a dedicated LXC with VAAPI hardware transcode access to the host's APU is straightforward in principle: stand up the LXC, run Jellyfin there via Ansible, and point the existing Traefik &lt;code&gt;IngressRoute&lt;/code&gt; at the new location instead of a cluster pod.&lt;/p&gt;

&lt;p&gt;That last part — pointing a Kubernetes Service at an external IP without changing anything downstream — should be one of the more boring parts of a migration like this. It produced the more interesting bug of the two covered here.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Routing a Service to an IP Outside the Cluster
&lt;/h2&gt;

&lt;p&gt;Traefik's &lt;code&gt;IngressRoute&lt;/code&gt; for Jellyfin references a Service by name — &lt;code&gt;services: [{name: jellyfin, port: 8096}]&lt;/code&gt;. To avoid touching that IngressRoute (and its Authelia middleware) at all, the plan was to keep the Service object, but back it with the LXC's IP instead of a pod selector. Kubernetes has a mechanism for exactly this: skip the Service's &lt;code&gt;selector&lt;/code&gt; field, and manually create an object that lists the actual backend addresses.&lt;/p&gt;

&lt;p&gt;The modern way to do this is &lt;code&gt;EndpointSlice&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;jellyfin&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8096&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8096&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;discovery.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;EndpointSlice&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes.io/service-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin&lt;/span&gt;
&lt;span class="na"&gt;addressType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IPv4&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;name&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;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8096&lt;/span&gt;
&lt;span class="na"&gt;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;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.20.254&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every check via &lt;code&gt;kubectl&lt;/code&gt; confirmed this was correct: the Service existed, the EndpointSlice existed with the right label linking it to the Service, the endpoint address listed correctly. By every signal &lt;code&gt;kubectl&lt;/code&gt; could give, this was done.&lt;/p&gt;

&lt;p&gt;Every request to &lt;code&gt;media.woitzik.dev&lt;/code&gt; 404'd.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the Actual Error Instead of Re-Checking kubectl
&lt;/h2&gt;

&lt;p&gt;The instinct when &lt;code&gt;kubectl&lt;/code&gt; says everything is fine is to re-check &lt;code&gt;kubectl&lt;/code&gt; — verify the label matches exactly, check for typos, look for a missing field. None of that was the problem. The actual answer was sitting in Traefik's own logs the entire time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subset not found for apps/jellyfin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traefik's Kubernetes CRD provider resolves a Service's backend addresses through the &lt;strong&gt;legacy &lt;code&gt;v1/Endpoints&lt;/code&gt; API&lt;/strong&gt; — the object with &lt;code&gt;subsets&lt;/code&gt;, not the newer &lt;code&gt;EndpointSlice&lt;/code&gt;. This is true regardless of the fact that &lt;code&gt;EndpointSlice&lt;/code&gt; is the object Kubernetes itself prefers and recommends for new code, and regardless of the fact that &lt;code&gt;kubectl&lt;/code&gt; has no opinion about which one any particular consumer actually reads. Kubernetes ships both APIs side by side specifically for this kind of provider-compatibility gap — and Traefik's ingress controller, as of the version in use here, is one of the consumers that hasn't moved to the newer one.&lt;/p&gt;

&lt;p&gt;The fix is a straightforward swap to the older object:&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;Endpoints&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps&lt;/span&gt;
&lt;span class="na"&gt;subsets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.0.20.254&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8096&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirmed immediately via &lt;code&gt;curl&lt;/code&gt; against the public hostname, and via Traefik's own logs going quiet for that route. Same conceptual object, different API, and only one of the two is the one this specific consumer actually reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The general lesson:&lt;/strong&gt; "kubectl shows it's configured correctly" answers &lt;em&gt;whether the Kubernetes API server accepted and stored the object&lt;/em&gt; — it says nothing about &lt;em&gt;whether the specific consumer reading that object supports the API version you used&lt;/em&gt;. For anything involving a controller or ingress provider reading Kubernetes objects indirectly (CRD-based routing being the clearest example), check that controller's own logs and documented compatibility before assuming a &lt;code&gt;kubectl&lt;/code&gt;-clean object is a working one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Gotcha: A PVC Shared by Reference Across Unrelated Files
&lt;/h2&gt;

&lt;p&gt;While removing Jellyfin's old in-cluster Deployment and its associated PVC, a second issue surfaced — this one with real data-loss potential, caught only because Kubernetes' own safety mechanism bought time.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;media&lt;/code&gt; PVC had been defined in &lt;code&gt;jellyfin.yml&lt;/code&gt;. It looked, from that file alone, like it belonged to Jellyfin and only Jellyfin. It didn't: four other Deployments in a completely different file — &lt;code&gt;usenet.yml&lt;/code&gt;'s Sonarr, Radarr, Bazarr, and SABnzbd — referenced the exact same PVC by name:&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;# usenet.yml — nothing in this file defines "media", it just claims it&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;media&lt;/span&gt;
    &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;media&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting the PVC's defining resource from &lt;code&gt;jellyfin.yml&lt;/code&gt; put it into &lt;code&gt;Terminating&lt;/code&gt; state. It did not actually disappear, because of &lt;code&gt;pvc-protection&lt;/code&gt; — a built-in Kubernetes finalizer that blocks PVC deletion while any pod still references it. The four usenet pods kept running, the PVC stayed &lt;code&gt;Bound&lt;/code&gt; from their perspective, and nothing broke &lt;em&gt;in that moment&lt;/em&gt;. But that's a temporary window, not a safe state: the moment any of those four pods restarted — a node drain, an OOM kill, a routine rollout — the PVC's finalizer would have cleared and the volume would have gone with it, taking the actual media library out from under four still-running applications.&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;# What would have caught this before deleting anything:&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"claimName: media"&lt;/span&gt; kubernetes/
&lt;span class="c"&gt;# → jellyfin.yml (the file being edited)&lt;/span&gt;
&lt;span class="c"&gt;# → usenet.yml (NOT obvious from looking at jellyfin.yml alone)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: recreate the same NFS-backed volume under new names, owned by &lt;code&gt;usenet.yml&lt;/code&gt; — the file whose Deployments actually still needed it — and repoint all four &lt;code&gt;claimName&lt;/code&gt; references to the new names. The old PVC finished terminating cleanly once nothing referenced it anymore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The general lesson:&lt;/strong&gt; before deleting any &lt;code&gt;PersistentVolumeClaim&lt;/code&gt; (or any named Kubernetes object that other resources reference indirectly — Secrets, ConfigMaps, Services), grep the entire manifest tree for that name, not just the file that appears to define it. &lt;code&gt;claimName&lt;/code&gt;, &lt;code&gt;secretName&lt;/code&gt;, and similar cross-references are invisible from the defining file alone, and Kubernetes' own protective finalizers — while genuinely useful — can create a false sense of safety: "nothing broke yet" is not the same claim as "this was safe."&lt;/p&gt;

&lt;h2&gt;
  
  
  Both Gotchas, Side by Side
&lt;/h2&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;EndpointSlice/Endpoints&lt;/th&gt;
&lt;th&gt;Shared PVC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What &lt;code&gt;kubectl&lt;/code&gt; showed&lt;/td&gt;
&lt;td&gt;Completely correct&lt;/td&gt;
&lt;td&gt;Completely correct (until deletion)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What actually mattered&lt;/td&gt;
&lt;td&gt;Whether the &lt;em&gt;consuming controller&lt;/em&gt; (Traefik) supports the object's API version&lt;/td&gt;
&lt;td&gt;Whether &lt;em&gt;other files&lt;/em&gt; reference the same object by name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where the real signal was&lt;/td&gt;
&lt;td&gt;The controller's own logs, not the API server&lt;/td&gt;
&lt;td&gt;A repo-wide grep, not the file being edited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety net that bought time&lt;/td&gt;
&lt;td&gt;None — direct outage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pvc-protection&lt;/code&gt; finalizer — temporary, not a fix&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both failures share the same shape: &lt;code&gt;kubectl&lt;/code&gt; (or any direct API inspection) confirms an object exists and is well-formed, but the actual question — does &lt;em&gt;this specific consumer&lt;/em&gt; read it correctly, does &lt;em&gt;this specific name&lt;/em&gt; have other dependents — lives somewhere &lt;code&gt;kubectl&lt;/code&gt; doesn't look. The fix in both cases was checking a different source of truth: the consuming controller's logs in one case, a full-tree text search in the other.&lt;/p&gt;




&lt;p&gt;The Traefik EndpointSlice/Endpoints gap specifically is worth checking against your own ingress controller version before relying on it — provider compatibility for newer Kubernetes APIs varies and changes between releases. For Azure environments running AKS with an external service (an on-prem system, a VM outside the cluster), the same external-IP-backed-Service pattern applies, and the same "check what your specific ingress controller actually supports" caveat applies just as directly.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>networking</category>
      <category>homelab</category>
    </item>
    <item>
      <title>SLO Burn-Rate Alerting with Prometheus: Beyond Threshold Alerts</title>
      <dc:creator>david</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:03:59 +0000</pubDate>
      <link>https://dev.to/dwoitzik/slo-burn-rate-alerting-with-prometheus-beyond-threshold-alerts-2218</link>
      <guid>https://dev.to/dwoitzik/slo-burn-rate-alerting-with-prometheus-beyond-threshold-alerts-2218</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/slo-burn-rate-alerting-prometheus-k3s/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most uptime alerts look like this:&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;ServiceDown&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;probe_success == &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;2m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fires when a service is completely down for two minutes. It won't fire when a service is responding to 95% of requests for 48 hours straight — even though that's silently consuming your entire monthly error budget.&lt;/p&gt;

&lt;p&gt;Burn-rate alerting is a different model. Instead of alerting on current state, it alerts on &lt;strong&gt;how fast you're spending your error budget&lt;/strong&gt;. A 30x burn rate means you'll exhaust your entire month of tolerance in about 50 minutes. A 6x burn rate means you have a few hours. Both warrant action — just different kinds of action.&lt;/p&gt;

&lt;p&gt;This is the implementation running on my bare-metal k3s cluster, based directly on the multi-window multi-burn-rate approach from the &lt;a href="https://sre.google/workbook/alerting-on-slos/" rel="noopener noreferrer"&gt;Google SRE Workbook&lt;/a&gt;.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Error Budgets, Briefly
&lt;/h2&gt;

&lt;p&gt;If your SLO is 99.9% availability, your monthly error budget is the allowed downtime: 43.8 minutes per month (0.1% of 43,800 minutes).&lt;/p&gt;

&lt;p&gt;The core insight: &lt;strong&gt;not all errors are the same urgency&lt;/strong&gt;. A service that's been returning errors at 30x the normal rate for the past two hours will exhaust that 43.8-minute budget in ~50 minutes — that's a page. A service burning at 6x for the past six hours has 4 hours left — that's a ticket, handled during the shift.&lt;/p&gt;

&lt;p&gt;Threshold alerting conflates these. Burn-rate alerting separates them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SLI: HTTP Probe Success Rate
&lt;/h2&gt;

&lt;p&gt;Everything is built on a single Service Level Indicator: the fraction of successful HTTP probes from the Prometheus &lt;a href="https://github.com/prometheus/blackbox_exporter" rel="noopener noreferrer"&gt;blackbox exporter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The blackbox exporter probes each public service endpoint on a fixed interval. &lt;code&gt;probe_success&lt;/code&gt; is 1 for a successful probe and 0 for a failure. The SLI is the average over a time window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/monitoring/slo-rules.yml&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_instance:probe_success:rate5m&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;avg_over_time(probe_success[5m])&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_instance:probe_error:rate5m&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;1 - avg_over_time(probe_success[5m])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;1 - success_rate = error_rate&lt;/code&gt;. At 99.9% SLO, the allowed steady-state error rate is 0.001 (0.1%).&lt;/p&gt;

&lt;h2&gt;
  
  
  Recording Rules: Pre-Computing the Windows
&lt;/h2&gt;

&lt;p&gt;Multi-window alerting needs error rates computed over multiple time windows. Prometheus can do this inline in alert expressions, but pre-computing them as recording rules keeps the alert expressions readable and reduces query load.&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;slo.availability.windows&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;1m&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Short windows (fast-burn detection)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_instance:probe_success:rate1h&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;avg_over_time(probe_success[1h])&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_instance:probe_success:rate2h&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;avg_over_time(probe_success[2h])&lt;/span&gt;

    &lt;span class="c1"&gt;# Medium windows&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_instance:probe_success:rate6h&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;avg_over_time(probe_success[6h])&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_instance:probe_success:rate30m&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;avg_over_time(probe_success[30m])&lt;/span&gt;

    &lt;span class="c1"&gt;# Long windows (slow-burn detection)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;record&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_instance:probe_success:rate24h&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;avg_over_time(probe_success[24h])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These evaluate every minute. The result is a set of pre-computed availability metrics across six time windows — from 30 minutes (most sensitive) to 24 hours (catches slow bleeds).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Alert Rules
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fast Burn: Page Immediately
&lt;/h3&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;SLOAvailabilityFastBurn&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;(1 - job_instance:probe_success:rate2h) &amp;gt; (30 * (1 - 0.999))&lt;/span&gt;
    &lt;span class="s"&gt;and&lt;/span&gt;
    &lt;span class="s"&gt;(1 - job_instance:probe_success:rate1h) &amp;gt; (30 * (1 - 0.999))&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;2m&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;critical&lt;/span&gt;
    &lt;span class="na"&gt;slo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;availability&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;SLO&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;fast&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;burn:&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="na"&gt;description&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;{{ $labels.instance }} error rate is burning through the monthly error budget&lt;/span&gt;
      &lt;span class="s"&gt;at ≥30x the allowed rate. At this pace the 99.9% budget is exhausted in ~50min.&lt;/span&gt;
      &lt;span class="s"&gt;Current 2h error rate: {{ printf "%.2f" $value }}%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The math:&lt;/strong&gt; A 99.9% SLO means 0.1% of requests can fail. The threshold for 30x burn is &lt;code&gt;30 × 0.001 = 0.03&lt;/code&gt; — a 3% error rate. If both the 2-hour window and the 1-hour window exceed 3%, this fires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why two windows?&lt;/strong&gt; The short window (1h) catches fast-developing incidents. The long window (2h) provides confirmation — it prevents a single spike from paging. Both must exceed the threshold simultaneously. This dual-window check is the key difference from naive threshold alerting: a two-minute blip won't page you, but a sustained fast burn will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Burn-rate math at 30x:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monthly budget: 43.8 minutes&lt;/li&gt;
&lt;li&gt;At 30x burn: 43.8 ÷ 30 = 1.46 minutes consumed per minute&lt;/li&gt;
&lt;li&gt;Budget exhausted in: 43.8 ÷ (30 - 1) ≈ 51 minutes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;51 minutes to act. Page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Slow Burn: Create a Ticket
&lt;/h3&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;SLOAvailabilitySlowBurn&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;(1 - job_instance:probe_success:rate6h) &amp;gt; (6 * (1 - 0.999))&lt;/span&gt;
    &lt;span class="s"&gt;and&lt;/span&gt;
    &lt;span class="s"&gt;(1 - job_instance:probe_success:rate30m) &amp;gt; (6 * (1 - 0.999))&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;15m&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;slo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;availability&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;SLO&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;slow&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;burn:&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="na"&gt;description&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;{{ $labels.instance }} error rate is burning through the monthly error budget&lt;/span&gt;
      &lt;span class="s"&gt;at ≥6x the allowed rate. At this pace the 99.9% budget is exhausted in ~4h.&lt;/span&gt;
      &lt;span class="s"&gt;Current 6h error rate: {{ printf "%.2f" $value }}%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The math:&lt;/strong&gt; &lt;code&gt;6 × 0.001 = 0.006&lt;/code&gt; — a 0.6% error rate. Budget exhaustion at 6x burn: 43.8 ÷ (6 - 1) ≈ 8.8 hours. The &lt;code&gt;for: 15m&lt;/code&gt; means it must sustain this rate for 15 minutes before firing, which filters transient dips.&lt;/p&gt;

&lt;p&gt;6h (long) + 30m (short) windows. A slow degradation is visible over 6 hours; the 30m short window prevents false positives from stale data.&lt;/p&gt;

&lt;p&gt;Severity: &lt;code&gt;warning&lt;/code&gt;. This goes to a Slack channel, not a pager. Fix it during the shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing Against Threshold Alerting
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Threshold alert (&lt;code&gt;&amp;lt; 99%&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Burn-rate alert&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Service down for 2 minutes&lt;/td&gt;
&lt;td&gt;✅ Fires&lt;/td&gt;
&lt;td&gt;✅ Fires (fast burn)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service at 95% for 48h&lt;/td&gt;
&lt;td&gt;❌ Fires then resolves&lt;/td&gt;
&lt;td&gt;✅ Fires slow burn, escalates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3% error rate for 1h&lt;/td&gt;
&lt;td&gt;❌ May not fire&lt;/td&gt;
&lt;td&gt;✅ Fast burn fires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5% error rate for 6h&lt;/td&gt;
&lt;td&gt;❌ Never fires&lt;/td&gt;
&lt;td&gt;✅ Slow burn fires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single 10-second blip&lt;/td&gt;
&lt;td&gt;✅ Fires (false positive)&lt;/td&gt;
&lt;td&gt;❌ Below &lt;code&gt;for&lt;/code&gt; threshold&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern: burn-rate alerting catches slow degradations that threshold alerting misses, and it filters the transient blips that threshold alerting over-alerts on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying as a PrometheusRule
&lt;/h2&gt;

&lt;p&gt;The rules deploy as a &lt;code&gt;PrometheusRule&lt;/code&gt; CRD, picked up automatically by the Prometheus Operator:&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;PrometheusRule&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;homelab-slo-alerts&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;monitoring&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;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-prometheus&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alert-rules&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;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;slo.burn-rate.page&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;SLOAvailabilityFastBurn&lt;/span&gt;
          &lt;span class="c1"&gt;# ... (see above)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;prometheus: kube-prometheus&lt;/code&gt; label tells the Prometheus Operator to load this rule. &lt;code&gt;kubectl get prometheusrule -n monitoring&lt;/code&gt; should show it; &lt;code&gt;kubectl get --raw /api/v1/namespaces/monitoring/pods/prometheus-kube-prometheus-prometheus-0/proxy/api/v1/rules&lt;/code&gt; lets you query the loaded rules directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Error Budget Dashboard Shows
&lt;/h2&gt;

&lt;p&gt;The complementary Grafana dashboard (&lt;code&gt;slo-dashboard.yml&lt;/code&gt;) renders three panels:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Availability over time&lt;/strong&gt; — &lt;code&gt;job_instance:probe_success:rate5m&lt;/code&gt; across all probed services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error budget remaining&lt;/strong&gt; — &lt;code&gt;1 - (sum(rate(probe_success[30d])) / count(probe_success))&lt;/code&gt; relative to the 0.1% budget&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burn rate&lt;/strong&gt; — current consumption rate, coloured by severity tier&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The budget panel is the most useful. When it's dropping steeply, something is consuming more than the flat weekly allocation. That's a signal even before an alert fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;This implementation measures &lt;strong&gt;external availability&lt;/strong&gt; only — HTTP probes from inside the cluster. It won't catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Increased latency that doesn't fail probes (need histogram SLIs for that)&lt;/li&gt;
&lt;li&gt;Internal service-to-service degradation (need distributed tracing or internal probes)&lt;/li&gt;
&lt;li&gt;Correctness issues — a 200 OK with wrong data doesn't fail a probe&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most homelab services — Nextcloud, Authelia, Jellyfin, Gitea — availability is the right SLI. For a production API, you'd want to add latency SLOs (P99 &amp;lt; 500ms) using histogram recording rules.&lt;/p&gt;




&lt;p&gt;The same pattern applies directly to enterprise environments. If you're running Azure Load Balancer health probes or Application Gateway, the SLI is the same: probe success rate. The recording rules and alert thresholds are identical. The only difference is where the metrics come from.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>monitoring</category>
      <category>homelab</category>
    </item>
    <item>
      <title>I Hardened Pod securityContext and Broke 9 Containers in Production</title>
      <dc:creator>david</dc:creator>
      <pubDate>Wed, 24 Jun 2026 19:03:35 +0000</pubDate>
      <link>https://dev.to/dwoitzik/i-hardened-pod-securitycontext-and-broke-9-containers-in-production-910</link>
      <guid>https://dev.to/dwoitzik/i-hardened-pod-securitycontext-and-broke-9-containers-in-production-910</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/kubernetes-securitycontext-hardening-broke-9-containers/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;kubeconform&lt;/code&gt; passed. &lt;code&gt;kubectl --dry-run&lt;/code&gt; passed. The PR looked exactly like what every Kubernetes security checklist tells you to do: &lt;code&gt;capabilities.drop: [ALL]&lt;/code&gt;, &lt;code&gt;runAsNonRoot: true&lt;/code&gt;, &lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt; across every container that was missing a &lt;code&gt;securityContext&lt;/code&gt;. Schema-valid, reviewed, merged.&lt;/p&gt;

&lt;p&gt;Within minutes — because this cluster runs ArgoCD with &lt;code&gt;selfHeal: true&lt;/code&gt;, where merge is deploy — nine containers were down. Two of them were Postgres, backing Paperless and Nextcloud. That's not a degraded non-critical service; that's an outage.&lt;/p&gt;

&lt;p&gt;This is the failure analysis, the two wrong assumptions that caused it, the trap that bit during recovery, and the lesson for the next time anyone — including future me — is tempted to do a blanket &lt;code&gt;securityContext&lt;/code&gt; pass across a manifest tree.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The Two Wrong Assumptions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Assumption 1: &lt;code&gt;capabilities.drop: [ALL]&lt;/code&gt; is always safe if the container doesn't need special privileges at runtime.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Wrong. It's not about what the &lt;em&gt;final running process&lt;/em&gt; needs — it's about what the &lt;em&gt;entrypoint script&lt;/em&gt; needs before it execs into that process. A huge number of container images follow the same pattern: start as root, &lt;code&gt;chown&lt;/code&gt;/&lt;code&gt;chmod&lt;/code&gt; the data directory so it's owned by an unprivileged user, then drop privileges via &lt;code&gt;su-exec&lt;/code&gt; or &lt;code&gt;setpriv&lt;/code&gt; before launching the actual application. That privilege-drop step itself requires &lt;code&gt;CAP_CHOWN&lt;/code&gt;, &lt;code&gt;CAP_SETUID&lt;/code&gt;, and &lt;code&gt;CAP_SETGID&lt;/code&gt; — capabilities that &lt;code&gt;drop: [ALL]&lt;/code&gt; removes before the entrypoint ever runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What looked like the safe, recommended hardening:&lt;/span&gt;
&lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;drop&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;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This broke gitea, authelia, headscale, mealie, and both Postgres instances (paperless and nextcloud) — every one of them runs this exact root-then-drop-privileges pattern in its entrypoint. It also broke the Paperless and Nextcloud Redis instances — but, tellingly, &lt;strong&gt;not&lt;/strong&gt; the Authelia Redis instance, because that one has an explicit &lt;code&gt;command: redis-server ...&lt;/code&gt; override that bypasses the image's normal entrypoint script entirely. Same image, same &lt;code&gt;securityContext&lt;/code&gt;, different outcome — because the actual code path that runs is different.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assumption 2: &lt;code&gt;runAsNonRoot: true&lt;/code&gt; is safe to set on any container, since "obviously" you want it not running as root.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Wrong in the opposite direction. &lt;code&gt;runAsNonRoot: true&lt;/code&gt; doesn't change anything about how the container runs — it's an &lt;strong&gt;admission-time check&lt;/strong&gt; that fails outright if the image's actual default user is root and nothing in the pod spec overrides it. &lt;code&gt;vault-unseal&lt;/code&gt; (hashicorp/vault), the Nextcloud and Paperless Redis instances, and &lt;code&gt;cloudflare-ddns&lt;/code&gt; (curlimages/curl) all default to root. These containers didn't crash-loop — they never started at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: container has runAsNonRoot and image will run as root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a &lt;code&gt;CreateContainerConfigError&lt;/code&gt;, a clean failure with a clear message — which made it one of the easier categories to diagnose. The crash-looping containers from Assumption 1 were the harder half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching It: Why "Application: Synced/Healthy" Lied
&lt;/h2&gt;

&lt;p&gt;The first instinct when something looks wrong is to check ArgoCD. &lt;code&gt;kubectl get application -n argocd&lt;/code&gt; showed &lt;code&gt;Synced&lt;/code&gt; and &lt;code&gt;Healthy&lt;/code&gt;. That was stale — ArgoCD's poll interval meant the Application object hadn't refreshed its view of the cluster yet, even though the new pods were already failing underneath 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;# Don't trust Application status alone during an active incident&lt;/span&gt;
argocd app get &amp;lt;name&amp;gt; &lt;span class="nt"&gt;--hard-refresh&lt;/span&gt;
&lt;span class="c"&gt;# or:&lt;/span&gt;
kubectl annotate application &amp;lt;name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="se"&gt;\&lt;/span&gt;
  argocd.argoproj.io/refresh&lt;span class="o"&gt;=&lt;/span&gt;hard &lt;span class="nt"&gt;--overwrite&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only thing that actually told the truth was looking directly at pod status and the pod's own &lt;code&gt;creationTimestamp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nt"&gt;-o&lt;/span&gt; wide
kubectl get pod &amp;lt;name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; apps &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;'{.metadata.creationTimestamp}{"\n"}{.status.containerStatuses[0].restartCount}'&lt;/span&gt;
kubectl logs &amp;lt;name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nt"&gt;--previous&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A pod with N restarts and a recent restart count "looking survivable" is not proof of health.&lt;/strong&gt; Two failures in this incident — paperless-ngx and uptime-kuma — surfaced only on a slower ReplicaSet rollout and weren't caught in the first sweep immediately after merge. They were found ~30 minutes later during an extended verification pass, specifically because someone went back and checked for a &lt;em&gt;clean&lt;/em&gt; &lt;code&gt;creationTimestamp&lt;/code&gt; with &lt;em&gt;zero&lt;/em&gt; restarts since — not just "fewer restarts than expected." The bar for "this is actually fixed" has to be zero restarts on the current generation, not a restart count that happens to look low.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Logs Told the Real Story Every Time
&lt;/h2&gt;

&lt;p&gt;Once you're looking at the right pod, &lt;code&gt;kubectl logs&lt;/code&gt; on the crashing container is unambiguous:&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;chown&lt;/span&gt;: /config: Operation not permitted
su-exec: setgroups&lt;span class="o"&gt;(&lt;/span&gt;0&lt;span class="o"&gt;)&lt;/span&gt;: Operation not permitted
setpriv: setresuid failed: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different error message formats, same root cause: the entrypoint tried to drop privileges and couldn't, because the capability that does that had been dropped first. This is the single most useful debugging fact from the whole incident — if you see &lt;em&gt;any&lt;/em&gt; of these three error patterns after a &lt;code&gt;securityContext&lt;/code&gt; change, the fix is "give the capability back," not "investigate the application."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Recovery Trap: selfHeal Undoes Manual Fixes
&lt;/h2&gt;

&lt;p&gt;To restore service faster than waiting on a PR review cycle, the instinct during an active outage is to patch the live cluster directly:&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 deployment authelia &lt;span class="nt"&gt;-n&lt;/span&gt; apps &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'[{"op": "remove", "path": "/spec/template/spec/containers/0/securityContext/capabilities"}]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works — for about as long as it takes ArgoCD's next reconciliation loop to notice the drift. With &lt;code&gt;selfHeal: true&lt;/code&gt;, ArgoCD's entire job is to make the live cluster match Git. A manual &lt;code&gt;kubectl patch&lt;/code&gt; that diverges from the committed manifest &lt;em&gt;is&lt;/em&gt; drift, by definition, and gets silently reverted back to the still-broken state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With selfHeal enabled, Git is the only place a fix can actually stick.&lt;/strong&gt; During this incident, the real fix had to land as a committed, merged change before it survived — the manual patch bought a few minutes at best, and gave a false sense of "it's fixed" that evaporated on the next sync cycle. For an incident under selfHeal, the fastest real path to recovery is a fast-tracked PR, not a live patch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix, Applied Selectively
&lt;/h2&gt;

&lt;p&gt;Five follow-up PRs, each fixing a specific verified failure mode as it was confirmed live — not a blanket re-revert of everything:&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;# Reverted only what was proven to break — capabilities stay dropped&lt;/span&gt;
&lt;span class="c1"&gt;# wherever the image's entrypoint doesn't need them:&lt;/span&gt;
&lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="c1"&gt;# capabilities.drop: ["ALL"]  ← removed for this specific image,&lt;/span&gt;
  &lt;span class="c1"&gt;# see inline comment for the verified failure mode&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="c1"&gt;# kubernetes/apps/authelia/authelia.yml&lt;/span&gt;
&lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="c1"&gt;# SEC-012: image entrypoint runs as root and needs CAP_CHOWN/CAP_SETGID/&lt;/span&gt;
  &lt;span class="c1"&gt;# CAP_SETUID to chown /config and su-exec into its runtime user —&lt;/span&gt;
  &lt;span class="c1"&gt;# confirmed live, dropping all capabilities crash-loops it&lt;/span&gt;
  &lt;span class="c1"&gt;# ("su-exec: setgroups(0): Operation not permitted").&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each reverted file got an inline comment recording the &lt;em&gt;specific verified failure mode&lt;/em&gt; — not a vague "this broke things." The next person (or future me) who's tempted to re-attempt a blanket capability drop across this manifest tree has the actual evidence sitting right there, rather than rediscovering it the same way.&lt;/p&gt;

&lt;p&gt;Final state: &lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt; everywhere — that one's genuinely always safe, it has no entrypoint-behavior dependency. &lt;code&gt;capabilities.drop: [ALL]&lt;/code&gt; kept only where verified safe (cloudflared, gitea after its own fix, and several others). &lt;code&gt;runAsNonRoot: true&lt;/code&gt; kept only where the image's actual default user is verifiably non-root. Net result: Trivy's configuration-misconfiguration finding count went from 215 to 171 — real progress, just not the full sweep the first PR claimed.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;kubeconform&lt;/code&gt; and &lt;code&gt;kubectl --dry-run&lt;/code&gt; validate that a manifest is &lt;strong&gt;schema-valid&lt;/strong&gt;. They say nothing about whether the container's actual entrypoint will survive the constraints you just imposed on it. Those are two completely different questions, and passing the first one tells you nothing about the second.&lt;/p&gt;

&lt;p&gt;For any image you don't control, the actual behavior of its entrypoint — does it run as root and drop privileges, does it default to a non-root user, does anything in its startup sequence need a specific capability — has to be verified &lt;strong&gt;live&lt;/strong&gt;, one image at a time, before a blanket security hardening change goes anywhere near a cluster with auto-sync enabled. The pattern to specifically watch for: anything that does &lt;code&gt;chown&lt;/code&gt;/&lt;code&gt;chmod&lt;/code&gt; on a data directory before launching the real process almost certainly needs &lt;code&gt;CAP_CHOWN&lt;/code&gt; and friends, regardless of how harmless the final running process looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Addendum: Handing the Capability Back Wasn't the Best Fix Available
&lt;/h2&gt;

&lt;p&gt;A reader pointed out, correctly, that "give the capability back" gets a service out of an outage but quietly gives up the thing the hardening pass was for in the first place. There's a fix that keeps both.&lt;/p&gt;

&lt;p&gt;For the root-then-drop-privileges images, the &lt;code&gt;chown&lt;/code&gt; in the entrypoint exists to fix ownership of a mounted volume so the unprivileged runtime user can write to it. &lt;code&gt;fsGroup&lt;/code&gt; in the pod's &lt;code&gt;securityContext&lt;/code&gt; does exactly that job at mount time — the kubelet recursively chowns the volume to the given GID before the container ever starts. If the volume is already owned correctly when the entrypoint runs, the entrypoint's own &lt;code&gt;chown&lt;/code&gt; becomes a no-op it doesn't need permission for, and &lt;code&gt;capabilities.drop: ["ALL"]&lt;/code&gt; can stay in place on the main container:&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;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fsGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;  &lt;span class="c1"&gt;# kubelet chowns the mounted volume(s) to this GID before container start&lt;/span&gt;
&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authelia&lt;/span&gt;
    &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;drop&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;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# no longer needs CAP_CHOWN/SETUID/SETGID —&lt;/span&gt;
                        &lt;span class="c1"&gt;# fsGroup already made the volume writable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the handful of images that &lt;code&gt;chown&lt;/code&gt; unconditionally regardless of existing ownership — no flag to skip it — the same idea applies one level up: move the &lt;code&gt;chown&lt;/code&gt; into an &lt;code&gt;initContainer&lt;/code&gt; that runs as root with only &lt;code&gt;CAP_CHOWN&lt;/code&gt;, and lock the long-lived app container down fully. The privileged moment still happens, but it's a few seconds during pod startup instead of a capability held by the process serving traffic for the pod's entire lifetime:&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;initContainers&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;fix-ownership&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;busybox&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chown"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-R"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1000:1000"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/config"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;runAsUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;add&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;CHOWN"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;drop&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;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;volumeMounts&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;config&lt;/span&gt;
        &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/config&lt;/span&gt;
&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;authelia&lt;/span&gt;
    &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;drop&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;ALL"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One trap on the &lt;code&gt;fsGroup&lt;/code&gt; route for volumes the size of Nextcloud's or Paperless's:&lt;/strong&gt; the default behavior recursively &lt;code&gt;chown&lt;/code&gt;s the entire volume on &lt;em&gt;every&lt;/em&gt; pod start, not just the first. On a large PV that's a full-tree walk before the app can even begin starting — startup time falls off a cliff as the dataset grows. Set &lt;code&gt;fsGroupChangePolicy: OnRootMismatch&lt;/code&gt; so Kubernetes only does the recursive chown when the volume's root ownership doesn't already match, and skips the walk on every subsequent restart once it's correct:&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;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fsGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt;
  &lt;span class="na"&gt;fsGroupChangePolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnRootMismatch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the better fix for every one of the five images this incident's follow-up PRs reverted — the follow-up PRs restored capabilities to get the fastest possible recovery during an active incident, which was the right call in the moment, but &lt;code&gt;fsGroup&lt;/code&gt; (plus the initContainer pattern for the stubborn unconditional-chown images) is the version worth landing as the actual final state, since it gets the hardening back without reopening the exposure it was meant to close.&lt;/p&gt;




&lt;p&gt;The same blast-radius problem exists in Azure — a blanket Pod Security Standard or Azure Policy applied across an AKS cluster's namespaces can break exactly this class of container for exactly this reason, just with &lt;code&gt;kubectl apply&lt;/code&gt; replaced by a policy assignment that enforces on the next pod restart instead of immediately. Verify per-workload before enforcing cluster-wide, not after.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Hardening Unattended Raspberry Pi Edge Nodes: Watchdog, fail2ban, nftables, and the Mistakes That Take Down DNS</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 22 Jun 2026 12:14:01 +0000</pubDate>
      <link>https://dev.to/dwoitzik/hardening-unattended-raspberry-pi-edge-nodes-watchdog-fail2ban-nftables-and-the-mistakes-that-4gk3</link>
      <guid>https://dev.to/dwoitzik/hardening-unattended-raspberry-pi-edge-nodes-watchdog-fail2ban-nftables-and-the-mistakes-that-4gk3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/raspberry-pi-edge-hardening-watchdog-fail2ban-nftables/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two Raspberry Pi 4Bs run AdGuard Home and Unbound for an entire home network, in an active/passive pair via Keepalived. They're physical hardware sitting on a shelf, not VMs or LXCs — no Proxmox snapshot, no PBS backup, no &lt;code&gt;terraform destroy &amp;amp;&amp;amp; apply&lt;/code&gt; to recover from a bad state. If one hangs hard at 2am, nobody notices until someone's phone can't resolve a hostname.&lt;/p&gt;

&lt;p&gt;This is the hardening pass that closed every gap I found in that setup: a hardware watchdog for total-system-freeze recovery, fail2ban for the one SSH-exposed surface, an nftables host firewall that's careful not to fight with Docker's own iptables rules, log size caps to stop slow SD-card death, and a DNS health check that works even on the day the rest of the monitoring stack is offline — which, as it turned out, was exactly the day it mattered.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why "It's Just DNS" Needs More Hardening, Not Less
&lt;/h2&gt;

&lt;p&gt;The instinct with a small, single-purpose device is to leave it alone — fewer moving parts, fewer ways to break it. That's backwards for a device with no operator watching it and no automated recovery path. A k3s pod that crashes gets rescheduled in seconds. A Raspberry Pi that hard-hangs stays hung until a human walks over and pulls the power.&lt;/p&gt;

&lt;p&gt;Everything below is about closing that gap: detecting failure independently, recovering from total freezes without intervention, and not introducing a new failure mode in the process of doing any of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware Watchdog: Recovering From a Hang Software Can't See
&lt;/h2&gt;

&lt;p&gt;A crashed container gets restarted by Docker. A kernel deadlock — the whole system stops responding, nothing crashes, nothing logs anything — doesn't. Nothing is left running to notice the problem or act on it.&lt;/p&gt;

&lt;p&gt;The Broadcom SoC in a Raspberry Pi has a hardware watchdog timer: a circuit that resets the board if it isn't periodically "petted." As long as something pets it, the system is presumed alive. If petting stops — because the kernel is deadlocked and nothing can run — the watchdog fires and power-cycles the board.&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="c"&gt;# /boot/firmware/config.txt
&lt;/span&gt;&lt;span class="py"&gt;dtparam&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;watchdog=on&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system.conf
&lt;/span&gt;&lt;span class="py"&gt;RuntimeWatchdogSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;15s&lt;/span&gt;
&lt;span class="py"&gt;RebootWatchdogSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10min&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RuntimeWatchdogSec=15s&lt;/code&gt; means systemd pets the hardware watchdog every 15 seconds while the system is healthy. If systemd itself stops running (the actual deadlock case this exists for), the pets stop, and the watchdog circuit force-resets the board. &lt;code&gt;RebootWatchdogSec=10min&lt;/code&gt; is a second, independent safety net — if a &lt;em&gt;reboot&lt;/em&gt; itself hangs (stuck somewhere in shutdown), the watchdog fires again after 10 minutes rather than leaving the board hung mid-reboot indefinitely.&lt;/p&gt;

&lt;p&gt;This requires a reboot to take effect — the &lt;code&gt;config.txt&lt;/code&gt; change only applies at boot. I gated the actual reboot behind an explicit flag (&lt;code&gt;rpi_optimize_reboot&lt;/code&gt;, default &lt;code&gt;false&lt;/code&gt;) rather than auto-rebooting a DNS server as a side effect of an Ansible run.&lt;/p&gt;

&lt;h2&gt;
  
  
  fail2ban: The One Exposed Surface
&lt;/h2&gt;

&lt;p&gt;These Pis are reachable from the entire server VLAN, and via the Keepalived VIP, present a single consistent address that's an obvious target for anything scanning the network. The only network-facing attack surface that matters here is SSH.&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="c"&gt;# /etc/fail2ban/jail.d/sshd.local
&lt;/span&gt;&lt;span class="nn"&gt;[sshd]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;ssh&lt;/span&gt;
&lt;span class="py"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;sshd&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five failed attempts within ten minutes bans the source IP for an hour. fail2ban only watches &lt;code&gt;sshd&lt;/code&gt; auth logs — it has zero interaction with the DNS path (AdGuard, Unbound, Docker). That isolation matters: a misconfigured fail2ban jail watching the wrong log file, or banning based on the wrong filter, is a self-inflicted outage risk on a box where outages are expensive. Scoping it to exactly one well-understood log source keeps the blast radius of a fail2ban misconfiguration limited to "SSH access," never to DNS itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nftables Trap: Don't Touch /etc/nftables.conf
&lt;/h2&gt;

&lt;p&gt;This is the part that could have caused the exact outage the rest of this hardening pass exists to prevent.&lt;/p&gt;

&lt;p&gt;The obvious way to add a host firewall on Debian is to edit &lt;code&gt;/etc/nftables.conf&lt;/code&gt; and enable &lt;code&gt;nftables.service&lt;/code&gt;. The problem: that file conventionally starts with &lt;code&gt;flush ruleset&lt;/code&gt; — and Docker manages its own NAT and FORWARD chains via &lt;code&gt;iptables-nft&lt;/code&gt; (the nftables-backed iptables compatibility layer). Enabling the stock &lt;code&gt;nftables.service&lt;/code&gt; would flush ruleset on every boot, wiping out Docker's NAT rules along with it, and silently break every published container port. On a box running AdGuard with &lt;code&gt;network_mode: host&lt;/code&gt; specifically so it can bind port 53 directly — but also running other containers in bridge mode with published ports — that's not a hypothetical, it's the actual topology.&lt;/p&gt;

&lt;p&gt;The fix: don't touch &lt;code&gt;/etc/nftables.conf&lt;/code&gt; or the stock service at all. Use a separate ruleset file and a separate, custom systemd service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/nftables-hostfw.conf
table inet hostfw {
  chain input {
    type filter hook input priority filter; policy drop;
    iif "lo" accept
    ct state established,related accept
    ip protocol icmp accept
    meta l4proto ipv6-icmp accept
    tcp dport 22 accept
    tcp dport 53 accept
    udp dport 53 accept
    tcp dport 3001 accept
    tcp dport { 80, 443 } accept
    udp dport 41641 accept
    ip protocol vrrp accept
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/hostfw.service
&lt;/span&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;Host firewall (inet hostfw table, additive — does not touch Docker's tables)&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 docker.service&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;docker.service&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;RemainAfterExit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&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/sbin/nft -f /etc/nftables-hostfw.conf&lt;/span&gt;
&lt;span class="py"&gt;ExecStop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/sbin/nft delete table inet hostfw&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A named table (&lt;code&gt;inet hostfw&lt;/code&gt;) in its own namespace, with &lt;code&gt;policy drop&lt;/code&gt; only on &lt;em&gt;that&lt;/em&gt; table's input chain — it's additive to whatever else nftables is doing, not a replacement of the ruleset. &lt;code&gt;After=docker.service&lt;/code&gt; and &lt;code&gt;Wants=docker.service&lt;/code&gt; ensure ordering: this table gets applied after Docker has already set up its own rules, so there's no race where this firewall's &lt;code&gt;policy drop&lt;/code&gt; briefly applies before Docker's accept rules for its own traffic exist.&lt;/p&gt;

&lt;p&gt;What this firewall &lt;strong&gt;covers&lt;/strong&gt;: SSH (22), DNS (53 — AdGuard runs &lt;code&gt;network_mode: host&lt;/code&gt;, so this is genuinely host-stack traffic, not Docker-NAT'd), AdGuard's web UI (3001), the HAProxy VIP (80/443), Tailscale (41641/udp), Keepalived VRRP.&lt;/p&gt;

&lt;p&gt;What it &lt;strong&gt;deliberately doesn't cover&lt;/strong&gt;: bridge-mode containers like Unbound (5335) and node_exporter (9100). Docker DNATs traffic to these &lt;em&gt;before&lt;/em&gt; it ever reaches the host's INPUT chain — this firewall's table never sees that traffic, confirmed by live testing, not just by reading documentation about how Docker's iptables integration works. Restricting bridge-mode container ports would require rules in Docker's own &lt;code&gt;DOCKER-USER&lt;/code&gt; chain, with careful IPv4/IPv6 handling to avoid breaking container egress. I deferred this: MikroTik already segments these Pis from the wider internet at the network layer, and the mistake-risk of getting &lt;code&gt;DOCKER-USER&lt;/code&gt; chain rules wrong on a live DNS server outweighed the marginal security benefit of restricting traffic that's already internal-only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validation that actually validates the deployment path&lt;/strong&gt;, not just the live change: live-tested on the replica Pi first, with a &lt;code&gt;systemd-run&lt;/code&gt; safety-rollback timer staged before every individual change (the same dead-man's-switch pattern as the MikroTik cleanup). Then re-tested via the actual Ansible run — a separate code path from the manual live test, since a playbook can have a templating bug that a manual &lt;code&gt;nft -f&lt;/code&gt; test wouldn't catch. Then validated with an actual reboot, to confirm the systemd service correctly &lt;em&gt;reapplies&lt;/em&gt; the ruleset on boot, rather than only working because it happened to still be live-applied from the manual test. Only after the replica was fully green did the same sequence run against the primary DNS node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stopping Slow SD-Card Death
&lt;/h2&gt;

&lt;p&gt;Docker's default &lt;code&gt;json-file&lt;/code&gt; log driver has no size limit. On a box with a real disk, that's eventually a problem; on a Pi with an SD card as its only storage, it's a slow-motion outage that looks like nothing is wrong until the card is full and everything stops:&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;/etc/docker/daemon.json&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;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"log-opts"&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;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&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;Existing container logs were already at 17MB and 2.7MB by the time I checked — not catastrophic yet, but on a trajectory toward "disk full" with zero warning beforehand, months out. This setting only caps logs for containers &lt;em&gt;created or recreated after&lt;/em&gt; the daemon restart — it doesn't retroactively truncate what's already there. Existing oversized logs needed a manual one-time cleanup; the daemon-wide default just stops the problem from recurring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory Limits: Catching a Leak Before It Takes the Whole Pi Down
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose, per service&lt;/span&gt;
&lt;span class="na"&gt;adguardhome&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512m&lt;/span&gt;
&lt;span class="na"&gt;unbound&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256m&lt;/span&gt;
&lt;span class="na"&gt;promtail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256m&lt;/span&gt;
&lt;span class="na"&gt;node_exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;128m&lt;/span&gt;
&lt;span class="na"&gt;autoheal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;64m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are generous numbers, chosen from actual observed usage with real headroom — the goal isn't to constrain normal operation, it's to make sure a genuine memory leak or runaway process in one container gets killed by Docker's OOM handling for &lt;em&gt;that container&lt;/em&gt; before it starves every other process on the Pi, including the DNS resolver everything depends on. Tested incrementally on the replica first, verified via &lt;code&gt;docker inspect&lt;/code&gt; that limits were actually enforced, confirmed all containers came back &lt;code&gt;Up&lt;/code&gt; after restart, with DNS unaffected throughout — the kind of change where "looks fine" isn't sufficient confirmation on a box this important.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Config Backup: The Gap Nobody Noticed
&lt;/h2&gt;

&lt;p&gt;These Pis are physical hardware — Proxmox Backup Server and Velero only cover VMs and LXCs, so neither one was ever backing these up. The gap had existed since the Pis were first deployed, just never surfaced, because nothing had ever required restoring from a backup yet.&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;# /usr/local/bin/backup-rpi-configs.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;DEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/backups
&lt;span class="nv"&gt;STAMP&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; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;tar &lt;/span&gt;czf &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/configs-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tar.gz"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-C&lt;/span&gt; / opt/adguardhome/conf opt/unbound 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
ls&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DEST&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/configs-&lt;span class="k"&gt;*&lt;/span&gt;.tar.gz 2&amp;gt;/dev/null | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +15 | xargs &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Daily, via a systemd timer with randomized delay (to avoid both Pis hitting disk I/O at the exact same instant), keeping the 14 most recent snapshots. Deliberately &lt;strong&gt;local-only&lt;/strong&gt;, with no NFS or git dependency — the NFS server runs as an LXC on the Proxmox host, and depending on the thing you're backing up &lt;em&gt;away from&lt;/em&gt; failing defeats the purpose. AdGuard's config also contains a bcrypt password hash; pushing that into git history, even encrypted-at-rest on a private remote, is an unnecessary exposure for a snapshot whose only job is "let me recover the last known-good config after an accidental change."&lt;/p&gt;

&lt;h2&gt;
  
  
  Alerting That Survives the Main Alerting Stack Being Down
&lt;/h2&gt;

&lt;p&gt;This is the piece that mattered in practice, not just in theory. The homelab's primary alerting path (Prometheus → Alertmanager → Discord) runs on the k3s cluster, which runs on the Proxmox host. On the day I built this, the Proxmox host itself was down for hardware repair — which meant the entire alerting pipeline was also down, on exactly the day DNS health mattered most, since DNS was now also the only thing left running unsupervised.&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;# Independent DNS health check — ZERO dependency on k3s/Prometheus/Alertmanager&lt;/span&gt;
&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nv"&gt;STATE_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/lib/dns-healthcheck.state"&lt;/span&gt;
&lt;span class="nv"&gt;HOSTNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

check_dns&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  dig +short +timeout&lt;span class="o"&gt;=&lt;/span&gt;3 google.com @127.0.0.1 &lt;span class="nt"&gt;-p&lt;/span&gt; 53 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  dig +short +timeout&lt;span class="o"&gt;=&lt;/span&gt;3 google.com @127.0.0.1 &lt;span class="nt"&gt;-p&lt;/span&gt; 5335 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;PREV_STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"unknown"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;PREV_STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;check_dns&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nv"&gt;CURRENT_STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"healthy"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else &lt;/span&gt;&lt;span class="nv"&gt;CURRENT_STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"unhealthy"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi

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;$PREV_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
  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;"unhealthy"&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="nv"&gt;MESSAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"🔴 **&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOSTNAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;**: DNS resolution failing. This alert is independent of the main monitoring stack."&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nv"&gt;MESSAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"🟢 **&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOSTNAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;**: DNS resolution recovered."&lt;/span&gt;
  &lt;span class="k"&gt;fi
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MESSAGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&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;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run every two minutes via a systemd timer. Two design choices that matter more than the script's mechanics:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It tests both layers independently&lt;/strong&gt; — AdGuard on port 53 &lt;em&gt;and&lt;/em&gt; Unbound directly on port 5335. AdGuard forwards to Unbound; testing only the front door (53) wouldn't distinguish "AdGuard is fine but its upstream resolver died" from "everything's fine." &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; between the two &lt;code&gt;dig&lt;/code&gt; calls means both have to succeed for the overall state to be healthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It only posts on a state change&lt;/strong&gt;, not on every run. A naive healthcheck that posts every two minutes regardless of state either spams a channel into being muted (defeating the purpose) or gets its messages ignored after the first few identical ones. Tracking previous state in a file and diffing against it means the alert fires exactly twice per incident: once when it breaks, once when it recovers — and nothing in between.&lt;/p&gt;

&lt;p&gt;The webhook URL reuses the same Discord webhook Alertmanager already posts to — found, while wiring this up, to have been committed in plaintext in the cluster's own monitoring config. Worth its own fix, but explicitly out of scope for this change; noted rather than silently expanded into a second unrelated remediation in the same commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Got Tested, Not Just Written
&lt;/h2&gt;

&lt;p&gt;Every change here got the same validation discipline, because the box matters too much to skip it: replica first, primary only after the replica was fully green; a manual live test &lt;em&gt;and&lt;/em&gt; a separate Ansible-driven test, since they're different code paths; and for anything that should survive a reboot, an actual reboot — not just trusting that a systemd unit file is correct.&lt;/p&gt;




&lt;p&gt;The pattern generalizes past Raspberry Pis: any unattended edge device — a branch-office router, an IoT gateway, a remote sensor node — has the same shape of problem. No operator watching it, no automated platform-level recovery, and a failure mode (hard hang) that ordinary application-level monitoring can't see because the monitoring agent itself is also hung. A hardware watchdog plus an alerting path with zero dependency on the thing being monitored is the minimum bar for "I'll find out if this breaks," regardless of what the device actually does.&lt;/p&gt;



</description>
      <category>homelab</category>
      <category>security</category>
      <category>networking</category>
    </item>
    <item>
      <title>IPv6 NAT66 Behind a FritzBox: The RouterOS 7 Bug That Broke WiFi Clients</title>
      <dc:creator>david</dc:creator>
      <pubDate>Mon, 22 Jun 2026 12:13:49 +0000</pubDate>
      <link>https://dev.to/dwoitzik/ipv6-nat66-behind-a-fritzbox-the-routeros-7-bug-that-broke-wifi-clients-4nha</link>
      <guid>https://dev.to/dwoitzik/ipv6-nat66-behind-a-fritzbox-the-routeros-7-bug-that-broke-wifi-clients-4nha</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/mikrotik-ipv6-nat66-cgn-routeros7/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most homelab IPv6 guides assume you have native IPv6 from your ISP: a delegated /56 prefix, clean RA on the WAN, no NAT. That describes maybe 30% of actual deployments in Germany.&lt;/p&gt;

&lt;p&gt;The other 70% sits behind a FritzBox with DS-Lite or CGN, gets a GUA on the WAN interface via SLAAC, and has no delegated prefix to distribute internally. If you want IPv6 inside your network, you build it yourself.&lt;/p&gt;

&lt;p&gt;This is the setup I run: ULA addressing internally, NAT66 masquerade for outbound, everything Terraform-managed. It worked until RouterOS 7's router advertisement defaults caused every FritzBox WiFi client to route IPv6 through MikroTik — and then get dropped.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The Topology
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet
    │
FritzBox (CGN / DS-Lite)
    │  ether1 (WAN) — gets GUA via SLAAC from FritzBox
MikroTik RB5009
    ├── vlan10-mgmt   fd10::1/64
    ├── vlan20-srv    fd20::1/64
    ├── vlan30-dmz    fd30::1/64
    ├── vlan40-iot    fd40::1/64
    └── vlan100-admin fd64::1/64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The FritzBox provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IPv4 via CGN/DS-Lite (no public IPv4)&lt;/li&gt;
&lt;li&gt;IPv6 GUA prefix via RA on its LAN port — MikroTik's ether1 picks this up via SLAAC&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Internally, I use ULA (&lt;code&gt;fd00::/8&lt;/code&gt;, RFC 4193). ULA is the IPv6 equivalent of RFC1918 private addressing. It's stable — it doesn't change when the ISP rotates the GUA prefix — and it works for all internal communication. The NAT66 rule masquerades ULA sources to the GUA when leaving ether1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: ULA Addresses Per VLAN
&lt;/h2&gt;

&lt;p&gt;Each VLAN gets a /64 from the &lt;code&gt;fd::/8&lt;/code&gt; space. I use the VLAN number as the second octet for readability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/stacks/network/ipv6_network.tf&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ipv6_ula_prefixes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan10-mgmt"&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fd10::/64"&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan20-srv"&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fd20::/64"&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan30-dmz"&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fd30::/64"&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan40-iot"&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fd40::/64"&lt;/span&gt;
    &lt;span class="s2"&gt;"vlan100-admin"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fd64::/64"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_address"&lt;/span&gt; &lt;span class="s2"&gt;"vlan_ula"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ipv6_ula_prefixes&lt;/span&gt;

  &lt;span class="nx"&gt;address&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"::/64"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"::1/64"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="nx"&gt;advertise&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;comment&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ULA gateway for ${each.key}"&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;advertise = true&lt;/code&gt; enables IPv6 ND (Neighbor Discovery) on each interface. Hosts on each VLAN receive a Router Advertisement with the /64 prefix and auto-configure a ULA address via SLAAC. No DHCPv6 needed.&lt;/p&gt;

&lt;p&gt;The router address is &lt;code&gt;::1&lt;/code&gt; in each /64: &lt;code&gt;fd10::1/64&lt;/code&gt;, &lt;code&gt;fd20::1/64&lt;/code&gt;, etc.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Accept RA on ether1
&lt;/h2&gt;

&lt;p&gt;MikroTik defaults to ignoring Router Advertisements when &lt;code&gt;forward = true&lt;/code&gt; (i.e., when acting as a router). You have to explicitly enable RA acceptance on the WAN interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_settings"&lt;/span&gt; &lt;span class="s2"&gt;"global"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;accept_router_advertisements&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"yes"&lt;/span&gt;
  &lt;span class="nx"&gt;forward&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this, ether1 accepts the RA from the FritzBox and configures its GUA via SLAAC. &lt;code&gt;ip6 address print&lt;/code&gt; will show the GUA alongside the manually configured ULA if you have any internal IPv6 config on ether1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: NAT66 Masquerade
&lt;/h2&gt;

&lt;p&gt;The NAT66 rule masquerades outbound IPv6 from ULA sources to the GUA on ether1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_firewall_nat"&lt;/span&gt; &lt;span class="s2"&gt;"nat66_masquerade"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"srcnat"&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"masquerade"&lt;/span&gt;
  &lt;span class="nx"&gt;src_address&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fd00::/8"&lt;/span&gt;
  &lt;span class="nx"&gt;out_interface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ether1"&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NAT66: ULA → WAN GUA (FritzBox upstream)"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;src_address = "fd00::/8"&lt;/code&gt; constraint is critical. Without it, the rule matches ALL IPv6 traffic leaving ether1 — including traffic from FritzBox WiFi clients that happens to transit MikroTik. This is one half of the bug that caused problems (more on that below).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: IPv6 Firewall
&lt;/h2&gt;

&lt;p&gt;The IPv6 firewall mirrors the IPv4 firewall philosophy: default-drop, explicit allows, &lt;code&gt;place_before&lt;/code&gt; for deterministic rule ordering.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# INPUT chain&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"v6_in_00_established"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"accept"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"input"&lt;/span&gt;
  &lt;span class="nx"&gt;connection_state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"established,related,untracked"&lt;/span&gt;
  &lt;span class="nx"&gt;place_before&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;routeros_ipv6_firewall_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v6_in_01_icmpv6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"V6-IN-00: Allow established/related"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"v6_in_01_icmpv6"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"accept"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"input"&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"icmpv6"&lt;/span&gt;
  &lt;span class="nx"&gt;place_before&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;routeros_ipv6_firewall_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v6_input_drop_all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"V6-IN-01: Allow ICMPv6 (NDP, RA, ping6)"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"v6_input_drop_all"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"drop"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"input"&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"V6-IN-DROP: Drop all other IPv6 input"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# FORWARD chain&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"v6_fwd_00_established"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"accept"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;
  &lt;span class="nx"&gt;connection_state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"established,related,untracked"&lt;/span&gt;
  &lt;span class="nx"&gt;place_before&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;routeros_ipv6_firewall_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v6_fwd_01_icmpv6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"V6-FWD-00: Allow established/related"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"v6_fwd_01_icmpv6"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"accept"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"icmpv6"&lt;/span&gt;
  &lt;span class="nx"&gt;place_before&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;routeros_ipv6_firewall_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v6_fwd_02_internal_out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"V6-FWD-01: Allow ICMPv6"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"v6_fwd_02_internal_out"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"accept"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;
  &lt;span class="nx"&gt;src_address&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fd00::/8"&lt;/span&gt;
  &lt;span class="nx"&gt;out_interface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ether1"&lt;/span&gt;
  &lt;span class="nx"&gt;place_before&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;routeros_ipv6_firewall_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;v6_forward_drop_all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"V6-FWD-02: Allow internal ULA to WAN"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"v6_forward_drop_all"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"drop"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"V6-FWD-DROP: Drop all other IPv6 forward"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The forward rule &lt;code&gt;v6_fwd_02_internal_out&lt;/code&gt; only allows ULA sources (&lt;code&gt;fd00::/8&lt;/code&gt;) to exit via ether1. That's intentional — and it's what exposed the RouterOS 7 bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug: RouterOS 7 Sends RA on All Interfaces
&lt;/h2&gt;

&lt;p&gt;After deploying this configuration, FritzBox WiFi clients started losing IPv6 connectivity.&lt;/p&gt;

&lt;p&gt;The symptom: devices on the FritzBox WiFi (SSID, not the MikroTik VLANs) had IPv6 addresses but couldn't reach the internet via IPv6. &lt;code&gt;traceroute6&lt;/code&gt; on an affected device showed the path going through MikroTik — not the FritzBox.&lt;/p&gt;

&lt;p&gt;The cause: &lt;strong&gt;RouterOS 7 enables Router Advertisement on all interfaces by default&lt;/strong&gt;, including &lt;code&gt;ether1&lt;/code&gt; (WAN).&lt;/p&gt;

&lt;p&gt;Here's the sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;MikroTik receives a GUA prefix from FritzBox via RA on ether1&lt;/li&gt;
&lt;li&gt;RouterOS 7 then &lt;em&gt;re-advertises&lt;/em&gt; a Router Advertisement on ether1 — back towards the FritzBox&lt;/li&gt;
&lt;li&gt;The FritzBox sees MikroTik advertising itself as an IPv6 router on the LAN&lt;/li&gt;
&lt;li&gt;FritzBox WiFi clients pick up MikroTik's RA and install it as their default IPv6 gateway&lt;/li&gt;
&lt;li&gt;IPv6 traffic from WiFi clients routes through MikroTik's FORWARD chain&lt;/li&gt;
&lt;li&gt;FORWARD chain only accepts &lt;code&gt;fd00::/8&lt;/code&gt; sources — GUA addresses from WiFi clients don't match&lt;/li&gt;
&lt;li&gt;Traffic dropped. IPv6 broken for all FritzBox WiFi clients.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is to disable RA on ether1. In RouterOS &lt;code&gt;/ip6/nd&lt;/code&gt;, find the ether1 entry and set &lt;code&gt;advertise=no&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem: as of &lt;code&gt;terraform-routeros&lt;/code&gt; provider version 1.99.1 (latest at time of writing), there is no &lt;code&gt;routeros_ipv6_nd&lt;/code&gt; resource to manage this via Terraform. The fix has to be applied manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cisco_ios"&gt;&lt;code&gt;&lt;span class="k"&gt;/ipv6/nd&lt;/span&gt; set [find interface=ether1] advertise=no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is documented in the Terraform configuration as a comment so it doesn't get overwritten by a future &lt;code&gt;terraform apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# RouterOS 7 enables RA advertisement on ALL interfaces by default — including&lt;/span&gt;
&lt;span class="c1"&gt;# ether1 (WAN). Once ether1 gets a GUA via SLAAC, MikroTik starts sending RAs&lt;/span&gt;
&lt;span class="c1"&gt;# on the FritzBox LAN. FritzBox WiFi clients then use MikroTik as their IPv6&lt;/span&gt;
&lt;span class="c1"&gt;# gateway, but the FORWARD chain only allows fd00::/8 sources → GUA clients&lt;/span&gt;
&lt;span class="c1"&gt;# are dropped → IPv6 broken on FritzBox WiFi.&lt;/span&gt;
&lt;span class="c1"&gt;# RA on ether1 is disabled in RouterOS: /ipv6/nd set [find interface=ether1] advertise=no&lt;/span&gt;
&lt;span class="c1"&gt;# routeros_ipv6_nd is not exposed in terraform-routeros/routeros ≤ 1.99.1 (latest as of 2026-06).&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;routeros_ipv6_nd&lt;/code&gt; is added to the provider (tracked upstream), this should be managed as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ipv6_nd"&lt;/span&gt; &lt;span class="s2"&gt;"ether1_no_ra"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ether1"&lt;/span&gt;
  &lt;span class="nx"&gt;advertise&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  ULA vs. GUA: Why Not Just Use the ISP Prefix?
&lt;/h2&gt;

&lt;p&gt;The obvious alternative: use the GUA prefix the FritzBox receives from the ISP, delegate a /64 to each VLAN, and skip NAT66 entirely. IPv6 was designed to eliminate NAT.&lt;/p&gt;

&lt;p&gt;The problem: German ISPs frequently rotate GUA prefixes. A prefix change means every device on every VLAN gets a new address — breaking DNS records, Ansible inventory, firewall rules, and anything else that references addresses directly.&lt;/p&gt;

&lt;p&gt;ULA solves this. The &lt;code&gt;fd::/8&lt;/code&gt; prefix is locally assigned and never changes. Internal addressing is stable forever. The NAT66 rule handles the GUA ↔ ULA translation at the WAN boundary transparently.&lt;/p&gt;

&lt;p&gt;The trade-off: ULA + NAT66 breaks end-to-end IPv6 reachability (GUA hosts on the internet can't initiate connections to your ULA hosts). For a homelab where all inbound connections come through a Cloudflare Tunnel or Traefik ingress anyway, that's not a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the Setup
&lt;/h2&gt;

&lt;p&gt;After applying the Terraform config and the manual RA 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;# From a device on vlan20-srv (should have fd20::/64 address)&lt;/span&gt;
ip &lt;span class="nt"&gt;-6&lt;/span&gt; addr show
&lt;span class="c"&gt;# Should see: fd20::xxx/64&lt;/span&gt;

&lt;span class="c"&gt;# Test outbound IPv6&lt;/span&gt;
ping6 &lt;span class="nt"&gt;-c&lt;/span&gt; 3 ipv6.google.com
&lt;span class="c"&gt;# Should succeed (NAT66 masquerades the ULA source to the GUA)&lt;/span&gt;

&lt;span class="c"&gt;# From a FritzBox WiFi device&lt;/span&gt;
ip &lt;span class="nt"&gt;-6&lt;/span&gt; route show
&lt;span class="c"&gt;# Default via should point to FritzBox, not MikroTik&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If WiFi clients still route through MikroTik after setting &lt;code&gt;advertise=no&lt;/code&gt;, run &lt;code&gt;ip6/nd print&lt;/code&gt; on the RouterOS terminal to verify the change persisted. RouterOS can be slow to propagate ND configuration changes.&lt;/p&gt;




&lt;p&gt;The same ULA-vs-GUA stability trade-off shows up in Azure networking — except there it's RFC1918 address space behind NAT Gateway or Azure Firewall instead of a CGN ISP. If you're designing the equivalent zero-trust network layer for Azure, the same default-deny-plus-explicit-allow philosophy applies.&lt;/p&gt;



</description>
      <category>mikrotik</category>
      <category>networking</category>
      <category>homelab</category>
    </item>
    <item>
      <title>My Firewall Had 77 Rules. Terraform Knew About 22 of Them.</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 21 Jun 2026 15:43:57 +0000</pubDate>
      <link>https://dev.to/dwoitzik/my-firewall-had-77-rules-terraform-knew-about-22-of-them-4pep</link>
      <guid>https://dev.to/dwoitzik/my-firewall-had-77-rules-terraform-knew-about-22-of-them-4pep</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/mikrotik-firewall-rule-drift-orphaned-rules/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wrote an &lt;a href="https://dev.to/blog/mikrotik-zero-trust-firewall-terraform"&gt;article about building a zero-trust MikroTik firewall with Terraform&lt;/a&gt; — default-deny chains, explicit allow rules, &lt;code&gt;place_before&lt;/code&gt; for deterministic ordering. The Terraform code was correct. I'd run &lt;code&gt;terraform plan&lt;/code&gt; regularly and it showed no drift.&lt;/p&gt;

&lt;p&gt;The live router had 77 firewall filter rules. The Terraform configuration tracked 22.&lt;/p&gt;

&lt;p&gt;This is the story of how that happened, why &lt;code&gt;terraform plan&lt;/code&gt; showing clean didn't catch it, and how a security tightening I'd made — and verified, and considered done — had been silently undone for weeks.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  How You End Up With Four Generations of the Same Firewall
&lt;/h2&gt;

&lt;p&gt;The pattern, in hindsight, is obvious: every time I did a significant firewall rework, I wrote a fresh, complete set of rules in &lt;code&gt;firewall_deterministic.tf&lt;/code&gt; and ran &lt;code&gt;terraform apply&lt;/code&gt;. Terraform created the new rules. It did not — because nothing told it to — remove the old generation, because the old generation's rules weren't &lt;em&gt;Terraform resources Terraform knew about&lt;/em&gt;. They'd been created by a previous &lt;code&gt;terraform apply&lt;/code&gt; of an &lt;em&gt;earlier version&lt;/em&gt; of the same file, then the resource definitions were edited or replaced rather than removed cleanly, or in a couple of cases, created directly via the RouterOS API during a debugging session and never imported.&lt;/p&gt;

&lt;p&gt;Terraform only manages what's in its state. A rule that exists on the router but isn't a resource in the current configuration is invisible to &lt;code&gt;terraform plan&lt;/code&gt; — there's no diff to show, because there's nothing in the config to compare it against. &lt;code&gt;terraform plan&lt;/code&gt; reporting "no changes" means &lt;em&gt;the resources Terraform knows about match reality&lt;/em&gt;. It says nothing about resources Terraform was never told to track.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug This Actually Caused
&lt;/h2&gt;

&lt;p&gt;This wasn't just clutter. It actively undid a real security fix.&lt;/p&gt;

&lt;p&gt;At some point I'd tightened a monitoring rule from "Prometheus can reach all internal VLANs" to "Prometheus can reach only port 9100 on the management VLAN":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The narrow, intentional version — added to fix an overly broad rule&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"routeros_ip_firewall_filter"&lt;/span&gt; &lt;span class="s2"&gt;"fwd_04a_srv_monitoring"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"accept"&lt;/span&gt;
  &lt;span class="nx"&gt;chain&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;
  &lt;span class="nx"&gt;src_address&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.20.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;dst_address&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.10.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;dst_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"9100"&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;place_before&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;routeros_ip_firewall_filter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fwd_08_allow_dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;comment&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"04a: SRV - Prometheus scrape to MGMT node_exporter (port 9100)"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rule existed in Terraform. &lt;code&gt;terraform plan&lt;/code&gt; showed it as applied, no drift. I had every reason to believe the network was scoped exactly this way.&lt;/p&gt;

&lt;p&gt;But RouterOS evaluates firewall rules &lt;strong&gt;in order&lt;/strong&gt; and stops at the &lt;strong&gt;first match&lt;/strong&gt;. Buried earlier in the live ruleset — a leftover from a previous generation — was the old, broad version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"04a: SRV - Allow monitoring to all internal VLANs"
src=10.0.20.0/24 dst=10.0.10.0/24 action=accept
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No port restriction. No protocol restriction. And because RouterOS hit this rule first, traffic matching it was accepted &lt;em&gt;before the router ever evaluated the narrower, newer rule&lt;/em&gt;. The port-9100-only restriction I'd written, tested, and confirmed in Terraform had never actually been enforced on the live device — the older, broader rule was silently winning every time.&lt;/p&gt;

&lt;p&gt;This is the sharpest version of the general problem with ordered rule lists: a rule that looks dead (superseded by a newer one) isn't dead unless it's actually removed. It's just sitting there, waiting for the day its broader match happens to fire first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Actual Scope of the Problem
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull live rules via the RouterOS REST API&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; admin:&lt;span class="nv"&gt;$PASS&lt;/span&gt; https://10.0.10.1/rest/ip/firewall/filter | jq length
&lt;span class="c"&gt;# → 77&lt;/span&gt;

&lt;span class="c"&gt;# Count Terraform-managed resources&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'resource "routeros_ip_firewall_filter"'&lt;/span&gt; terraform/stacks/network/firewall_deterministic.tf
&lt;span class="c"&gt;# → 22&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;55 rules existed on the router with no corresponding Terraform resource. Diffing live rules against the 22 known-good ones by exact field match (action, chain, src/dst address, port, protocol — not just comment text, since comments had also drifted across generations) split that 55 into two groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;36 rules&lt;/strong&gt; were exact or near-exact duplicates of a currently-tracked rule — leftover generations of the same intent, just stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;19 rules&lt;/strong&gt; were legitimate, distinct, and still in active use — VPN access tiers, Atlantis/MikroDash API access, WireGuard, a Minecraft server port-forward, OIDC redirect routes. These had been created manually at some point and simply never added to Terraform in the first place. Not drift in the dangerous sense — just infrastructure that was never brought under IaC.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deleting Firewall Rules Without Locking Yourself Out
&lt;/h2&gt;

&lt;p&gt;This is the highest-blast-radius device in the network. A mistake deleting the wrong rule doesn't get fixed by SSHing back in — if the rule that breaks is the one allowing SSH, there's no way back in remotely. Before deleting anything, I staged a full per-rule restore as a one-shot RouterOS scheduler entry — a dead man's switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cisco_ios"&gt;&lt;code&gt;&lt;span class="k"&gt;/system&lt;/span&gt; scheduler add name="restore-firewall-failsafe" \
&lt;span class="k"&gt;  start-time=startup&lt;/span&gt; interval=5m \
&lt;span class="k"&gt;  on-event="/system&lt;/span&gt; script run restore-firewall-rules"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restore script re-creates every rule about to be deleted, scheduled to fire automatically in five minutes &lt;em&gt;unless cancelled&lt;/em&gt;. The procedure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Stage the restore script and the scheduler entry (not yet running — &lt;code&gt;disabled=yes&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Enable the scheduler.&lt;/li&gt;
&lt;li&gt;Delete the 36 orphaned rules via direct REST API calls.&lt;/li&gt;
&lt;li&gt;Immediately verify DNS, SSH, and WAN connectivity from a separate, already-open session.&lt;/li&gt;
&lt;li&gt;Only if everything checks out: disable and remove the scheduler entry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 4 had failed — if deleting a rule had broken something — the scheduler would have restored the deleted rules automatically within five minutes, without requiring any further access to the router. This pattern generalizes to any change where the failure mode is "I can no longer reach the device to fix my mistake": stage the rollback to fire automatically on a timer, and only cancel the timer after confirming success through a separate channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Left
&lt;/h2&gt;

&lt;p&gt;41 rules remain: the 22 Terraform-managed ones, plus the 19 legitimate manual rules — now tracked as a known gap (&lt;code&gt;docs/OPERATIONS.md&lt;/code&gt;) rather than invisible clutter. Bringing those 19 under Terraform via &lt;code&gt;import&lt;/code&gt; blocks is the obvious next step, but it's explicitly &lt;em&gt;not&lt;/em&gt; urgent — they're working, intentional, and visible in documentation now. The 36 that mattered (because they were actively undermining a security control) are gone.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;terraform plan&lt;/code&gt; showing no drift is not the same claim as "the live device matches my intent." It only means &lt;em&gt;the resources Terraform is tracking&lt;/em&gt; match their last-applied state. Anything created outside that tracked set — via a prior version of the config that got edited rather than cleanly replaced, or via direct API/CLI access during a debugging session — is invisible to the diff, indefinitely, until someone goes and looks at the live device directly.&lt;/p&gt;

&lt;p&gt;For an ordered rule list specifically (firewalls, but also things like Azure Firewall Policy rule collections, NSG priority-ordered rules, or any first-match system), an orphaned broad rule isn't neutral clutter — it can silently take precedence over a narrower rule you believe supersedes it. Periodically diffing live state against Terraform state by direct query — not just trusting &lt;code&gt;plan&lt;/code&gt; — is the only way to catch this class of bug.&lt;/p&gt;




&lt;p&gt;The same risk exists in Azure NSGs and Azure Firewall Policy: priority-ordered rules where an old, broad rule with a lower priority number can silently win over a newer, narrower one if it was never cleaned up after a security tightening. If you're managing NSG rule sets at scale, periodically pulling live rule state via &lt;code&gt;az network nsg rule list&lt;/code&gt; and diffing it against your Terraform state catches exactly this class of drift before it becomes a finding in someone else's audit.&lt;/p&gt;



</description>
      <category>mikrotik</category>
      <category>terraform</category>
      <category>security</category>
      <category>networking</category>
    </item>
    <item>
      <title>Kyverno: Supply Chain Security as Admission Control on Kubernetes</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 21 Jun 2026 15:43:51 +0000</pubDate>
      <link>https://dev.to/dwoitzik/kyverno-supply-chain-security-as-admission-control-on-kubernetes-5blm</link>
      <guid>https://dev.to/dwoitzik/kyverno-supply-chain-security-as-admission-control-on-kubernetes-5blm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/kyverno-supply-chain-security-kubernetes/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Kubernetes has no opinion about what you run. You can deploy a container with no resource limits, no security context, root access to the host filesystem, and an image tagged &lt;code&gt;:latest&lt;/code&gt; that changes every week — and the scheduler will place it without complaint.&lt;/p&gt;

&lt;p&gt;In a homelab that's annoying. In a production cluster, it's a compliance failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kyverno.io/" rel="noopener noreferrer"&gt;Kyverno&lt;/a&gt; is a Kubernetes-native policy engine. It runs as an admission webhook — every &lt;code&gt;kubectl apply&lt;/code&gt;, every ArgoCD sync, every Helm install is evaluated against your policies before it reaches the scheduler. Violations are either blocked (Enforce) or logged (Audit).&lt;/p&gt;

&lt;p&gt;This post covers the three policies running on my k3s cluster and the Audit-first rollout strategy that lets you enforce gradually without breaking existing workloads.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why Admission Control
&lt;/h2&gt;

&lt;p&gt;The alternative to admission control is runtime enforcement: scan running containers, alert on violations, remediate manually. This works, but it's reactive. A misconfigured deployment reaches the scheduler, requests a node, pulls an image, and starts running before anything flags it.&lt;/p&gt;

&lt;p&gt;Admission control is preventive. The webhook intercepts the API request before the object is created. A rejected request never touches the scheduler.&lt;/p&gt;

&lt;p&gt;For supply chain security specifically — controlling what can run, not just what is running — admission control is the right layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Kyverno via ArgoCD
&lt;/h2&gt;

&lt;p&gt;Kyverno deploys via Helm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/kyverno/application.yml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kyverno&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kyverno.github.io/kyverno/&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3.2.6&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kyverno&lt;/span&gt;
    &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;admissionController:&lt;/span&gt;
          &lt;span class="s"&gt;replicas: 1&lt;/span&gt;
        &lt;span class="s"&gt;backgroundController:&lt;/span&gt;
          &lt;span class="s"&gt;replicas: 1&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;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&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;kyverno&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="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="na"&gt;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;backgroundController&lt;/code&gt; is the component that evaluates existing resources against policies (background scan) and populates &lt;code&gt;PolicyReport&lt;/code&gt; objects. Without it, you only catch violations at admission time — existing non-compliant resources stay invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy 1: Require Resource Limits (Audit)
&lt;/h2&gt;

&lt;p&gt;Containers without resource limits are a noisy-neighbour problem. A single container that consumes unbounded memory will trigger the OOM killer across the whole node, affecting every other pod on it.&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;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;policies.kyverno.io/title&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;policies.kyverno.io/description&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;Pods without resource limits can starve other workloads on the same node.&lt;/span&gt;
      &lt;span class="s"&gt;Run `kubectl get policyreport -A` to see violations before enforcing.&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;Audit&lt;/span&gt;
  &lt;span class="na"&gt;background&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;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;check-container-limits&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;Pod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;apps&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;database&lt;/span&gt;&lt;span class="pi"&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;Container&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;request.object.spec.containers[0].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;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;resources.limits&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="na"&gt;foreach&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;list&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.spec.containers"&lt;/span&gt;
            &lt;span class="na"&gt;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;conditions&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;key&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;element.resources.limits&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;length(@)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
                    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equals&lt;/span&gt;
                    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is in &lt;code&gt;Audit&lt;/code&gt; mode — violations are logged to &lt;code&gt;PolicyReport&lt;/code&gt; but not blocked. That's intentional. Before enforcing, you need to know what would break.&lt;/p&gt;

&lt;p&gt;Check current violations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get policyreport &lt;span class="nt"&gt;-A&lt;/span&gt;
kubectl describe policyreport &lt;span class="nt"&gt;-n&lt;/span&gt; apps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The report shows every pod in &lt;code&gt;apps&lt;/code&gt;, &lt;code&gt;monitoring&lt;/code&gt;, and &lt;code&gt;database&lt;/code&gt; namespaces that's missing resource limits. Fix those, then flip &lt;code&gt;validationFailureAction&lt;/code&gt; to &lt;code&gt;Enforce&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy 2: Disallow Privileged Containers (Enforce)
&lt;/h2&gt;

&lt;p&gt;This one is in &lt;code&gt;Enforce&lt;/code&gt; mode from day one. No homelab service needs privileged mode — if something requires &lt;code&gt;privileged: true&lt;/code&gt;, that's a flag to investigate, not accommodate.&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;disallow-privileged-containers&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;background&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;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;check-privileged&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;Pod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;apps&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;external-secrets&lt;/span&gt;&lt;span class="pi"&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;Privileged&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;containers&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;are&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;allowed."&lt;/span&gt;
        &lt;span class="na"&gt;foreach&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;list&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.spec.containers"&lt;/span&gt;
            &lt;span class="na"&gt;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;conditions&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;key&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;element.securityContext.privileged&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;false&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
                    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equals&lt;/span&gt;
                    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;|| false&lt;/code&gt; handles the case where &lt;code&gt;securityContext&lt;/code&gt; is not set — the expression evaluates to false, which correctly passes validation (no security context means non-privileged).&lt;/p&gt;

&lt;p&gt;This blocks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Containers with &lt;code&gt;securityContext.privileged: true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;By extension, anything that tries to use &lt;code&gt;hostPID&lt;/code&gt;, &lt;code&gt;hostNetwork&lt;/code&gt;, or &lt;code&gt;hostPath&lt;/code&gt; in ways that require privilege escalation&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Policy 3: Disallow &lt;code&gt;:latest&lt;/code&gt; Image Tag (Audit)
&lt;/h2&gt;

&lt;p&gt;Images tagged &lt;code&gt;:latest&lt;/code&gt; are non-deterministic. What &lt;code&gt;nginx:latest&lt;/code&gt; points to today is different from what it pointed to last week. Rollbacks are impossible because you can't pin to the previous image. Reproducible deployments require pinned tags — either semver (&lt;code&gt;4.39.20&lt;/code&gt;) or digest (&lt;code&gt;sha256:abc123&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;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;disallow-latest-tag&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;Audit&lt;/span&gt;
  &lt;span class="na"&gt;background&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;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;check-image-tag&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;Pod&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
              &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;apps&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;monitoring&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;database&lt;/span&gt;&lt;span class="pi"&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;Image&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;element.image&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;must&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;use&lt;/span&gt;&lt;span class="nv"&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;specific&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tag,&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;:latest&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;untagged."&lt;/span&gt;
        &lt;span class="na"&gt;foreach&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;list&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.spec.containers"&lt;/span&gt;
            &lt;span class="na"&gt;deny&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;conditions&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;key&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;element.image&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
                    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equals&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;*:latest"&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="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;element.image&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;contains(@,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;':')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
                    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equals&lt;/span&gt;
                    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second condition catches images with no tag at all — &lt;code&gt;nginx&lt;/code&gt; without &lt;code&gt;:latest&lt;/code&gt; still resolves to &lt;code&gt;latest&lt;/code&gt; under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Example: The Authelia &lt;code&gt;:latest&lt;/code&gt; Violation
&lt;/h2&gt;

&lt;p&gt;When I first enabled the &lt;code&gt;disallow-latest-tag&lt;/code&gt; policy in Audit mode and ran &lt;code&gt;kubectl get policyreport -n apps&lt;/code&gt;, Authelia showed up immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS  disallow-privileged-containers  authelia-xxx    apps
FAIL  disallow-latest-tag             authelia-xxx    apps
  → ghcr.io/authelia/authelia:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Authelia deployment had been running &lt;code&gt;latest&lt;/code&gt; from day one. The fix was straightforward:&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;# Before&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/authelia/authelia:latest&lt;/span&gt;

&lt;span class="c1"&gt;# After&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/authelia/authelia:4.39.20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once pinned, the PolicyReport cleared. This is the Audit → Enforce workflow in practice: enable, observe, fix violations, then enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Audit → Enforce Rollout Strategy
&lt;/h2&gt;

&lt;p&gt;The pattern that makes Kyverno safe to adopt on a running cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Deploy all policies in validationFailureAction: Audit
2. Wait for background scans to populate PolicyReports (5-10 min)
3. kubectl get policyreport -A → review violations
4. Fix violations in affected deployments
5. Change policy to validationFailureAction: Enforce
6. Verify no new violations in PolicyReport
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Going directly to Enforce on a running cluster breaks things. ArgoCD sync jobs, Helm hooks, system daemonsets — they all create pods and will hit the policy. Audit first gives you visibility without the blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoping Policies to Specific Namespaces
&lt;/h2&gt;

&lt;p&gt;Notice all three policies match only &lt;code&gt;[apps, monitoring, database]&lt;/code&gt;. System namespaces (&lt;code&gt;kube-system&lt;/code&gt;, &lt;code&gt;kube-public&lt;/code&gt;, &lt;code&gt;argocd&lt;/code&gt;, &lt;code&gt;kyverno&lt;/code&gt;) are excluded deliberately.&lt;/p&gt;

&lt;p&gt;System components often need exception behaviour — hostPath volumes for kubelet, privileged containers for CNI plugins, no resource limits on critical infrastructure. Scoping your policies to user workload namespaces avoids blocking cluster internals while still enforcing what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  PolicyReport: The Visibility Layer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# All reports across all namespaces&lt;/span&gt;
kubectl get policyreport &lt;span class="nt"&gt;-A&lt;/span&gt;

&lt;span class="c"&gt;# Detail for a specific namespace&lt;/span&gt;
kubectl describe policyreport polr-ns-apps &lt;span class="nt"&gt;-n&lt;/span&gt; apps

&lt;span class="c"&gt;# All violations&lt;/span&gt;
kubectl get policyreport &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | &lt;span class="se"&gt;\&lt;/span&gt;
  jq &lt;span class="s1"&gt;'.items[].results[] | select(.result == "fail")'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PolicyReports are Kubernetes-native objects. You can build Grafana dashboards against them (Kyverno exports metrics to Prometheus), alert on policy violations, and track compliance trends over time.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;require-resource-limits&lt;/code&gt; violation count is a useful metric: as you fix deployments, it should trend towards zero. When it hits zero and stays there, flip to Enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enterprise Bridge
&lt;/h2&gt;

&lt;p&gt;These three policies map directly to supply chain security requirements under ISO 27001 (A.12.6 — Technical Vulnerability Management) and NIS2 Article 21 (4.b — handling of incidents, 4.e — supply chain security).&lt;/p&gt;

&lt;p&gt;In Azure environments, the equivalent layer is Azure Policy + Defender for Containers — the same concept, different implementation. For teams deploying Kubernetes workloads in regulated environments, Kyverno policies committed to Git provide the audit trail that compliance frameworks require: every policy change is a pull request, every violation is logged.&lt;/p&gt;



</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>homelab</category>
    </item>
    <item>
      <title>I Ran Gitleaks Against My Own Repo and Found 12 Real Secrets</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 21 Jun 2026 15:14:09 +0000</pubDate>
      <link>https://dev.to/dwoitzik/i-ran-gitleaks-against-my-own-repo-and-found-12-real-secrets-1j18</link>
      <guid>https://dev.to/dwoitzik/i-ran-gitleaks-against-my-own-repo-and-found-12-real-secrets-1j18</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/gitleaks-secret-scanning-homelab-remediation/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I assumed my homelab repo was clean. No one had ever flagged anything in review (there is no one else reviewing it), CI was green, and I generally try to use Vault and ExternalSecrets for anything sensitive.&lt;/p&gt;

&lt;p&gt;Then I ran a full-history &lt;code&gt;gitleaks detect&lt;/code&gt; against it. It found &lt;strong&gt;12 distinct secrets committed in plaintext&lt;/strong&gt; — including the OIDC private key that signs SSO tokens for half the cluster.&lt;/p&gt;

&lt;p&gt;This is the scanning setup I put in place afterward, the baseline strategy that let me adopt secret scanning without getting blocked by my own history on every commit, and the remediation plan for the leaks themselves.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What Gitleaks Found
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gitleaks detect &lt;span class="nt"&gt;--no-banner&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve real findings, plus one already-hashed password (lower severity but still shouldn't be hand-committed) and one false positive in &lt;code&gt;ROADMAP.md&lt;/code&gt; (documentation text that happened to match a generic API key pattern).&lt;/p&gt;

&lt;p&gt;The real findings, by severity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Secret&lt;/th&gt;
&lt;th&gt;Why It Matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/apps/authelia/configmap.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OIDC issuer private key&lt;/td&gt;
&lt;td&gt;Signs SSO tokens for ArgoCD, Vault, Grafana — highest blast radius&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/apps/garage/config.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RPC secret + admin token&lt;/td&gt;
&lt;td&gt;Storage backend for Velero/Loki/CNPG backups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/apps/garage/secrets.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Admin token (duplicate)&lt;/td&gt;
&lt;td&gt;Same secret committed twice in two files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;terraform/stacks/network/local_backend.hcl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Garage S3 access key&lt;/td&gt;
&lt;td&gt;This is the Terraform state backend's own credential&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/system/postgres/cnpg-backup-secret.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Garage S3 secret key&lt;/td&gt;
&lt;td&gt;Used for WAL archiving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/apps/paperless/secrets.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Postgres password + AI API token&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/apps/cloudflared/secrets.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cloudflare Tunnel token&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/apps/headscale/config.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OIDC client secret&lt;/td&gt;
&lt;td&gt;Must match Authelia's client config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/system/monitoring/loki.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minio/S3 password&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubernetes/apps/mikrodash/secrets.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dashboard password&lt;/td&gt;
&lt;td&gt;Lowest priority — internal tool only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these were exposed by a public repo (this one is private), but "private repo" is not a security control — it's a single permission setting away from being public, and anyone with read access to the repo (or its history, forever) has all of this regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Private Repo Doesn't Make This Fine
&lt;/h2&gt;

&lt;p&gt;The honest reason these accumulated: early in the project, before Vault and ExternalSecrets were set up, every new service got a quick &lt;code&gt;secrets.yml&lt;/code&gt; with the actual values inline, "just to get it working." Once Vault was running, &lt;em&gt;new&lt;/em&gt; services went through it — but nobody went back and migrated the old ones. Each individually felt low-risk at the time. Twelve of them, four months later, is a real exposure if the repo's access list ever changes.&lt;/p&gt;

&lt;p&gt;This is the same drift pattern as the Terraform-vs-RouterOS-firewall divergence I wrote about separately: each shortcut is locally reasonable, the accumulated state is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Gitleaks Without Getting Blocked by History
&lt;/h2&gt;

&lt;p&gt;The naive approach — turn on &lt;code&gt;gitleaks protect&lt;/code&gt; in pre-commit and call it done — fails immediately. Every single future commit gets blocked by the 12 pre-existing leaks, because gitleaks scans the whole working tree, not just your diff. You'd have to fix all 12 &lt;em&gt;before&lt;/em&gt; you could make any other commit, including the commit that adds the scanning.&lt;/p&gt;

&lt;p&gt;The fix is a baseline file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gitleaks detect &lt;span class="nt"&gt;--baseline-path&lt;/span&gt; .gitleaks-baseline.json &lt;span class="nt"&gt;--no-banner&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A baseline is a snapshot of currently-known findings. Anything in the baseline is allowed to keep existing; anything &lt;em&gt;new&lt;/em&gt; fails the hook. Generate it once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gitleaks detect &lt;span class="nt"&gt;--report-format&lt;/span&gt; json &lt;span class="nt"&gt;--report-path&lt;/span&gt; .gitleaks-baseline.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit that baseline file. From this point forward, gitleaks only blocks genuinely new secrets — exactly what you want when adopting scanning on a repo with history older than the scanning itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three-Layer Hook Setup
&lt;/h2&gt;

&lt;p&gt;One scan layer is not enough — a single missed &lt;code&gt;git commit --no-verify&lt;/code&gt; or a commit made from a machine without the hooks installed slips through. Three layers, increasing scope, decreasing frequency:&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;# .pre-commit-config.yaml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitleaks-staged&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;Gitleaks (staged changes)&lt;/span&gt;
  &lt;span class="na"&gt;description&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;Blocks committing NEW secrets. Uses .gitleaks-baseline.json so the&lt;/span&gt;
    &lt;span class="s"&gt;12 pre-existing leaks don't block every commit until they're fully&lt;/span&gt;
    &lt;span class="s"&gt;remediated — only genuinely new secrets fail this.&lt;/span&gt;
  &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash -c 'gitleaks protect --staged --baseline-path .gitleaks-baseline.json --no-banner -v'&lt;/span&gt;
  &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system&lt;/span&gt;
  &lt;span class="na"&gt;always_run&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;pass_filenames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pre-commit&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitleaks-full-repo&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;Gitleaks (full history, pre-push only)&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Re-scans the entire repo and history before any push, against the same baseline.&lt;/span&gt;
  &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash -c 'gitleaks detect --baseline-path .gitleaks-baseline.json --no-banner -v'&lt;/span&gt;
  &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system&lt;/span&gt;
  &lt;span class="na"&gt;always_run&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;pass_filenames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pre-push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;gitleaks protect --staged&lt;/code&gt;&lt;/strong&gt; at commit time — fast, scans only what's staged, catches a secret before it ever enters history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;gitleaks detect&lt;/code&gt;&lt;/strong&gt; at push time — re-scans the entire repo (slower, but only runs once per push, not once per commit). This catches anything that slipped past the first layer, for example a commit made with &lt;code&gt;git commit --no-verify&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI&lt;/strong&gt; runs the same &lt;code&gt;gitleaks detect&lt;/code&gt; command as a third, environment-independent layer — catches anything pushed from a machine that never had the hooks installed at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Allowlisting Real False Positives
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ROADMAP.md&lt;/code&gt; false positive needed an explicit allowlist entry, not a baseline bypass — baseline entries are meant for things you intend to fix, allowlist entries are for things that were never secrets in the first place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# .gitleaks.toml&lt;/span&gt;
&lt;span class="nn"&gt;[extend]&lt;/span&gt;
&lt;span class="py"&gt;useDefault&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[allowlist]&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;"Known false positives"&lt;/span&gt;
&lt;span class="py"&gt;regexes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="c"&gt;# ROADMAP.md doc text listing which services use which OIDC client auth&lt;/span&gt;
  &lt;span class="c"&gt;# method — matches the generic-api-key pattern but is plain documentation,&lt;/span&gt;
  &lt;span class="c"&gt;# not a secret.&lt;/span&gt;
  &lt;span class="s"&gt;'''Proxmox/PBS/Grafana/Headscale use `client_secret_basic`'''&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;Be specific with allowlist regexes. A broad pattern here defeats the entire point of scanning — match the exact false-positive string, not a category of strings that happens to include it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Remediation Plan
&lt;/h2&gt;

&lt;p&gt;Finding the leaks and remediating them are two different projects. Remediation means: rotate the actual credential (not just remove it from the file — the old value is still valid until rotated), and move the new value into Vault behind an ExternalSecret so it never gets hand-committed again.&lt;/p&gt;

&lt;p&gt;The tricky part is &lt;strong&gt;ordering&lt;/strong&gt;. Some of these credentials are dependencies of each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Garage RPC secret + admin token + S3 keys
   ↳ Everything else's backups depend on Garage being internally consistent.
     Rotating the S3 key also invalidates Terraform's own state backend
     credential (terraform/stacks/network/local_backend.hcl uses the same
     key) — update both in the same pass or Terraform loses access to its
     own state.

2. Authelia OIDC issuer private key
   ↳ Highest blast radius if left exposed (signs every SSO session).
     After rotating, every service trusting the old key should be checked
     for unexpected active sessions.

3. Everything else, any order
   ↳ Cloudflare Tunnel token (rotate in Cloudflare dashboard first, update
     second — order matters for tokens with an external source of truth).
   ↳ Headscale OIDC client secret must be rotated in lockstep with
     Authelia's matching client config — they're a pair.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A secret with downstream dependents must be rotated with the dependents in mind, not in isolation. Rotating Garage's S3 key without immediately updating the Terraform backend config doesn't remove a vulnerability — it breaks Terraform's access to its own state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming Remediation Actually Worked
&lt;/h2&gt;

&lt;p&gt;After moving a secret to Vault and rotating the credential, re-run the same scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gitleaks detect &lt;span class="nt"&gt;--baseline-path&lt;/span&gt; .gitleaks-baseline.json &lt;span class="nt"&gt;--no-banner&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The secret will &lt;strong&gt;still show up&lt;/strong&gt; — it's in history, and the baseline still lists it. That's expected; the baseline isn't meant to disappear until every listed item has actually been fixed. Only regenerate the baseline once all 12 are addressed, as a final confirmation step that nothing was missed in the process — not as a way to make individual items "go away" faster.&lt;/p&gt;

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

&lt;p&gt;Scanning catches secrets in files. It does not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrub git history.&lt;/strong&gt; The old values remain readable to anyone with repo access, forever, unless you rewrite history (&lt;code&gt;git filter-repo&lt;/code&gt;) — which has its own risks if anyone else has a clone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace rotation.&lt;/strong&gt; A secret found and removed from the current file tree is still valid until you change the actual credential at its source (Cloudflare dashboard, Garage admin CLI, Postgres &lt;code&gt;ALTER USER&lt;/code&gt;, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catch secrets gitleaks' default ruleset doesn't recognize.&lt;/strong&gt; Custom internal token formats need custom regex rules — &lt;code&gt;useDefault = true&lt;/code&gt; covers known formats (AWS keys, generic API key patterns, JWTs) but not everything.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The same baseline-adoption pattern applies directly to any enterprise repo with years of history and no prior secret scanning — which describes most codebases that predate a security initiative. The Vault + ExternalSecrets target architecture this remediation moves toward is the same pattern covered in &lt;a href="https://dev.to/blog/external-secrets-operator-vault-kubernetes"&gt;External Secrets Operator + HashiCorp Vault&lt;/a&gt; — that's where these 12 secrets are headed.&lt;/p&gt;

</description>
      <category>security</category>
      <category>kubernetes</category>
      <category>homelab</category>
    </item>
    <item>
      <title>ArgoCD Gotchas: Cache Staleness and the SharedResourceWarning Nobody Explains</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 21 Jun 2026 15:14:06 +0000</pubDate>
      <link>https://dev.to/dwoitzik/argocd-gotchas-cache-staleness-and-the-sharedresourcewarning-nobody-explains-1jie</link>
      <guid>https://dev.to/dwoitzik/argocd-gotchas-cache-staleness-and-the-sharedresourcewarning-nobody-explains-1jie</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/argocd-cache-staleness-shared-resource-warning/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;kubectl apply&lt;/code&gt; reports success. You check the resource — the field you just changed is back to its old value. No error. No event. &lt;code&gt;kubectl get&lt;/code&gt; shows the change applied, then a few seconds later shows it gone, like it never happened.&lt;/p&gt;

&lt;p&gt;This isn't a typo or a YAML indentation bug. It's ArgoCD's &lt;code&gt;selfHeal&lt;/code&gt; doing exactly what it's designed to do — re-applying from its own cached understanding of what the resource &lt;em&gt;should&lt;/em&gt; be, which can lag behind a change you just made by hand, or even behind a fresh &lt;code&gt;git push&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This hit the same homelab three times in one day, across three unrelated resources. Here's the pattern, the fix, and a second, related gotcha that produces a different symptom from a similar root cause.&lt;/p&gt;

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

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

&lt;p&gt;Three separate incidents, same shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Tempo &lt;code&gt;PersistentVolumeClaim&lt;/code&gt;'s &lt;code&gt;storageClassName&lt;/code&gt; kept reverting after being changed.&lt;/li&gt;
&lt;li&gt;Traefik's &lt;code&gt;tlsStore&lt;/code&gt; and dashboard configuration reverted after a Helm values update.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;paperless-gpt&lt;/code&gt; deployment's &lt;code&gt;volumeMounts&lt;/code&gt; reverted after a direct edit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each time, the sequence was: edit the live resource or push a change to Git → confirm the change is live → come back later → the old value is back, with no error logged anywhere obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Happens: &lt;code&gt;selfHeal&lt;/code&gt; Plus a Stale Cache
&lt;/h2&gt;

&lt;p&gt;ArgoCD's &lt;code&gt;selfHeal: true&lt;/code&gt; continuously reconciles the live cluster state against ArgoCD's &lt;em&gt;rendered&lt;/em&gt; understanding of what the Application's manifests/Helm chart should produce. That's the entire point of GitOps — drift gets corrected automatically, so a manual &lt;code&gt;kubectl edit&lt;/code&gt; doesn't silently become the new permanent state.&lt;/p&gt;

&lt;p&gt;The bug isn't that selfHeal exists. It's that the &lt;strong&gt;rendered understanding&lt;/strong&gt; ArgoCD reconciles against comes from the &lt;code&gt;argocd-repo-server&lt;/code&gt;'s manifest/Helm chart cache, and that cache doesn't always get invalidated promptly after a fresh &lt;code&gt;git push&lt;/code&gt; or a fresh &lt;code&gt;kubectl apply&lt;/code&gt; made outside ArgoCD. For a window of time — usually short, but long enough to be confusing — ArgoCD's source of truth for "what should this look like" is stale, and &lt;code&gt;selfHeal&lt;/code&gt; faithfully reverts your change back to match it.&lt;/p&gt;

&lt;p&gt;This is functionally indistinguishable, from the outside, from "ArgoCD is ignoring my change" — but the actual mechanism is "ArgoCD is enforcing an outdated cached version of what it thinks I want."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Force a Hard Refresh
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl patch application &amp;lt;name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="nt"&gt;--type&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;'{"metadata":{"annotations":{"argocd.argoproj.io/refresh":"hard"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;hard&lt;/code&gt; refresh value (as opposed to &lt;code&gt;normal&lt;/code&gt;) tells ArgoCD to bypass the repo-server's manifest cache entirely and re-render from source. Wait roughly 15 seconds, then re-check.&lt;/p&gt;

&lt;p&gt;If that alone doesn't resolve it, the cache itself may need restarting, not just invalidating for one Application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout restart deployment argocd-repo-server &lt;span class="nt"&gt;-n&lt;/span&gt; argocd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a bigger hammer — it affects every Application's next reconciliation, not just the one you're debugging — so try the targeted &lt;code&gt;hard&lt;/code&gt; refresh annotation first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The StatefulSet Exception
&lt;/h2&gt;

&lt;p&gt;For the Tempo PVC specifically, neither of the above fully resolved it on the first try, because &lt;code&gt;volumeClaimTemplates&lt;/code&gt; on a &lt;code&gt;StatefulSet&lt;/code&gt; are &lt;strong&gt;immutable&lt;/strong&gt; — Kubernetes rejects any attempt to change them on an existing object. Clearing ArgoCD's stale cache fixes ArgoCD's &lt;em&gt;intent&lt;/em&gt; going forward, but it can't retroactively fix a field that was never mutable on the live object in the first place.&lt;/p&gt;

&lt;p&gt;The fix there is to delete and recreate the StatefulSet itself (the underlying PVC and its data survive deleting the StatefulSet, as long as you don't also delete the PVC):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete statefulset &amp;lt;name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; &lt;span class="nt"&gt;--cascade&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orphan
&lt;span class="c"&gt;# re-sync from ArgoCD to recreate the StatefulSet with the new template&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--cascade=orphan&lt;/code&gt; deletes the StatefulSet object without deleting the Pods or PVCs it owns — letting ArgoCD's next sync recreate the StatefulSet (now with the corrected, non-stale template) and re-adopt the existing PVC.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Second, Different-Looking Bug With a Related Cause: SharedResourceWarning
&lt;/h2&gt;

&lt;p&gt;A related but distinct symptom: a resource flickers between two different specs, or gets pruned entirely, and &lt;code&gt;.status.conditions&lt;/code&gt; on one of the Applications shows a &lt;code&gt;SharedResourceWarning&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This isn't a cache problem — it's an &lt;strong&gt;ownership conflict&lt;/strong&gt;. Two different ArgoCD Applications are both trying to manage a resource with the same name and namespace. In this case: a Helm chart's own &lt;code&gt;ingressRoute.dashboard.enabled&lt;/code&gt; flag was creating a Traefik dashboard &lt;code&gt;IngressRoute&lt;/code&gt;, while a separately, manually-defined &lt;code&gt;IngressRoute&lt;/code&gt; with the same name existed in a different Application's manifest set — both claiming ownership of the same object.&lt;/p&gt;

&lt;p&gt;ArgoCD has no way to know which one is "correct" — it just observes that the live object doesn't match what &lt;em&gt;either&lt;/em&gt; Application individually expects, and flags the conflict rather than guessing.&lt;/p&gt;

&lt;p&gt;The fix is to pick exactly one owner and have the other stop claiming the resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/traefik/application.yml — Helm chart's own dashboard route, disabled&lt;/span&gt;
&lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;ingressRoute:&lt;/span&gt;
      &lt;span class="s"&gt;dashboard:&lt;/span&gt;
        &lt;span class="s"&gt;enabled: false  # the manual, Authelia-protected route below is canonical&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="c1"&gt;# kubernetes/system/other-ingressroute.yml — the manually-defined route, kept&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;traefik-dashboard&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;traefik&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# ... Authelia-protected route — this is the one that stays&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once only one Application's manifest set defines the object, recreate it (delete the now-orphaned duplicate definition's effect, let the remaining owner's next sync take over cleanly) and the warning clears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telling the Two Apart
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A field reverts within seconds of a manual or git-pushed change; no error anywhere&lt;/td&gt;
&lt;td&gt;Repo-server cache staleness&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;hard&lt;/code&gt; refresh annotation; restart &lt;code&gt;argocd-repo-server&lt;/code&gt; if that's not enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A field reverts but &lt;code&gt;volumeClaimTemplates&lt;/code&gt; is involved on a StatefulSet&lt;/td&gt;
&lt;td&gt;Cache staleness &lt;em&gt;plus&lt;/em&gt; an immutable field that can't be patched in place&lt;/td&gt;
&lt;td&gt;Same cache fix, plus delete-and-recreate the StatefulSet with &lt;code&gt;--cascade=orphan&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A resource flickers between two different specs, or gets pruned; &lt;code&gt;SharedResourceWarning&lt;/code&gt; in &lt;code&gt;.status.conditions&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Two Applications both claim ownership of the same resource&lt;/td&gt;
&lt;td&gt;Disable one owner's claim (Helm flag or manifest removal), keep the other&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The diagnostic tell: cache staleness is &lt;em&gt;temporal&lt;/em&gt; — the same Application reverts a change made moments ago, and a refresh fixes it. Ownership conflict is &lt;em&gt;structural&lt;/em&gt; — check &lt;code&gt;.status.conditions&lt;/code&gt; for &lt;code&gt;SharedResourceWarning&lt;/code&gt; first; if it's there, refreshing the cache won't help, because there's nothing stale about either Application's understanding — they're both correctly rendering their own manifests, and the manifests themselves conflict.&lt;/p&gt;




&lt;p&gt;The cache-staleness pattern is specific to ArgoCD's repo-server architecture, but the ownership-conflict pattern is universal to any GitOps tool managing Kubernetes resources — Flux has the same failure mode if two Kustomizations or HelmReleases both define a resource with the same identity. Checking &lt;code&gt;.status.conditions&lt;/code&gt; before assuming a sync or cache problem saves a lot of time chasing the wrong fix.&lt;/p&gt;

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