<?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>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;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>
    <item>
      <title>How a 1 GiB Memory Limit Took Down My Entire k3s Cluster</title>
      <dc:creator>david</dc:creator>
      <pubDate>Thu, 18 Jun 2026 19:08:07 +0000</pubDate>
      <link>https://dev.to/dwoitzik/how-a-1-gib-memory-limit-took-down-my-entire-k3s-cluster-pen</link>
      <guid>https://dev.to/dwoitzik/how-a-1-gib-memory-limit-took-down-my-entire-k3s-cluster-pen</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/k3s-cascading-failure-oomkill-dns-storm/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It started with Paperless-ngx crashing.&lt;/p&gt;

&lt;p&gt;It ended with my control-plane node sitting at a load average of 90, CoreDNS generating 1.2 million DNS queries per day, and worker nodes reporting 3.8 GiB of allocatable memory instead of the 16 GiB they actually had.&lt;/p&gt;

&lt;p&gt;The root cause of all of it: a single 1 GiB memory limit set three months earlier without much thought.&lt;/p&gt;

&lt;p&gt;This is the full post-mortem — not the sanitized version where everything was obvious in hindsight, but the actual sequence of failures and how I traced each one back to its 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 Setup
&lt;/h2&gt;

&lt;p&gt;Three-node k3s cluster running on Proxmox VMs (VLAN 20, server subnet):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;vm-srv-k3s-11&lt;/code&gt; — control-plane, 4 cores, 12 GiB dedicated&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vm-srv-k3s-12&lt;/code&gt; — worker, 4 cores, up to 16 GiB (balloon)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vm-srv-k3s-13&lt;/code&gt; — worker, 4 cores, up to 16 GiB (balloon)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apps namespace runs about 20 workloads: Nextcloud, Authelia, Paperless-ngx, Jellyfin, Home Assistant, Gitea, Mealie, and more. GitOps via ArgoCD; Longhorn for distributed storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: Paperless OOMKilled 16 Times in 5 Hours
&lt;/h2&gt;

&lt;p&gt;Paperless-ngx uses Tesseract for OCR and Apache Tika for document ingestion. When a batch of documents hits at once — invoice exports, scanned PDFs — both workers burst memory hard and fast.&lt;/p&gt;

&lt;p&gt;The deployment had 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="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;250m&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;500m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 1 GiB ceiling is too low. When Tesseract processes a high-resolution scanned document, it easily needs 2–3 GiB. The kernel OOM killer terminates the container every time. Kubernetes restarts it. The next document in the queue triggers another OOM. Repeat sixteen times.&lt;/p&gt;

&lt;p&gt;Fix: raised limits and reduced concurrency to stay under the higher ceiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;500m&lt;/span&gt;
  &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3Gi&lt;/span&gt;
    &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2000m&lt;/span&gt;
&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PAPERLESS_TASK_WORKERS&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;2"&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;PAPERLESS_THREADS_PER_WORKER&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;2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;But this didn't explain the load average of 90.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 2: The Control-Plane Was Scheduling App Workloads
&lt;/h2&gt;

&lt;p&gt;When I checked where Paperless was running, it was on &lt;code&gt;vm-srv-k3s-11&lt;/code&gt; — the control-plane.&lt;/p&gt;

&lt;p&gt;In a standard k3s setup, the control-plane has a &lt;code&gt;node-role.kubernetes.io/control-plane:NoSchedule&lt;/code&gt; taint. User workloads shouldn't land there. But somewhere along the way, the Paperless deployment had picked up a toleration:&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;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&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;Exists&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;operator: Exists&lt;/code&gt; with no &lt;code&gt;key&lt;/code&gt; or &lt;code&gt;effect&lt;/code&gt; matches &lt;strong&gt;every taint on every node&lt;/strong&gt;, including &lt;code&gt;NoSchedule&lt;/code&gt; on the control-plane. The pod scheduled there, and every OOMKill → restart cycle added another spike of CPU load to a node already running etcd, the k3s API server, CoreDNS, kube-proxy, and Longhorn replica management.&lt;/p&gt;

&lt;p&gt;The fix was to remove the blanket toleration entirely. The Paperless deployment doesn't need to run on the control-plane.&lt;/p&gt;

&lt;p&gt;With the toleration removed and the memory limit raised, load on &lt;code&gt;vm-srv-k3s-11&lt;/code&gt; dropped from 90 to 1.04 immediately. But two more problems had already developed in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 3: CoreDNS Was Generating 1.2 Million Queries Per Day
&lt;/h2&gt;

&lt;p&gt;During the OOM cascade, I noticed AdGuard Home (running on two Raspberry Pi nodes in HA via Keepalived) was under unusually high load. I checked the query log: &lt;strong&gt;1.2 million DNS queries in 24 hours&lt;/strong&gt; for a three-node homelab cluster.&lt;/p&gt;

&lt;p&gt;The culprit: CoreDNS default cache TTL.&lt;/p&gt;

&lt;p&gt;CoreDNS ships with a 30-second cache TTL. Every pod that makes a DNS lookup for a Kubernetes service gets an answer that expires in 30 seconds. In a healthy cluster that's fine. During an OOM cascade — where pods are restarting constantly, new IPs are being assigned, and connection state is unstable — the DNS query rate explodes. Pods that are restarting frequently keep hammering CoreDNS for the same records.&lt;/p&gt;

&lt;p&gt;The fix was a one-line patch to the CoreDNS ConfigMap:&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 configmap coredns &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;--patch&lt;/span&gt; &lt;span class="s1"&gt;'
data:
  Corefile: |
    .:53 {
      errors
      health
      ready
      kubernetes cluster.local in-addr.arpa ip6.arpa {
        pods insecure
        fallthrough in-addr.arpa ip6.arpa
        ttl 30
      }
      prometheus :9153
      forward . /etc/resolv.conf
      cache 300
      loop
      reload
      loadbalance
    }
'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raising the cache TTL from 30 to 300 seconds reduced the upstream query volume by roughly 10x. I also updated AdGuard Home (via Ansible) to enable optimistic caching and increase its cache size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ansible/roles/adguard/templates/AdGuardHome.yaml.j2&lt;/span&gt;
&lt;span class="na"&gt;dns&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cache_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;67108864&lt;/span&gt;  &lt;span class="c1"&gt;# 64 MiB&lt;/span&gt;
  &lt;span class="na"&gt;cache_optimistic&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;cache_optimistic: true&lt;/code&gt; means AdGuard returns the cached (possibly stale) answer immediately while refreshing in the background — eliminating the latency spike on cache expiry. Combined, these two changes brought the daily query count down to ~120k.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 4: Worker Nodes Reporting Wrong Allocatable Memory
&lt;/h2&gt;

&lt;p&gt;While fixing the above, I noticed something odd in &lt;code&gt;kubectl describe node vm-srv-k3s-12&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;Capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="m"&gt;4&lt;/span&gt;
  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;3981384Ki  ← ~3.8 GiB&lt;/span&gt;
&lt;span class="na"&gt;Allocatable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="m"&gt;4&lt;/span&gt;
  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;3878584Ki  ← ~3.7 GiB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VM was allocated 16 GiB in Proxmox. Why was kubelet reporting 3.8 GiB?&lt;/p&gt;

&lt;p&gt;The answer is Proxmox balloon memory.&lt;/p&gt;

&lt;p&gt;Balloon memory in Proxmox works like this: you set a &lt;code&gt;dedicated&lt;/code&gt; (maximum) and a &lt;code&gt;floating&lt;/code&gt; (minimum) value. When the host is under memory pressure, Proxmox can shrink the guest down to the &lt;code&gt;floating&lt;/code&gt; minimum. The key detail: &lt;strong&gt;kubelet reads available memory at startup time&lt;/strong&gt;. If kubelet starts when the VM has been ballooned down to its minimum, that's what it registers as the node's capacity — and it doesn't update that value dynamically.&lt;/p&gt;

&lt;p&gt;My Terraform config had this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dedicated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16384&lt;/span&gt;  &lt;span class="c1"&gt;# 16 GiB max&lt;/span&gt;
  &lt;span class="nx"&gt;floating&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;   &lt;span class="c1"&gt;# 4 GiB min ← too low&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workers had been under pressure during the OOM cascade, Proxmox had ballooned them down to 4 GiB, kubelet restarted and registered 3.8 GiB (4096 MB minus kernel + system overhead), and that's what Kubernetes thought the nodes had.&lt;/p&gt;

&lt;p&gt;The fix: raise the minimum balloon to ensure kubelet always sees adequate memory:&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;memory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;dedicated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16384&lt;/span&gt;
  &lt;span class="nx"&gt;floating&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;   &lt;span class="c1"&gt;# 8 GiB min — safe floor for kubelet registration&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After restarting &lt;code&gt;k3s-agent&lt;/code&gt; on both workers, capacity showed correctly:&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;Capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;16383272Ki&lt;/span&gt;  &lt;span class="c1"&gt;# 16 GiB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Full Cascade, Traced
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1Gi Paperless limit + Exists toleration
        ↓
OOMKill × 16 on the control-plane
        ↓
k3s-11 load average: 90
(etcd + API server + OCR workers + Longhorn replicas all competing)
        ↓
Pods restarting constantly → high DNS churn
        ↓
CoreDNS 30s TTL → 1.2M queries/day → AdGuard overload
        ↓
Balloon minimum 4096 MB → kubelet restart → 3.8 GiB registered
        ↓
Scheduler thinks workers have less capacity → over-schedules control-plane
        ↓
(back to top)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each failure made the next one worse. Raising the memory limit without fixing the toleration would have helped Paperless but left the control-plane overloaded. Fixing the toleration without fixing the balloon minimum would have moved the problem to a worker node with 3.8 GiB of visible capacity. The DNS fix was independent but would have eventually caused its own stability issues at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Would Have Caught This Earlier
&lt;/h2&gt;

&lt;p&gt;A few things would have surfaced these issues before they compounded:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Resource limit policy at admission time.&lt;/strong&gt; A Kyverno &lt;code&gt;require-resource-limits&lt;/code&gt; policy in Audit mode would have flagged the original 1 GiB limit as a potential issue and made it visible in PolicyReports before OOMKills started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Control-plane taint monitoring.&lt;/strong&gt; A simple alert on &lt;code&gt;kube_pod_info{node="vm-srv-k3s-11"} unless kube_pod_info{namespace="kube-system"}&lt;/code&gt; would have fired the moment a user workload landed on the control-plane.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Node capacity validation in Terraform.&lt;/strong&gt; The balloon minimum should be part of the VM definition review — ideally validated against the minimum kubelet requires to start safely.&lt;/p&gt;

&lt;p&gt;None of these are exotic. They're standard practice in production clusters. The lesson is that homelab clusters accumulate the same failure modes as production clusters, just with less monitoring to catch them.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Root 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;OOMKill × 16&lt;/td&gt;
&lt;td&gt;1 GiB limit too low for Tesseract burst&lt;/td&gt;
&lt;td&gt;Limit → 3 GiB, workers → 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control-plane load 90&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tolerations: operator: Exists&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove blanket toleration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1.2M DNS queries/day&lt;/td&gt;
&lt;td&gt;CoreDNS TTL 30s + OOM-induced restart churn&lt;/td&gt;
&lt;td&gt;CoreDNS cache → 300s, AdGuard optimistic + 64 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.8 GiB allocatable&lt;/td&gt;
&lt;td&gt;Proxmox balloon min 4096 MB, kubelet reads at startup&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;floating = 8192&lt;/code&gt; in Terraform&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cluster has been stable since. Paperless processes the same document batches without issue. CoreDNS query volume is down 90%. And kubelet now correctly reports 16 GiB on both workers.&lt;/p&gt;




&lt;p&gt;The same failure modes — resource limits without ceiling analysis, overly permissive scheduling constraints, and hypervisor-level capacity mismatches — appear in enterprise Kubernetes deployments running on Azure VMs or bare-metal. The only difference is scale: one misconfigured limit in a 500-node cluster can trigger the same DNS storm, just with three extra zeros behind the query count.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>debugging</category>
    </item>
    <item>
      <title>External Secrets Operator + HashiCorp Vault: GitOps Secret Lifecycle in Kubernetes</title>
      <dc:creator>david</dc:creator>
      <pubDate>Thu, 18 Jun 2026 19:08:06 +0000</pubDate>
      <link>https://dev.to/dwoitzik/external-secrets-operator-hashicorp-vault-gitops-secret-lifecycle-in-kubernetes-467k</link>
      <guid>https://dev.to/dwoitzik/external-secrets-operator-hashicorp-vault-gitops-secret-lifecycle-in-kubernetes-467k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/external-secrets-operator-vault-kubernetes/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Kubernetes Secrets are not secret.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;base64&lt;/code&gt; is not encryption. Anyone with &lt;code&gt;kubectl get secret&lt;/code&gt; access can decode them instantly. Secrets stored in etcd are encrypted at rest only if you've explicitly configured encryption providers — and most clusters haven't. And if you're managing secrets in Git (even with SOPS or Sealed Secrets), the ciphertext is committed to version control forever.&lt;/p&gt;

&lt;p&gt;The proper solution is an external secret store: a system specifically designed for secret storage, with access control, audit logging, and rotation built in. &lt;a href="https://www.vaultproject.io/" rel="noopener noreferrer"&gt;HashiCorp Vault&lt;/a&gt; is the most common choice. &lt;a href="https://external-secrets.io/" rel="noopener noreferrer"&gt;External Secrets Operator&lt;/a&gt; bridges Vault to Kubernetes — syncing secrets into the cluster without storing them in Git.&lt;/p&gt;

&lt;p&gt;This post covers the full setup running on my k3s cluster: Vault deployment, bootstrap sequence, ClusterSecretStore, and the first real ExternalSecret.&lt;/p&gt;

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

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Git (no secrets)
      ↓ ArgoCD syncs
  Vault (KV v2)          ←── You store secrets here
      ↑
External Secrets Operator
      ↓ creates/syncs
  k8s Secret (in-cluster, not in Git)
      ↓ consumed by
  Application Pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key property: &lt;strong&gt;nothing sensitive is ever committed to Git&lt;/strong&gt;. ArgoCD manages all Kubernetes manifests except Secrets. Vault holds the actual values. ESO syncs them into the cluster on a refresh interval. Applications consume &lt;code&gt;k8s.io/v1/Secret&lt;/code&gt; objects as normal — nothing changes from the application's perspective.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Deploy Vault via ArgoCD
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/vault/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;vault&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://helm.releases.hashicorp.com&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;0.28.1&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;vault&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;server:&lt;/span&gt;
          &lt;span class="s"&gt;standalone:&lt;/span&gt;
            &lt;span class="s"&gt;enabled: true&lt;/span&gt;
          &lt;span class="s"&gt;dataStorage:&lt;/span&gt;
            &lt;span class="s"&gt;enabled: true&lt;/span&gt;
            &lt;span class="s"&gt;size: 5Gi&lt;/span&gt;
            &lt;span class="s"&gt;storageClass: nfs-client&lt;/span&gt;
        &lt;span class="s"&gt;ui:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: true&lt;/span&gt;
          &lt;span class="s"&gt;serviceType: ClusterIP&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;vault&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;NFS-backed storage for the Vault data directory. Vault runs as a single-node instance (standalone mode) — sufficient for a homelab, and it avoids the complexity of Raft consensus with multiple replicas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The Bootstrap Sequence
&lt;/h2&gt;

&lt;p&gt;Vault ships sealed. Before it can serve any secrets, you must unseal it. This is a one-time manual operation — by design. Unsealing requires a quorum of key shares, so no single compromise can unlock the store.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Wait for the Vault pod to be running&lt;/span&gt;
kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Ready pod/vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120s

&lt;span class="c"&gt;# 2. Initialize Vault (5 key shares, 3 required to unseal)&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault &lt;span class="nt"&gt;--&lt;/span&gt; vault operator init &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-key-shares&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-key-threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3

&lt;span class="c"&gt;# Output (save these — you cannot recover them):&lt;/span&gt;
&lt;span class="c"&gt;# Unseal Key 1: abc...&lt;/span&gt;
&lt;span class="c"&gt;# Unseal Key 2: def...&lt;/span&gt;
&lt;span class="c"&gt;# Unseal Key 3: ghi...&lt;/span&gt;
&lt;span class="c"&gt;# Unseal Key 4: jkl...&lt;/span&gt;
&lt;span class="c"&gt;# Unseal Key 5: mno...&lt;/span&gt;
&lt;span class="c"&gt;# Initial Root Token: hvs.xxx...&lt;/span&gt;

&lt;span class="c"&gt;# 3. Unseal with 3 of the 5 keys&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault &lt;span class="nt"&gt;--&lt;/span&gt; vault operator unseal &amp;lt;key-1&amp;gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault &lt;span class="nt"&gt;--&lt;/span&gt; vault operator unseal &amp;lt;key-2&amp;gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault &lt;span class="nt"&gt;--&lt;/span&gt; vault operator unseal &amp;lt;key-3&amp;gt;

&lt;span class="c"&gt;# 4. Verify unsealed&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault &lt;span class="nt"&gt;--&lt;/span&gt; vault status
&lt;span class="c"&gt;# Sealed: false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Store the unseal keys and root token somewhere secure.&lt;/strong&gt; Losing them means losing access to your Vault permanently. A password manager with hardware 2FA (Vaultwarden, 1Password, Bitwarden) works. Do not commit them to Git.&lt;/p&gt;

&lt;p&gt;After a Vault pod restart (node reboot, update), you need to unseal again with 3 keys. Auto-unseal via AWS KMS or Azure Key Vault removes this manual step in production environments — acceptable for a homelab to skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Enable KV v2 and Write Your First Secret
&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;# Authenticate with the root token&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; vault-0 &lt;span class="nt"&gt;-n&lt;/span&gt; vault &lt;span class="nt"&gt;--&lt;/span&gt; /bin/sh
vault login &amp;lt;root-token&amp;gt;

&lt;span class="c"&gt;# Enable KV v2 at the 'secret/' path&lt;/span&gt;
vault secrets &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret kv-v2

&lt;span class="c"&gt;# Write the first secret&lt;/span&gt;
vault kv put secret/authelia &lt;span class="se"&gt;\&lt;/span&gt;
  hmac-secret&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;-base64&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;# Verify&lt;/span&gt;
vault kv get secret/authelia
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;KV v2 maintains version history. You can roll back to a previous version of a secret, see who wrote what and when (with audit logging enabled), and compare versions. This is what makes Vault appropriate for compliance contexts — it's not just a secret store, it's a secret lifecycle management system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Deploy External Secrets Operator
&lt;/h2&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;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;external-secrets&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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://charts.external-secrets.io&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;0.10.4&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;external-secrets&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;external-secrets&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;h2&gt;
  
  
  Step 5: Create the Token Secret and ClusterSecretStore
&lt;/h2&gt;

&lt;p&gt;ESO needs a way to authenticate against Vault. The simplest approach for a single-cluster setup: a Kubernetes secret containing the Vault root token (or a scoped AppRole token for production).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create the token secret that ESO will use to authenticate against Vault&lt;/span&gt;
kubectl create secret generic vault-token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; external-secrets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;vault-root-token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the &lt;code&gt;ClusterSecretStore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/external-secrets/cluster-secret-store.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;external-secrets.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterSecretStore&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault&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;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;vault&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;http://vault.vault.svc.cluster.local:8200&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v2&lt;/span&gt;
      &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;tokenSecretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault-token&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;external-secrets&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ClusterSecretStore&lt;/code&gt; (vs &lt;code&gt;SecretStore&lt;/code&gt;) is cluster-scoped — any namespace can reference it. For multi-tenant clusters where namespaces shouldn't cross-read each other's secrets, use namespace-scoped &lt;code&gt;SecretStore&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;path: secret&lt;/code&gt; and &lt;code&gt;version: v2&lt;/code&gt; match the KV mount we created in step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: The First ExternalSecret
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/apps/authelia/external-secret.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;external-secrets.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ExternalSecret&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;authelia-secrets&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;secretStoreRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault&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;ClusterSecretStore&lt;/span&gt;
  &lt;span class="na"&gt;target&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-secrets&lt;/span&gt;
    &lt;span class="na"&gt;creationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Merge&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hmac-secret&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&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="s"&gt;secret/authelia&lt;/span&gt;
        &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hmac-secret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;refreshInterval: 1h&lt;/code&gt;&lt;/strong&gt; — ESO re-reads from Vault every hour. If you rotate the secret in Vault, the k8s Secret is updated within an hour. No pod restart required for most applications that read secrets from mounted files (as opposed to environment variables, which require a restart).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;creationPolicy: Merge&lt;/code&gt;&lt;/strong&gt; — Instead of creating a new Secret from scratch, ESO merges the Vault-sourced key into an existing Secret. This is useful when a Secret needs some values from Vault (sensitive) and others from a ConfigMap (non-sensitive). The application sees a single unified Secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;remoteRef.key&lt;/code&gt;&lt;/strong&gt; — The full Vault path is &lt;code&gt;secret/data/authelia&lt;/code&gt; (KV v2 prepends &lt;code&gt;data/&lt;/code&gt;), but ESO handles the &lt;code&gt;/data/&lt;/code&gt; prefix automatically when &lt;code&gt;version: v2&lt;/code&gt; is set. You write &lt;code&gt;secret/authelia&lt;/code&gt; in the ExternalSecret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying It Works
&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;# Check the ExternalSecret sync status&lt;/span&gt;
kubectl get externalsecret authelia-secrets &lt;span class="nt"&gt;-n&lt;/span&gt; apps

&lt;span class="c"&gt;# Output:&lt;/span&gt;
&lt;span class="c"&gt;# NAME               STORE   REFRESH INTERVAL   STATUS         READY&lt;/span&gt;
&lt;span class="c"&gt;# authelia-secrets   vault   1h                 SecretSynced   True&lt;/span&gt;

&lt;span class="c"&gt;# Check the resulting k8s Secret&lt;/span&gt;
kubectl get secret authelia-secrets &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;'{.data.hmac-secret}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;STATUS&lt;/code&gt; shows &lt;code&gt;SecretSyncedError&lt;/code&gt;, check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;kubectl describe externalsecret authelia-secrets -n apps&lt;/code&gt; for the error message&lt;/li&gt;
&lt;li&gt;Vault pod is running and unsealed (&lt;code&gt;kubectl exec vault-0 -n vault -- vault status&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The token secret exists in the &lt;code&gt;external-secrets&lt;/code&gt; namespace&lt;/li&gt;
&lt;li&gt;The KV path actually exists in Vault (&lt;code&gt;vault kv get secret/authelia&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit log&lt;/strong&gt;: Every secret read from Vault is logged. &lt;code&gt;vault audit enable file file_path=/vault/logs/audit.log&lt;/code&gt; gives you a full trail of who (which token) accessed what secret and when.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotation without redeployment&lt;/strong&gt;: Rotate a secret in Vault, ESO syncs it within the refresh interval. For file-mounted secrets, the pod picks it up without restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No secrets in Git&lt;/strong&gt;: The ExternalSecret manifest commits to Git. It describes &lt;em&gt;what&lt;/em&gt; to sync and &lt;em&gt;where from&lt;/em&gt; — but not the value. The value stays in Vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance evidence&lt;/strong&gt;: KV v2 version history + audit log gives you the access evidence ISO 27001 (A.9.4 — System and Application Access Control) and NIS2 require.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next: AppRole Authentication
&lt;/h2&gt;

&lt;p&gt;The setup above uses the Vault root token for ESO authentication. That works, but the root token has unrestricted access to everything in Vault.&lt;/p&gt;

&lt;p&gt;For a more hardened setup, create a Vault AppRole with a policy scoped to only the secrets ESO needs:&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;# Policy: ESO can only read under secret/data/&lt;/span&gt;
vault policy write eso-readonly - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
path "secret/data/*" {
  capabilities = ["read"]
}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# AppRole&lt;/span&gt;
vault auth &lt;span class="nb"&gt;enable &lt;/span&gt;approle
vault write auth/approle/role/eso &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;policies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eso-readonly"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;token_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1h &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;token_max_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4h

&lt;span class="c"&gt;# Get role_id and secret_id for ESO&lt;/span&gt;
vault &lt;span class="nb"&gt;read &lt;/span&gt;auth/approle/role/eso/role-id
vault write &lt;span class="nt"&gt;-f&lt;/span&gt; auth/approle/role/eso/secret-id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the &lt;code&gt;ClusterSecretStore&lt;/code&gt; to use AppRole auth instead of &lt;code&gt;tokenSecretRef&lt;/code&gt;. This follows the principle of least privilege — a compromise of the ESO token only exposes read access to secrets, not root-level Vault control.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>security</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Full Observability on k3s: kube-prometheus-stack + Loki + Grafana OIDC</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 14 Jun 2026 12:07:33 +0000</pubDate>
      <link>https://dev.to/dwoitzik/full-observability-on-k3s-kube-prometheus-stack-loki-grafana-oidc-1lec</link>
      <guid>https://dev.to/dwoitzik/full-observability-on-k3s-kube-prometheus-stack-loki-grafana-oidc-1lec</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/kube-prometheus-loki-grafana-k3s/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A Kubernetes cluster without observability is a black box. You deploy services, they run — until they don't. When something breaks at 2am, you need metrics, logs, and alerts that actually tell you what happened.&lt;/p&gt;

&lt;p&gt;This is the full observability stack running on my bare-metal k3s cluster: &lt;code&gt;kube-prometheus-stack&lt;/code&gt; for metrics and alerting, Loki with Garage S3 for log persistence, Promtail collecting logs from non-Kubernetes nodes via Ansible, SNMP metrics from the MikroTik router, and Grafana with Authelia OIDC — so there's one login for everything.&lt;/p&gt;

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

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Metrics                          Logs
───────                          ────
kube-prometheus-stack            Promtail (k8s DaemonSet)
  ├── Prometheus (30d retention)   ├── All pod logs
  ├── Alertmanager                 └── System logs
  └── node-exporter (k8s)
                                 Promtail (Ansible, bare-metal)
SNMP Exporter                      ├── /var/log/syslog
  └── MikroTik RB5009 → Prometheus └── /var/log/auth.log
                                   └── Docker container logs
node_exporter (Ansible)
  └── RPi + LXC nodes → Prometheus

                    Grafana (OIDC via Authelia)
                         │
                    ┌────┴────┐
               Prometheus    Loki → Garage S3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything lands in Grafana. One URL, one SSO login, metrics and logs side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: kube-prometheus-stack via ArgoCD
&lt;/h2&gt;

&lt;p&gt;The kube-prometheus-stack Helm chart installs Prometheus, Alertmanager, Grafana, and all the associated CRDs in a single deployment.&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/kube-prometheus-stack.yaml&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;kube-prometheus-stack&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://prometheus-community.github.io/helm-charts&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;61.3.2&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;kube-prometheus-stack&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;prometheusOperator:&lt;/span&gt;
          &lt;span class="s"&gt;crds:&lt;/span&gt;
            &lt;span class="s"&gt;enabled: false      # manage CRDs separately to avoid ArgoCD ordering issues&lt;/span&gt;

        &lt;span class="s"&gt;prometheus:&lt;/span&gt;
          &lt;span class="s"&gt;prometheusSpec:&lt;/span&gt;
            &lt;span class="s"&gt;retention: 30d&lt;/span&gt;
            &lt;span class="s"&gt;dnsConfig:&lt;/span&gt;
              &lt;span class="s"&gt;options:&lt;/span&gt;
                &lt;span class="s"&gt;- name: ndots&lt;/span&gt;
                  &lt;span class="s"&gt;value: "1"&lt;/span&gt;
            &lt;span class="s"&gt;storageSpec:&lt;/span&gt;
              &lt;span class="s"&gt;volumeClaimTemplate:&lt;/span&gt;
                &lt;span class="s"&gt;spec:&lt;/span&gt;
                  &lt;span class="s"&gt;storageClassName: longhorn&lt;/span&gt;
                  &lt;span class="s"&gt;accessModes: ["ReadWriteOnce"]&lt;/span&gt;
                  &lt;span class="s"&gt;resources:&lt;/span&gt;
                    &lt;span class="s"&gt;requests:&lt;/span&gt;
                      &lt;span class="s"&gt;storage: 20Gi&lt;/span&gt;

        &lt;span class="s"&gt;grafana:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: true&lt;/span&gt;
          &lt;span class="s"&gt;sidecar:&lt;/span&gt;
            &lt;span class="s"&gt;datasources:&lt;/span&gt;
              &lt;span class="s"&gt;enabled: true&lt;/span&gt;
              &lt;span class="s"&gt;searchNamespace: ALL&lt;/span&gt;
            &lt;span class="s"&gt;dashboards:&lt;/span&gt;
              &lt;span class="s"&gt;enabled: true&lt;/span&gt;
              &lt;span class="s"&gt;searchNamespace: ALL&lt;/span&gt;
              &lt;span class="s"&gt;label: grafana_dashboard&lt;/span&gt;
              &lt;span class="s"&gt;labelValue: "1"&lt;/span&gt;
          &lt;span class="s"&gt;additionalDataSources:&lt;/span&gt;
            &lt;span class="s"&gt;- name: Loki&lt;/span&gt;
              &lt;span class="s"&gt;type: loki&lt;/span&gt;
              &lt;span class="s"&gt;access: proxy&lt;/span&gt;
              &lt;span class="s"&gt;url: http://loki.monitoring.svc.cluster.local:3100&lt;/span&gt;
              &lt;span class="s"&gt;jsonData:&lt;/span&gt;
                &lt;span class="s"&gt;maxLines: 1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;prometheusOperator.crds.enabled: false&lt;/code&gt;&lt;/strong&gt; — CRDs and the operator have an ordering dependency. Disabling CRD installation here and managing them separately prevents ArgoCD sync failures on fresh installs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;retention: 30d&lt;/code&gt;&lt;/strong&gt; with Longhorn storage — Prometheus data persists across pod restarts and node reboots. Without &lt;code&gt;storageSpec&lt;/code&gt;, metrics live only in the pod's ephemeral storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ndots: 1&lt;/code&gt;&lt;/strong&gt; — reduces DNS lookup latency inside the cluster. With Kubernetes' default of 5, every single-label hostname triggers 5 NXDOMAIN lookups before resolution. Setting it to 1 cuts that overhead significantly for monitoring scrapes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Grafana OIDC via Authelia
&lt;/h2&gt;

&lt;p&gt;Grafana's generic OAuth provider connects to &lt;a href="https://dev.to/blog/k3s-authelia-proxmox-homelab"&gt;Authelia&lt;/a&gt;. Users authenticate once — the same session covers Grafana, Vaultwarden, and every other protected service.&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;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;grafana.ini&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="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring.yourdomain.com&lt;/span&gt;
              &lt;span class="na"&gt;root_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://monitoring.yourdomain.com&lt;/span&gt;

            &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;oauth_auto_login&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;auth.generic_oauth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
              &lt;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;client_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
              &lt;span class="na"&gt;client_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;your-oidc-client-secret&amp;gt;"&lt;/span&gt;
              &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openid profile email groups&lt;/span&gt;
              &lt;span class="na"&gt;auth_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://auth.yourdomain.com/api/oidc/authorization&lt;/span&gt;
              &lt;span class="na"&gt;token_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://auth.yourdomain.com/api/oidc/token&lt;/span&gt;
              &lt;span class="na"&gt;api_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://auth.yourdomain.com/api/oidc/userinfo&lt;/span&gt;
              &lt;span class="na"&gt;login_attribute_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;preferred_username&lt;/span&gt;
              &lt;span class="na"&gt;groups_attribute_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;groups&lt;/span&gt;
              &lt;span class="na"&gt;role_attribute_path&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;contains(groups[*], 'admins') &amp;amp;&amp;amp; 'Admin' || 'Viewer'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;role_attribute_path&lt;/code&gt; maps Authelia group membership to Grafana roles using JMESPath. Members of the &lt;code&gt;admins&lt;/code&gt; group get Admin access; everyone else gets read-only Viewer access. No per-user role management in Grafana.&lt;/p&gt;

&lt;p&gt;In Authelia, add the client:&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;identity_providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;oidc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
        &lt;span class="na"&gt;client_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Grafana&lt;/span&gt;
        &lt;span class="na"&gt;client_secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;bcrypt-hash&amp;gt;"&lt;/span&gt;
        &lt;span class="na"&gt;authorization_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;one_factor&lt;/span&gt;
        &lt;span class="na"&gt;redirect_uris&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;https://monitoring.yourdomain.com/login/generic_oauth&lt;/span&gt;
        &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;openid&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;profile&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;email&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;groups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Loki with Garage S3 Storage
&lt;/h2&gt;

&lt;p&gt;Loki needs durable object storage. Storing logs on a local PVC means a node failure loses your log history. Garage — the same lightweight S3 instance &lt;a href="https://dev.to/blog/velero-garage-k3s-backup"&gt;already deployed for Velero backups&lt;/a&gt; — handles 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="c1"&gt;# kubernetes/system/monitoring/loki.yaml&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;loki&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://grafana.github.io/helm-charts&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;6.6.2&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;loki&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;deploymentMode: SingleBinary&lt;/span&gt;

        &lt;span class="s"&gt;loki:&lt;/span&gt;
          &lt;span class="s"&gt;auth_enabled: false&lt;/span&gt;
          &lt;span class="s"&gt;commonConfig:&lt;/span&gt;
            &lt;span class="s"&gt;replication_factor: 1&lt;/span&gt;

          &lt;span class="s"&gt;storage:&lt;/span&gt;
            &lt;span class="s"&gt;type: s3&lt;/span&gt;
            &lt;span class="s"&gt;bucketNames:&lt;/span&gt;
              &lt;span class="s"&gt;chunks: loki-data&lt;/span&gt;
              &lt;span class="s"&gt;ruler: loki-data&lt;/span&gt;
              &lt;span class="s"&gt;admin: loki-data&lt;/span&gt;
            &lt;span class="s"&gt;s3:&lt;/span&gt;
              &lt;span class="s"&gt;endpoint: http://garage.apps.svc.cluster.local:3900&lt;/span&gt;
              &lt;span class="s"&gt;region: homelab&lt;/span&gt;
              &lt;span class="s"&gt;s3ForcePathStyle: true&lt;/span&gt;
              &lt;span class="s"&gt;insecure: true&lt;/span&gt;

          &lt;span class="s"&gt;limits_config:&lt;/span&gt;
            &lt;span class="s"&gt;retention_period: 30d&lt;/span&gt;
            &lt;span class="s"&gt;ingestion_rate_mb: 2&lt;/span&gt;
            &lt;span class="s"&gt;ingestion_burst_size_mb: 4&lt;/span&gt;

          &lt;span class="s"&gt;compactor:&lt;/span&gt;
            &lt;span class="s"&gt;retention_enabled: true&lt;/span&gt;
            &lt;span class="s"&gt;compaction_interval: 10m&lt;/span&gt;
            &lt;span class="s"&gt;retention_delete_delay: 2h&lt;/span&gt;

          &lt;span class="s"&gt;schemaConfig:&lt;/span&gt;
            &lt;span class="s"&gt;configs:&lt;/span&gt;
              &lt;span class="s"&gt;- from: "2024-04-01"&lt;/span&gt;
                &lt;span class="s"&gt;object_store: s3&lt;/span&gt;
                &lt;span class="s"&gt;store: tsdb&lt;/span&gt;
                &lt;span class="s"&gt;schema: v13&lt;/span&gt;
                &lt;span class="s"&gt;index:&lt;/span&gt;
                  &lt;span class="s"&gt;prefix: index_&lt;/span&gt;
                  &lt;span class="s"&gt;period: 24h&lt;/span&gt;

        &lt;span class="s"&gt;singleBinary:&lt;/span&gt;
          &lt;span class="s"&gt;replicas: 1&lt;/span&gt;
          &lt;span class="s"&gt;extraEnv:&lt;/span&gt;
            &lt;span class="s"&gt;- name: AWS_ACCESS_KEY_ID&lt;/span&gt;
              &lt;span class="s"&gt;valueFrom:&lt;/span&gt;
                &lt;span class="s"&gt;secretKeyRef:&lt;/span&gt;
                  &lt;span class="s"&gt;name: loki-s3-secrets&lt;/span&gt;
                  &lt;span class="s"&gt;key: access-key-id&lt;/span&gt;
            &lt;span class="s"&gt;- name: AWS_SECRET_ACCESS_KEY&lt;/span&gt;
              &lt;span class="s"&gt;valueFrom:&lt;/span&gt;
                &lt;span class="s"&gt;secretKeyRef:&lt;/span&gt;
                  &lt;span class="s"&gt;name: loki-s3-secrets&lt;/span&gt;
                  &lt;span class="s"&gt;key: secret-access-key&lt;/span&gt;

        &lt;span class="s"&gt;# disable unused replicated components&lt;/span&gt;
        &lt;span class="s"&gt;read:&lt;/span&gt;
          &lt;span class="s"&gt;replicas: 0&lt;/span&gt;
        &lt;span class="s"&gt;write:&lt;/span&gt;
          &lt;span class="s"&gt;replicas: 0&lt;/span&gt;
        &lt;span class="s"&gt;backend:&lt;/span&gt;
          &lt;span class="s"&gt;replicas: 0&lt;/span&gt;

        &lt;span class="s"&gt;chunksCache:&lt;/span&gt;
          &lt;span class="s"&gt;allocatedMemory: 512&lt;/span&gt;
        &lt;span class="s"&gt;resultsCache:&lt;/span&gt;
          &lt;span class="s"&gt;allocatedMemory: 512&lt;/span&gt;

        &lt;span class="s"&gt;lokiCanary:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: false&lt;/span&gt;
        &lt;span class="s"&gt;test:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;s3ForcePathStyle: true&lt;/code&gt; and &lt;code&gt;insecure: true&lt;/code&gt; (plain HTTP to the cluster-internal Garage endpoint) are both required. Garage uses path-style URLs, and the internal cluster DNS endpoint is HTTP — Loki's TLS verification would fail on a self-signed cert.&lt;/p&gt;

&lt;p&gt;Before deploying, create the Loki bucket in Garage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; apps deploy/garage &lt;span class="nt"&gt;--&lt;/span&gt; /garage bucket create loki-data
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; apps deploy/garage &lt;span class="nt"&gt;--&lt;/span&gt; /garage key create loki-key
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; apps deploy/garage &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /garage bucket allow loki-data &lt;span class="nt"&gt;--read&lt;/span&gt; &lt;span class="nt"&gt;--write&lt;/span&gt; &lt;span class="nt"&gt;--owner&lt;/span&gt; &lt;span class="nt"&gt;--key&lt;/span&gt; loki-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create the credentials secret:&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;Secret&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;loki-s3-secrets&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Opaque&lt;/span&gt;
&lt;span class="na"&gt;stringData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;access-key-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;garage-key-id&amp;gt;"&lt;/span&gt;
  &lt;span class="na"&gt;secret-access-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;&amp;lt;garage-secret-key&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Promtail as a DaemonSet
&lt;/h2&gt;

&lt;p&gt;Promtail ships with the Loki chart and runs as a DaemonSet — one pod per node — collecting all container logs automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# kubernetes/system/monitoring/promtail.yaml&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;promtail&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://grafana.github.io/helm-charts&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;6.16.4&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;promtail&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;config:&lt;/span&gt;
          &lt;span class="s"&gt;clients:&lt;/span&gt;
            &lt;span class="s"&gt;- url: http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Promtail discovers all pods automatically via the Kubernetes API. No per-service configuration needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: node_exporter + Promtail on Bare-Metal Nodes
&lt;/h2&gt;

&lt;p&gt;The Raspberry Pi nodes and Docker LXC container are not part of the k3s cluster — they need the monitoring agent installed via Ansible.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;monitoring_agent&lt;/code&gt; role deploys node_exporter and Promtail as Docker containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ansible/roles/monitoring_agent/tasks/main.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy node_exporter&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose.yml.j2&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/docker/node_exporter/docker-compose.yml&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy promtail configuration&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;promtail.yml.j2&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/docker/promtail/promtail.yml&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restart promtail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node_exporter Compose template exposes system metrics on port 9100:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;node_exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/node-exporter:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_exporter&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/proc:/host/proc:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/sys:/host/sys:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/:/rootfs:ro&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="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.procfs=/host/proc'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.rootfs=/rootfs'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--path.sysfs=/host/sys'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9100:9100"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Promtail config ships system logs, auth logs, and Docker container logs to Loki:&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;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://{{ monitoring_core_host }}:3100/loki/api/v1/push&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&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;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;localhost&lt;/span&gt;&lt;span class="pi"&gt;]&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;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;inventory_hostname&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
          &lt;span class="na"&gt;__path__&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/syslog&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;localhost&lt;/span&gt;&lt;span class="pi"&gt;]&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;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;inventory_hostname&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
          &lt;span class="na"&gt;__path__&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/log/auth.log&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;localhost&lt;/span&gt;&lt;span class="pi"&gt;]&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;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{{&lt;/span&gt; &lt;span class="nv"&gt;inventory_hostname&lt;/span&gt; &lt;span class="pi"&gt;}}&lt;/span&gt;
          &lt;span class="na"&gt;__path__&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/docker/containers/*/*-json.log&lt;/span&gt;
    &lt;span class="na"&gt;pipeline_stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;expressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;log&lt;/span&gt;
            &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;stream&lt;/span&gt;
            &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;attrs.name&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;stream&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;output&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: SNMP Monitoring for MikroTik
&lt;/h2&gt;

&lt;p&gt;The MikroTik router runs SNMP but doesn't expose Prometheus metrics natively. The SNMP exporter bridges this gap — it scrapes the router via SNMP and translates the results to Prometheus format.&lt;/p&gt;

&lt;p&gt;In the Prometheus config (inside kube-prometheus-stack's &lt;code&gt;additionalScrapeConfigs&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;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mikrotik_snmp'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.0.10.1'&lt;/span&gt;     &lt;span class="c1"&gt;# MikroTik management IP&lt;/span&gt;
    &lt;span class="na"&gt;metrics_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/snmp&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;module&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;if_mib&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;public_v2&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__address__&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__param_target&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;__param_target&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;instance&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__address__&lt;/span&gt;
        &lt;span class="na"&gt;replacement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;snmp-exporter:9116&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you per-interface traffic counters, error rates, and operational status for every port on the switch — all visible in Grafana.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Custom Dashboard as ConfigMap
&lt;/h2&gt;

&lt;p&gt;Grafana's sidecar watches for ConfigMaps with the label &lt;code&gt;grafana_dashboard: "1"&lt;/code&gt; and automatically imports them. This means dashboards are version-controlled in Git:&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/loki-dashboard.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;loki-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;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;grafana_dashboard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;loki-logs.json&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;{&lt;/span&gt;
      &lt;span class="s"&gt;"title": "Loki: Kubernetes Logs",&lt;/span&gt;
      &lt;span class="s"&gt;"uid": "loki-kubernetes-logs",&lt;/span&gt;
      &lt;span class="s"&gt;"panels": [&lt;/span&gt;
        &lt;span class="s"&gt;{&lt;/span&gt;
          &lt;span class="s"&gt;"title": "Log Stream",&lt;/span&gt;
          &lt;span class="s"&gt;"type": "logs",&lt;/span&gt;
          &lt;span class="s"&gt;"targets": [&lt;/span&gt;
            &lt;span class="s"&gt;{&lt;/span&gt;
              &lt;span class="s"&gt;"expr": "{namespace=~\"$namespace\", pod=~\"$pod\"}"&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
          &lt;span class="s"&gt;]&lt;/span&gt;
        &lt;span class="s"&gt;}&lt;/span&gt;
      &lt;span class="s"&gt;]&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No manual dashboard import, no "save to disk" issues after container restarts. The dashboard JSON lives in Git, ArgoCD applies it, the sidecar picks it up automatically.&lt;/p&gt;

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

&lt;p&gt;After deploying all components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metrics&lt;/strong&gt;: Prometheus scrapes every k3s node, every bare-metal node, and the MikroTik router. 30 days of history on Longhorn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs&lt;/strong&gt;: All pod logs and system logs from every node flow into Loki, stored durably on Garage S3. 30 day retention with automatic compaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dashboards&lt;/strong&gt;: Grafana shows metrics and logs in a single view, with Loki datasource pre-configured. Custom dashboards deploy via ArgoCD with zero manual steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access&lt;/strong&gt;: One SSO login via Authelia. Group membership determines the Grafana role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage efficiency&lt;/strong&gt;: Garage serves both Velero backups and Loki log chunks from a single lightweight deployment — two use cases, one binary.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The same three-layer observability model — metrics, logs, traces — applies in enterprise Azure environments with Azure Monitor, Log Analytics, and Application Insights. If you're building the network foundation that those services sit on, the &lt;a href="https://dev.to/templates"&gt;Enterprise Terraform Blueprints&lt;/a&gt; cover the Private Link isolation layer for Azure monitoring endpoints.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>HA DNS for Homelab: Unbound + AdGuard Home + Keepalived on Raspberry Pi</title>
      <dc:creator>david</dc:creator>
      <pubDate>Sun, 14 Jun 2026 12:06:45 +0000</pubDate>
      <link>https://dev.to/dwoitzik/ha-dns-for-homelab-unbound-adguard-home-keepalived-on-raspberry-pi-4oo1</link>
      <guid>https://dev.to/dwoitzik/ha-dns-for-homelab-unbound-adguard-home-keepalived-on-raspberry-pi-4oo1</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://woitzik.dev/blog/unbound-adguard-keepalived-homelab/" rel="noopener noreferrer"&gt;woitzik.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;DNS is the most critical service in any network. If it goes down, nothing works — browsers can't resolve hostnames, services can't reach each other, and the error messages are uniformly unhelpful. In a homelab, a single DNS server is a single point of failure.&lt;/p&gt;

&lt;p&gt;This is the DNS architecture running on two Raspberry Pi 4B edge nodes in my homelab: Unbound as a recursive resolver, AdGuard Home for filtering, and Keepalived for automatic failover. The whole stack is managed with Ansible.&lt;/p&gt;

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

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client (any device on the network)
        │
        ▼
Virtual IP 10.0.20.5 (Keepalived VIP)
        │
        ├── Primary: rpi-srv-01 (10.0.20.2) — MASTER
        └── Backup:  rpi-srv-02 (10.0.20.3) — BACKUP
               │
               ▼
        AdGuard Home (filtering + blocking)
               │
               ▼
        Unbound :5335 (recursive resolver)
               │
               ▼
        Root DNS servers (no upstream forwarder)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clients point to a single IP (the VIP). If the primary Pi fails, Keepalived moves the VIP to the backup node within seconds. No client reconfiguration, no DNS TTL wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Recursive Resolution
&lt;/h2&gt;

&lt;p&gt;Most homelab DNS setups forward queries to a public upstream (Cloudflare, Google, Quad9). That works, but every query you make is visible to a third party.&lt;/p&gt;

&lt;p&gt;Unbound resolves queries by starting at the DNS root servers and following delegations down — the same way authoritative DNS actually works. No single upstream sees your full query history. The trade-off is slightly higher first-query latency; subsequent queries are cached locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Unbound Ansible Role
&lt;/h2&gt;

&lt;p&gt;Unbound runs in Docker on each Pi. The Ansible role deploys the Compose stack and configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ansible/roles/unbound/tasks/main.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Create Unbound configuration directory&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/unbound/conf&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;directory&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0755'&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Unbound configuration&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unbound.conf.j2&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/unbound/conf/unbound.conf&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restart Unbound&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Unbound Docker Compose stack&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/unbound/docker-compose.yml&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;services:&lt;/span&gt;
        &lt;span class="s"&gt;unbound:&lt;/span&gt;
          &lt;span class="s"&gt;image: klutchell/unbound:latest&lt;/span&gt;
          &lt;span class="s"&gt;container_name: unbound&lt;/span&gt;
          &lt;span class="s"&gt;restart: unless-stopped&lt;/span&gt;
          &lt;span class="s"&gt;ports:&lt;/span&gt;
            &lt;span class="s"&gt;- "5335:53/udp"&lt;/span&gt;
            &lt;span class="s"&gt;- "5335:53/tcp"&lt;/span&gt;
          &lt;span class="s"&gt;healthcheck:&lt;/span&gt;
            &lt;span class="s"&gt;test: ["CMD", "dig", "+short", "@127.0.0.1", "-p", "5335", "google.com"]&lt;/span&gt;
            &lt;span class="s"&gt;interval: 30s&lt;/span&gt;
            &lt;span class="s"&gt;timeout: 10s&lt;/span&gt;
            &lt;span class="s"&gt;retries: 3&lt;/span&gt;
          &lt;span class="s"&gt;labels:&lt;/span&gt;
            &lt;span class="s"&gt;- "autoheal=true"&lt;/span&gt;
          &lt;span class="s"&gt;volumes:&lt;/span&gt;
            &lt;span class="s"&gt;- /opt/unbound/conf/unbound.conf:/etc/unbound/unbound.conf&lt;/span&gt;

        &lt;span class="s"&gt;autoheal:&lt;/span&gt;
          &lt;span class="s"&gt;image: willfarrell/autoheal:latest&lt;/span&gt;
          &lt;span class="s"&gt;container_name: autoheal&lt;/span&gt;
          &lt;span class="s"&gt;restart: always&lt;/span&gt;
          &lt;span class="s"&gt;environment:&lt;/span&gt;
            &lt;span class="s"&gt;- AUTOHEAL_CONTAINER_LABEL=autoheal&lt;/span&gt;
          &lt;span class="s"&gt;volumes:&lt;/span&gt;
            &lt;span class="s"&gt;- /var/run/docker.sock:/var/run/docker.sock&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;Optimize kernel network buffers for Unbound&lt;/span&gt;
  &lt;span class="na"&gt;ansible.posix.sysctl&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="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.name&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;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;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item.value&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;
    &lt;span class="na"&gt;sysctl_set&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;loop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&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;net.core.rmem_max"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&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;4194304"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&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;net.core.wmem_max"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&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;4194304"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Port 5335&lt;/strong&gt; — Unbound does not bind to port 53. That port belongs to AdGuard Home. AdGuard forwards to &lt;code&gt;127.0.0.1:5335&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autoheal&lt;/strong&gt; — watches for containers with the &lt;code&gt;autoheal=true&lt;/code&gt; label and restarts them if the healthcheck fails. DNS downtime on a Pi is silent and annoying; autoheal catches it automatically.&lt;/p&gt;

&lt;p&gt;The Unbound configuration template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# ansible/roles/unbound/templates/unbound.conf.j2
&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;:
    &lt;span class="n"&gt;interface&lt;/span&gt;: &lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;: &lt;span class="m"&gt;53&lt;/span&gt;
    &lt;span class="n"&gt;do&lt;/span&gt;-&lt;span class="n"&gt;ip4&lt;/span&gt;: &lt;span class="n"&gt;yes&lt;/span&gt;
    &lt;span class="n"&gt;do&lt;/span&gt;-&lt;span class="n"&gt;udp&lt;/span&gt;: &lt;span class="n"&gt;yes&lt;/span&gt;
    &lt;span class="n"&gt;do&lt;/span&gt;-&lt;span class="n"&gt;tcp&lt;/span&gt;: &lt;span class="n"&gt;yes&lt;/span&gt;
    &lt;span class="n"&gt;do&lt;/span&gt;-&lt;span class="n"&gt;ip6&lt;/span&gt;: &lt;span class="n"&gt;no&lt;/span&gt;

    &lt;span class="n"&gt;access&lt;/span&gt;-&lt;span class="n"&gt;control&lt;/span&gt;: &lt;span class="m"&gt;127&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;8&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt;
    &lt;span class="n"&gt;access&lt;/span&gt;-&lt;span class="n"&gt;control&lt;/span&gt;: &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;8&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt;

    &lt;span class="n"&gt;hide&lt;/span&gt;-&lt;span class="n"&gt;identity&lt;/span&gt;: &lt;span class="n"&gt;yes&lt;/span&gt;
    &lt;span class="n"&gt;hide&lt;/span&gt;-&lt;span class="n"&gt;version&lt;/span&gt;: &lt;span class="n"&gt;yes&lt;/span&gt;

    &lt;span class="c"&gt;# Cache settings
&lt;/span&gt;    &lt;span class="n"&gt;cache&lt;/span&gt;-&lt;span class="n"&gt;min&lt;/span&gt;-&lt;span class="n"&gt;ttl&lt;/span&gt;: &lt;span class="m"&gt;60&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;-&lt;span class="n"&gt;max&lt;/span&gt;-&lt;span class="n"&gt;ttl&lt;/span&gt;: &lt;span class="m"&gt;86400&lt;/span&gt;
    &lt;span class="n"&gt;prefetch&lt;/span&gt;: &lt;span class="n"&gt;yes&lt;/span&gt;

    &lt;span class="c"&gt;# DNSSEC
&lt;/span&gt;    &lt;span class="n"&gt;auto&lt;/span&gt;-&lt;span class="n"&gt;trust&lt;/span&gt;-&lt;span class="n"&gt;anchor&lt;/span&gt;-&lt;span class="n"&gt;file&lt;/span&gt;: &lt;span class="s2"&gt;"/var/lib/unbound/root.key"&lt;/span&gt;

&lt;span class="n"&gt;forward&lt;/span&gt;-&lt;span class="n"&gt;zone&lt;/span&gt;:
    &lt;span class="n"&gt;name&lt;/span&gt;: &lt;span class="s2"&gt;"."&lt;/span&gt;
    &lt;span class="n"&gt;forward&lt;/span&gt;-&lt;span class="n"&gt;first&lt;/span&gt;: &lt;span class="n"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;access-control: 10.0.0.0/8 allow&lt;/code&gt; permits queries from all homelab VLANs. Queries from outside that range are refused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: AdGuard Home Ansible Role
&lt;/h2&gt;

&lt;p&gt;AdGuard runs in &lt;code&gt;network_mode: host&lt;/code&gt; so it can bind to port 53 directly on the Pi's interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ansible/roles/adguard/tasks/main.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy AdGuard Home Docker Compose file&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/adguardhome/docker-compose.yml&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;services:&lt;/span&gt;
        &lt;span class="s"&gt;adguardhome:&lt;/span&gt;
          &lt;span class="s"&gt;image: adguard/adguardhome&lt;/span&gt;
          &lt;span class="s"&gt;container_name: adguardhome&lt;/span&gt;
          &lt;span class="s"&gt;restart: always&lt;/span&gt;
          &lt;span class="s"&gt;network_mode: host&lt;/span&gt;
          &lt;span class="s"&gt;volumes:&lt;/span&gt;
            &lt;span class="s"&gt;- /opt/adguardhome/work:/opt/adguardhome/work&lt;/span&gt;
            &lt;span class="s"&gt;- /opt/adguardhome/conf:/opt/adguardhome/conf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the AdGuard UI, set the upstream DNS to &lt;code&gt;127.0.0.1:5335&lt;/code&gt; (Unbound). All filtered queries that pass through AdGuard's blocklists are forwarded to Unbound for recursive resolution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Config Sync to the Replica
&lt;/h2&gt;

&lt;p&gt;AdGuard doesn't natively replicate configuration between instances. The role uses &lt;a href="https://github.com/bakito/adguardhome-sync" rel="noopener noreferrer"&gt;adguardhome-sync&lt;/a&gt; on the backup node to pull config from the primary:&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;Deploy AdGuardHome-Sync on replica node&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inventory_hostname == 'rpi-srv-02'&lt;/span&gt;
  &lt;span class="na"&gt;block&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;Deploy Sync Docker Compose&lt;/span&gt;
      &lt;span class="na"&gt;ansible.builtin.copy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/adguardhome-sync/docker-compose.yml&lt;/span&gt;
        &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;services:&lt;/span&gt;
            &lt;span class="s"&gt;adguardhome-sync:&lt;/span&gt;
              &lt;span class="s"&gt;image: ghcr.io/bakito/adguardhome-sync&lt;/span&gt;
              &lt;span class="s"&gt;container_name: adguardhome-sync&lt;/span&gt;
              &lt;span class="s"&gt;restart: unless-stopped&lt;/span&gt;
              &lt;span class="s"&gt;environment:&lt;/span&gt;
                &lt;span class="s"&gt;- ORIGIN_URL=http://10.0.20.2:3001&lt;/span&gt;
                &lt;span class="s"&gt;- ORIGIN_USERNAME=dw&lt;/span&gt;
                &lt;span class="s"&gt;- ORIGIN_PASSWORD={{ vault_adguard_password }}&lt;/span&gt;
                &lt;span class="s"&gt;- REPLICA1_URL=http://127.0.0.1:3001&lt;/span&gt;
                &lt;span class="s"&gt;- REPLICA1_USERNAME=dw&lt;/span&gt;
                &lt;span class="s"&gt;- REPLICA1_PASSWORD={{ vault_adguard_password }}&lt;/span&gt;
                &lt;span class="s"&gt;- CRON=*/10 * * * *&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every 10 minutes, the replica pulls filter lists, custom rules, and settings from the primary. If the primary goes down, the replica is already up to date and takes over immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Keepalived for Failover
&lt;/h2&gt;

&lt;p&gt;Keepalived uses VRRP to maintain a shared Virtual IP across both nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# ansible/roles/keepalived/templates/keepalived.conf.j2
&lt;/span&gt;&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_1&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; {{ &lt;span class="s2"&gt;"MASTER"&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inventory_hostname&lt;/span&gt; == &lt;span class="s1"&gt;'rpi-srv-01'&lt;/span&gt; &lt;span class="n"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;"BACKUP"&lt;/span&gt; }}
    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; {{ &lt;span class="m"&gt;150&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inventory_hostname&lt;/span&gt; == &lt;span class="s1"&gt;'rpi-srv-01'&lt;/span&gt; &lt;span class="n"&gt;else&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt; }}
    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

    &lt;span class="n"&gt;authentication&lt;/span&gt; {
        &lt;span class="n"&gt;auth_type&lt;/span&gt; &lt;span class="n"&gt;PASS&lt;/span&gt;
        &lt;span class="n"&gt;auth_pass&lt;/span&gt; {{ &lt;span class="n"&gt;keepalived_auth_pass&lt;/span&gt; }}
    }

    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;20&lt;/span&gt;.&lt;span class="m"&gt;5&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The primary node (&lt;code&gt;rpi-srv-01&lt;/code&gt;) has priority 150, the backup has 100. As long as the primary is up, it holds the VIP. If it stops sending VRRP advertisements, the backup promotes itself and takes over the IP within ~3 seconds.&lt;/p&gt;

&lt;p&gt;VRRP uses multicast. If your switch filters multicast between ports (MikroTik does by default), you need to permit &lt;code&gt;224.0.0.18&lt;/code&gt; on the VLAN carrying the Pi nodes.&lt;/p&gt;

&lt;p&gt;The Ansible task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ansible/roles/keepalived/tasks/main.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Keepalived&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.apt&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;keepalived&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;present&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy Keepalived configuration from template&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keepalived.conf.j2&lt;/span&gt;
    &lt;span class="na"&gt;dest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/keepalived/keepalived.conf&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Restart Keepalived&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;Ensure Keepalived service is started and enabled&lt;/span&gt;
  &lt;span class="na"&gt;ansible.builtin.service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keepalived&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;started&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deploying with Ansible
&lt;/h2&gt;

&lt;p&gt;The three roles are applied to the &lt;code&gt;rpi_nodes&lt;/code&gt; host group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ansible/playbooks/site.yml (relevant section)&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rpi_nodes&lt;/span&gt;
  &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;common&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;unbound&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;adguard&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;keepalived&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Deploy to both Pi nodes&lt;/span&gt;
ansible-playbook ansible/playbooks/site.yml &lt;span class="nt"&gt;--limit&lt;/span&gt; rpi_nodes

&lt;span class="c"&gt;# Dry run first&lt;/span&gt;
ansible-playbook ansible/playbooks/site.yml &lt;span class="nt"&gt;--limit&lt;/span&gt; rpi_nodes &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing Failover
&lt;/h2&gt;

&lt;p&gt;Confirm the VIP is on the primary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip addr show eth0 | &lt;span class="nb"&gt;grep &lt;/span&gt;10.0.20.5
&lt;span class="c"&gt;# Should show the VIP on rpi-srv-01 only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simulate a failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On rpi-srv-01&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop keepalived

&lt;span class="c"&gt;# On any client&lt;/span&gt;
dig @10.0.20.5 google.com
&lt;span class="c"&gt;# Should still resolve — now via rpi-srv-02&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check which node now holds the VIP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On rpi-srv-02&lt;/span&gt;
ip addr show eth0 | &lt;span class="nb"&gt;grep &lt;/span&gt;10.0.20.5
&lt;span class="c"&gt;# VIP should now appear here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Keepalived on the primary and it re-claims the VIP automatically.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;All devices point to &lt;code&gt;10.0.20.5&lt;/code&gt; — a single address that never changes&lt;/li&gt;
&lt;li&gt;Queries are filtered by AdGuard (blocklists, custom rules) then resolved recursively by Unbound&lt;/li&gt;
&lt;li&gt;If either Pi goes down, the other takes over within 3 seconds&lt;/li&gt;
&lt;li&gt;Filter lists and config sync automatically every 10 minutes&lt;/li&gt;
&lt;li&gt;Kernel buffer tuning ensures Unbound can handle high-volume UDP traffic without dropping queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entire stack is idempotent Ansible — running the playbook again changes nothing if everything is already in the desired state.&lt;/p&gt;




&lt;p&gt;DNS control is the foundation of any Zero-Trust network — on-premises or in the cloud. In Azure, the equivalent of this setup is Azure Private DNS Zones with Private Link resolvers. The &lt;a href="https://dev.to/templates"&gt;Enterprise Terraform Blueprints&lt;/a&gt; include pre-configured Private DNS Zones for all major Azure PaaS services.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>networking</category>
      <category>dns</category>
    </item>
  </channel>
</rss>
