<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Bizr.net</title>
	<atom:link href="https://bizr.net/feed/" rel="self" type="application/rss+xml" />
	<link>https://bizr.net</link>
	<description>Lessons from the lab, the stack, and the team.</description>
	<lastBuildDate>Thu, 25 Jun 2026 00:07:46 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0</generator>

<image>
	<url>https://bizr.net/wp-content/uploads/2026/05/favicon-1.png</url>
	<title>Bizr.net</title>
	<link>https://bizr.net</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Watching the Firewall Breathe: Monitoring OPNsense with Telegraf, InfluxDB, and Grafana</title>
		<link>https://bizr.net/watching-the-firewall-breathe-monitoring-opnsense-with-telegraf-influxdb-and-grafana/</link>
					<comments>https://bizr.net/watching-the-firewall-breathe-monitoring-opnsense-with-telegraf-influxdb-and-grafana/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Thu, 25 Jun 2026 00:07:43 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[OPNsense]]></category>
		<category><![CDATA[Self-Hosting]]></category>
		<category><![CDATA[sso]]></category>
		<category><![CDATA[Threat Intelligence]]></category>
		<category><![CDATA[two-factor-authentication]]></category>
		<category><![CDATA[WireGuard]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2247</guid>

					<description><![CDATA[The OPNsense dashboard tells you how things look right now. It has no memory of an hour ago, let alone last Tuesday at 2am. Here's how I gave it one — wiring Telegraf, InfluxDB, and Grafana into a real monitoring stack, and the version-mismatch trap that quietly cost me the most time along the way.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">For a long time, &#8220;is OPNsense okay?&#8221; meant logging into the web UI and eyeballing a handful of widgets on the dashboard. CPU looked fine. Memory looked fine. The interface graphs were&#8230; there, technically, but only for whatever window the dashboard widget happened to keep in memory. The moment I closed the tab, that history was gone. If something had spiked at 3am, I had no way to know unless I happened to be awake and staring at the screen when it happened.</p>



<p class="wp-block-paragraph">That&#8217;s not monitoring. That&#8217;s just looking.</p>



<p class="wp-block-paragraph">This post is about the stack I run instead: <strong>Telegraf</strong> collecting metrics off OPNsense, <strong>InfluxDB</strong> storing them as a proper time series, and <strong>Grafana</strong> turning that history into dashboards I can actually trust. None of the three pieces is exotic — this is a genuinely standard combination, sometimes shortened to &#8220;TIG stack&#8221; minus the L (Telegraf, InfluxDB, Grafana) — but getting OPNsense specifically to feed into it cleanly took a bit more care than I expected, and there are a few decisions along the way that are easy to get wrong without realizing it for weeks.</p>



<h2 class="wp-block-heading">Why bother with a separate stack at all</h2>



<p class="wp-block-paragraph">OPNsense&#8217;s own dashboard isn&#8217;t bad. For a quick &#8220;is the box currently on fire&#8221; check, it&#8217;s genuinely useful. But it has three structural limitations that no amount of squinting fixes:</p>



<ol class="wp-block-list">
<li><strong>No real history.</strong> The graphs reset when you reload the page, and even when they don&#8217;t, they&#8217;re not backed by a queryable time series — you can&#8217;t ask &#8220;what did WAN throughput look like at 2am three Tuesdays ago&#8221; and get an answer.</li>



<li><strong>No cross-host correlation.</strong> If you&#8217;re running two OPNsense boxes — which, if you&#8217;ve read <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">Never Lock Yourself Out</a>, you know I do, one at home and one fronting the colo stack — the built-in dashboard has no concept of &#8220;show me both of these side by side.&#8221; </li>



<li><strong>No alerting on the data itself</strong>, beyond OPNsense&#8217;s own fairly basic notification system. You can get an email when a service crashes, but you can&#8217;t easily say &#8220;page me if WAN throughput sustains over 80% of link capacity for five minutes&#8221; without something purpose-built for that kind of rule.</li>
</ol>



<p class="wp-block-paragraph">A proper time series database and a real dashboarding tool solve all three at once, and once it&#8217;s running, it becomes the natural home for metrics from everything else in the homelab too — not just OPNsense.</p>



<h2 class="wp-block-heading">The architecture, in one sentence</h2>



<p class="wp-block-paragraph">OPNsense runs a Telegraf agent that collects local metrics and writes them, over the network, into InfluxDB; Grafana then queries InfluxDB and renders the result as dashboards. Three distinct jobs, three distinct tools, none of them doing more than one thing.</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-1559744eb2fa9a9dec42dc7a0cd6eb07"><code>OPNsense
  │  os-telegraf plugin (collection agent)
  │  inputs: cpu, mem, disk, interface, netstat, ping, pf states, dhcp leases
  ▼
InfluxDB
  │  time series storage, retention policy, downsampling
  ▼
Grafana
  │  queries InfluxDB, renders panels, fires alerts
  ▼
You, looking at a dashboard instead of guessing</code></pre>



<p class="wp-block-paragraph">I run InfluxDB and Grafana as Docker containers on the same dmz-stack host that runs Caddy, Authelia, and the rest of the <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">DMZ services I&#8217;ve written about before</a> — not on OPNsense itself. OPNsense is a firewall first; I&#8217;d rather not load it up with a database and a dashboard server it doesn&#8217;t need to be running, and keeping the heavier services off the firewall box means a bad container doesn&#8217;t have any chance of taking the firewall down with it.</p>



<h2 class="wp-block-heading">Step 1 — Installing Telegraf on OPNsense</h2>



<p class="wp-block-paragraph">OPNsense doesn&#8217;t ship Telegraf by default, but it&#8217;s a one-click plugin install: <strong>System → Firmware → Plugins</strong>, search for <code>os-telegraf</code>, install it. Once installed, configuration lives under <strong>Services → Telegraf</strong>, split across a few tabs that map directly onto Telegraf&#8217;s own concepts — inputs, outputs, and general settings.</p>



<p class="wp-block-paragraph">This is worth pausing on, because it&#8217;s easy to install the plugin, leave every input checkbox at its default, point the output at InfluxDB, and call it done — and you&#8217;ll get <em>something</em> working, but it won&#8217;t be the metrics you actually want. Telegraf&#8217;s inputs aren&#8217;t &#8220;on&#8221; by default just because the plugin is installed; you choose what gets collected.</p>



<p class="wp-block-paragraph">The inputs I actually enable:</p>



<ul class="wp-block-list">
<li><strong>CPU</strong> — per-core usage, not just an aggregate average. Aggregate-only CPU graphs hide a single pegged core behind three idle ones.</li>



<li><strong>Mem</strong> — straightforward, but worth keeping even on a box with plenty of headroom, since a slow memory leak over weeks is exactly the kind of thing a dashboard catches and a quick glance at the live widget never will.</li>



<li><strong>Disk / DiskIO</strong> — especially relevant if you&#8217;re logging anything verbosely to local storage, which on a firewall, you usually are.</li>



<li><strong>Interface</strong> — per-NIC bytes in/out, errors, drops. This is the single most useful input for a firewall specifically; throughput history per interface is the whole reason most people start this project in the first place.</li>



<li><strong>Netstat</strong> — connection counts, useful for spotting something hammering the box with new connections faster than normal.</li>



<li><strong>Ping</strong> — Telegraf can actively ping a target and record latency/loss itself, which is a nice complement to passive interface stats; I point this at my upstream gateway and at the Miami VPS from the <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">CGNAT tunnel post</a>, so a degrading tunnel shows up as a graph trending the wrong way, not just a missed handshake notification. </li>



<li><strong>pf states / firewall</strong> — OPNsense&#8217;s Telegraf plugin exposes pf state table size and related firewall internals, which matters more than it sounds like it should; a state table creeping toward its configured maximum is a real, dashboard-worthy warning sign, not just trivia.</li>
</ul>



<p class="wp-block-paragraph">I leave most of the more exotic inputs off. Telegraf&#8217;s input list is genuinely huge — it supports things this box will never need — and every enabled input is one more thing writing data, one more thing potentially misbehaving, one more thing to reason about later. Enable what you&#8217;ll actually look at.</p>



<h2 class="wp-block-heading">Step 2 — Pointing Telegraf at InfluxDB</h2>



<p class="wp-block-paragraph">This is the part where version matters more than the OPNsense UI makes obvious. InfluxDB 1.x and InfluxDB 2.x are different enough in their authentication and data model that the Telegraf output configuration looks meaningfully different depending on which one you&#8217;re running.</p>



<p class="wp-block-paragraph">If you&#8217;re on <strong>InfluxDB 1.x</strong> (database/retention-policy model, closer to classic SQL-ish thinking), the output block is the simpler of the two:</p>



<p class="wp-block-paragraph">toml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-430d715dd6625597c7bb8ddba0972f5e"><code>&#91;&#91;outputs.influxdb]]
  urls = &#91;"http://192.168.50.53:8086"]
  database = "opnsense"
  username = "telegraf"
  password = "REDACTED"</code></pre>



<p class="wp-block-paragraph">If you&#8217;re on <strong>InfluxDB 2.x</strong> (organization/bucket/token model), it&#8217;s a different output plugin entirely — <code>influxdb_v2</code>, not <code>influxdb</code> — and authentication is a token, not a username/password pair:</p>



<p class="wp-block-paragraph">toml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-935ea7c29715ee0975ae90b85a8e427c"><code>&#91;&#91;outputs.influxdb_v2]]
  urls = &#91;"http://192.168.50.53:8086"]
  token = "REDACTED"
  organization = "homelab"
  bucket = "opnsense"</code></pre>



<p class="wp-block-paragraph">Mixing these up is the single most common way to get a Telegraf service that starts cleanly, logs no errors, and silently writes nothing anywhere — because you&#8217;ve configured the v1 plugin against a v2 server (or vice versa), and the connection just fails quietly enough that it&#8217;s easy to miss for a while. If your Grafana dashboards are stubbornly empty and Telegraf&#8217;s own logs look unremarkable, this mismatch is the first thing I&#8217;d check.</p>



<p class="wp-block-paragraph">I run InfluxDB 2.x, mainly because it&#8217;s the actively developed line and the bucket/token model maps more cleanly onto &#8220;one bucket per logical source, one scoped token per writer&#8221; than the older database/user model did. A scoped write-only token for the OPNsense Telegraf agent, separate from whatever token Grafana uses to read, means a compromised OPNsense box can write garbage into one bucket but can&#8217;t touch anything else in the InfluxDB instance.</p>



<h2 class="wp-block-heading">Step 3 — Wiring Grafana to InfluxDB</h2>



<p class="wp-block-paragraph">Once data is actually landing in InfluxDB — worth confirming with <code>influx query</code> or the InfluxDB UI directly before touching Grafana at all, for the same reason I&#8217;d test any pipeline stage in isolation before chaining the next one onto it — adding it as a Grafana data source is the easy part: <strong>Connections → Data sources → Add data source → InfluxDB</strong>, point it at the same URL, hand it the read token (not the OPNsense write token), and pick the query language.</p>



<p class="wp-block-paragraph">This is the other place version matters: InfluxDB 1.x speaks <strong>InfluxQL</strong>, a SQL-like query language; InfluxDB 2.x&#8217;s native language is <strong>Flux</strong>, which is a genuinely different paradigm — pipeline-style, more like a functional data-processing language than a query language. Grafana supports both, but which one you&#8217;re writing depends entirely on which InfluxDB version is on the other end, and Flux in particular has a learning curve if you&#8217;ve never written it before. I&#8217;d budget more time for &#8220;learning to write a Flux query that actually does what I want&#8221; than for any other single step in this whole setup — it was, for me, the slowest part by a wide margin, slower than anything on the OPNsense or Telegraf side.</p>



<p class="wp-block-paragraph">A basic Flux query for, say, WAN interface throughput over the last six hours looks like this:</p>



<p class="wp-block-paragraph">flux</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-0437e4436cb37a07bb12df65057b6ddd"><code>from(bucket: "opnsense")
  |&gt; range(start: -6h)
  |&gt; filter(fn: (r) =&gt; r._measurement == "net")
  |&gt; filter(fn: (r) =&gt; r.interface == "igb0")
  |&gt; filter(fn: (r) =&gt; r._field == "bytes_recv" or r._field == "bytes_sent")
  |&gt; derivative(unit: 1s, nonNegative: true)</code></pre>



<p class="wp-block-paragraph">That <code>derivative()</code> call matters more than it looks like it should — Telegraf&#8217;s <code>net</code> input reports <em>cumulative</em> byte counters, not instantaneous throughput. Graph the raw counter and you get a relentlessly climbing line that tells you almost nothing useful at a glance; take the derivative and you get the actual bytes-per-second rate, which is the number you actually wanted on the dashboard in the first place. This tripped me up for longer than I&#8217;d like to admit — the data was &#8220;correct,&#8221; it just wasn&#8217;t shaped the way a useful graph needs it to be.</p>



<h2 class="wp-block-heading">Step 4 — Building dashboards that earn their keep</h2>



<p class="wp-block-paragraph">It&#8217;s tempting to build one enormous dashboard with every metric on it. Resist that. The dashboards that actually get looked at regularly, in my experience, are the narrow ones:</p>



<ul class="wp-block-list">
<li><strong>Firewall health</strong> — CPU, memory, pf state table usage, disk. The &#8220;is this box okay&#8221; dashboard, checked reflexively the way you&#8217;d check a car&#8217;s dashboard lights.</li>



<li><strong>Throughput</strong> — per-interface bytes in/out, derived to rate, split by interface (WAN, LAN, the <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">WireGuard tunnel interface</a> specifically gets its own panel, since tunnel throughput tells a different story than raw WAN throughput). </li>



<li><strong>Tunnel and ping health</strong> — the active ping checks against the Miami endpoint and upstream gateway, graphed as latency and packet loss over time, rather than just the binary &#8220;handshake or no handshake&#8221; signal a status page gives you.</li>
</ul>



<p class="wp-block-paragraph">Splitting things this way also makes Grafana&#8217;s alerting more useful, since an alert rule tied to a narrow, specific panel (&#8220;pf state table over 80% of max for 5 minutes&#8221;) is something you can actually reason about and act on, where an alert buried inside a sprawling everything-dashboard tends to get ignored the same way a cluttered notification feed does.</p>



<p class="wp-block-paragraph">There&#8217;s a fourth dashboard worth calling out separately: <strong>security signal</strong>. Once <a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">GeoIP blocking, CrowdSec, and the fail2ban-to-OPNsense sync</a> are all feeding aliases on the firewall, Telegraf&#8217;s pf-state and firewall inputs end up indirectly reflecting how much of that traffic is actually getting dropped at the edge — a rising baseline of blocked-packet counts over time is a much better &#8220;is the noise increasing&#8221; signal than scrolling through raw logs ever was. The same goes for <a href="https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/">the fail2ban export pipeline</a> — the size of that banlist file is itself a number worth graphing over time, since a sudden jump is exactly the kind of thing a dashboard catches faster than a person remembering to go check. </p>



<h2 class="wp-block-heading">What I&#8217;d tell past-me before starting this</h2>



<p class="wp-block-paragraph">A few things that would have saved real time:</p>



<ul class="wp-block-list">
<li><strong>Decide your InfluxDB version before writing a single Telegraf config line.</strong> Going back and forth between <code>influxdb</code> and <code>influxdb_v2</code> output plugins after the fact, because you started on one version and migrated, is more annoying than just picking deliberately up front.</li>



<li><strong>Enable inputs deliberately, not by leaving every default checked.</strong> A Telegraf agent dutifully collecting forty metrics you&#8217;ll never look at isn&#8217;t free — it&#8217;s still I/O, still storage, still cardinality in InfluxDB, for genuinely no benefit.</li>



<li><strong>Remember that counters need derivatives.</strong> Any &#8220;total bytes&#8221; or &#8220;total packets&#8221; style metric is almost always more useful as a rate than as a running total. If a graph looks like a staircase that only ever goes up, that&#8217;s usually the tell.</li>



<li><strong>Keep write and read tokens separate</strong>, the same network-of-least-privilege instinct that shows up everywhere else in this series — scoped API tokens for ACME DNS challenges, scoped tokens for OPNsense&#8217;s own REST API, and now scoped tokens for InfluxDB too. It&#8217;s the same lesson, applied again.</li>



<li><strong>Put Grafana behind Authelia.</strong> It&#8217;s a web UI with real operational visibility into your network — exactly the kind of thing that deserves the same <a href="https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/">Authelia SSO</a> and <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">real two-factor setup</a> as anything else in the DMZ, not a standalone admin/admin login sitting on its own. </li>
</ul>



<h2 class="wp-block-heading">Where this leaves things</h2>



<p class="wp-block-paragraph">A dashboard that actually has memory now. I can look at three weeks of WAN throughput, correlate a CPU spike with a specific timestamp instead of a vague memory of &#8220;it felt slow yesterday,&#8221; and get paged by Grafana on a real threshold instead of relying on a script that only checks one binary condition. The OPNsense web UI is still there for day-to-day configuration, but it&#8217;s no longer doing double duty as the only window into the firewall&#8217;s history — which, in hindsight, was always more than a single page of live widgets was ever going to handle well.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/watching-the-firewall-breathe-monitoring-opnsense-with-telegraf-influxdb-and-grafana/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Ditch Google: Self-Hosting SearXNG Behind Authelia for Desktop and Open WebUI</title>
		<link>https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/</link>
					<comments>https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Sat, 20 Jun 2026 16:13:11 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[authelia]]></category>
		<category><![CDATA[caddy]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Networking]]></category>
		<category><![CDATA[Open WebUI]]></category>
		<category><![CDATA[Privacy]]></category>
		<category><![CDATA[Reverse Proxy]]></category>
		<category><![CDATA[SearXNG]]></category>
		<category><![CDATA[Self-Hosting]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2223</guid>

					<description><![CDATA[Every search query is a small confession. Here's how I took mine back — running SearXNG as a self-hosted metasearch engine, locking it down with Authelia and real 2FA, and wiring it in as the default search for both my desktop browser and my local LLM stack.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph">Search engines know too much about you. Every query, every click, every &#8220;how do I fix this embarrassing error&#8221; 2am search — logged, profiled, monetized. If you&#8217;re already running a homelab with a reverse proxy and an auth layer, there&#8217;s no good reason to keep feeding that machine. Run your own.</p>



<p class="wp-block-paragraph">This is the build: SearXNG as a self-hosted metasearch engine, Authelia sitting in front of it for real authentication, Caddy doing the reverse-proxy handoff, and the whole thing wired into both your desktop browser and Open WebUI as the default search backend. No third-party API keys, no query logs leaving your network, no ads.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The architecture</h2>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-f8efe48c124b81ef0708f3d658982181"><code>Browser / Open WebUI
        │
        ▼
   Cloudflare (DNS + proxy, optional)
        │
        ▼
   OPNsense / HAProxy (TLS termination)
        │
        ▼
      Caddy (reverse proxy, forward-auth)
       │         │
       ▼         ▼
   Authelia   SearXNG
   (auth)    (search engine)</code></pre>



<p class="wp-block-paragraph">Caddy fronts everything and asks Authelia &#8220;is this request authenticated?&#8221; before forwarding to SearXNG. Browse to the search domain, get bounced to a login page with 2FA. Once authenticated, the session cookie carries you through.</p>



<p class="wp-block-paragraph">If the OPNsense/HAProxy layer in that diagram looks unfamiliar, <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">Beyond the Tunnel</a> covers exactly this piece — HAProxy, ACME, and VLAN isolation sitting behind a WireGuard tunnel, which is what actually gets traffic from &#8220;the internet&#8221; down to Caddy in the first place. And if you&#8217;re starting from zero on getting traffic in at all — no public IP, ISP behind CGNAT — <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">Bypassing CGNAT with an OPNsense WireGuard Tunnel</a> is the prerequisite underneath that. </p>



<p class="wp-block-paragraph">The one wrinkle: Open WebUI&#8217;s web-search feature calls SearXNG&#8217;s JSON API directly, with no human in the loop to click through a login screen. We&#8217;ll handle that with a network-scoped bypass rule rather than leaving the whole instance open.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 1 — Add SearXNG to your Docker Compose stack</h2>



<p class="wp-block-paragraph">If you&#8217;ve already got Caddy and Authelia running together — covered in <a href="https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/">One Login to Rule Them All</a>, where the same Caddy-fronts-Authelia pattern was first wired up for Open WebUI and Nextcloud — this is just one more service in the same file. </p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-d910283eee5a47043bfc55d61b881460"><code>services:
  searxng:
    image: searxng/searxng:latest
    container_name: searxng
    restart: unless-stopped
    networks: &#91;dmz]
    environment:
      - SEARXNG_BASE_URL=https://search.yourdomain.com/
      - TZ=America/yourtimezone
    volumes:
      - ./searxng:/etc/searxng:rw
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETGID
      - SETUID
    logging:
      driver: "json-file"
      options:
        max-size: "1m"
        max-file: "1"</code></pre>



<p class="wp-block-paragraph">A few things worth calling out:</p>



<ul class="wp-block-list">
<li><strong><code>SEARXNG_BASE_URL</code></strong> is what SearXNG uses to generate every self-referencing link — the OpenSearch descriptor, redirects, form actions. Get this wrong and things will <em>mostly</em> work, then quietly break in specific places (more on that pain later).</li>



<li><strong>Dropped capabilities</strong> — SearXNG doesn&#8217;t need root-level Linux capabilities to run. Drop everything, add back only <code>CHOWN</code>/<code>SETGID</code>/<code>SETUID</code> for the entrypoint&#8217;s user-switching. Standard container hardening, costs nothing.</li>



<li><strong>Bounded logs</strong> — <code>max-size</code>/<code>max-file</code> keep the container&#8217;s logs from growing unbounded on a long-running homelab box nobody babysits daily.</li>
</ul>



<p class="wp-block-paragraph">Bring it up:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-6ef10db3616a70aaa1c9c2b8982f1039"><code>docker compose up -d searxng</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 2 — Wire Caddy to front it</h2>



<p class="wp-block-paragraph">Add a site block to your <code>Caddyfile</code>. If you&#8217;ve already got a forward-auth snippet defined for other internal services — this is the exact same <code>forward_auth</code> block from <a href="https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/">One Login to Rule Them All</a> — reuse it rather than writing a new one:</p>



<p class="wp-block-paragraph">caddyfile</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-dd092023801834715c76bc96a545ebc1"><code>(forward_auth_block) {
  forward_auth authelia:9091 {
    uri /api/verify?rd=https://auth.yourdomain.com
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
    header_up X-Forwarded-Proto https
    header_up X-Forwarded-Host {host}
  }
}

search.yourdomain.com:80 {
  import forward_auth_block
  log {
    output file /var/log/caddy/access.log
  }
  header Strict-Transport-Security "max-age=15552000; includeSubDomains"
  reverse_proxy searxng:8080 {
    header_up Host {host}
    header_up X-Forwarded-Proto https
  }
}</code></pre>



<p class="wp-block-paragraph">Notice this listens on port 80, not 443. If TLS is terminated upstream (OPNsense/HAProxy, Cloudflare) and forwards plain HTTP into your Docker host, Caddy doesn&#8217;t need to handle certificates at all — <code>auto_https off</code> at the top of the Caddyfile makes that explicit.</p>



<p class="wp-block-paragraph">Reload, don&#8217;t just restart, when testing Caddyfile edits live:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-19b9c78d8736925893dd3f2d7d05d8b7"><code>docker exec caddy caddy reload --config /etc/caddy/Caddyfile</code></pre>



<p class="wp-block-paragraph"><code>restart</code> runs the existing container with the config it started with. <code>reload</code> tells the running process to re-read the file. They are not the same thing, and conflating them cost me twenty minutes of confusion mid-build — a request kept slipping past auth entirely, and the running config simply hadn&#8217;t changed.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 3 — Authelia access control</h2>



<p class="wp-block-paragraph">In Authelia&#8217;s <code>configuration.yml</code>, add a rule for the new domain:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-4fb37e3fb51860927b8d1196b5d81d8b"><code>access_control:
  default_policy: deny
  rules:
    - domain: search.yourdomain.com
      policy: two_factor</code></pre>



<p class="wp-block-paragraph">Validate before you restart anything — it&#8217;s free insurance against a YAML typo taking down your auth layer:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-b8d459a013b0044e863799325c8a4796"><code>docker exec -it authelia authelia validate-config --config /config/configuration.yml
docker restart authelia</code></pre>



<p class="wp-block-paragraph">Authelia&#8217;s session store (SQLite or Redis, depending on your setup) persists across restarts, so this won&#8217;t force-logout anyone already authenticated elsewhere. If you haven&#8217;t set up 2FA itself yet — TOTP enrollment, real SMTP for notifications, and a hostname-vs-IP TLS gotcha that&#8217;s easy to walk straight into — <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">Locking Down Authelia</a> covers that foundational setup before any of this access-control layer matters. </p>



<p class="wp-block-paragraph">At this point, visiting <code>search.yourdomain.com</code> in a browser should redirect you to Authelia&#8217;s login page, prompt for 2FA, and land you on SearXNG after authenticating.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 4 — The Open WebUI problem, and the fix</h2>



<p class="wp-block-paragraph">Open WebUI&#8217;s web-search integration hits SearXNG&#8217;s API directly:</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-af84ceaea2114d7d32424de846a4cb1b"><code>GET https://search.yourdomain.com/search?format=json&amp;q=...</code></pre>



<p class="wp-block-paragraph">No browser, no cookie jar, no way to click through a login page. If your forward-auth rule applies universally, every one of these calls gets rejected — and crucially, an unauthenticated SearXNG instance with <strong>no</strong> access control is worse, since anyone on the internet could query it freely. If you&#8217;re exposing anything publicly at all, it&#8217;s worth having the broader edge-hardening picture in place — <a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">Hardening Your Homelab&#8217;s Public Edge</a> covers GeoIP blocking and CrowdSec, the layer that sits in front of everything else described here.</p>



<p class="wp-block-paragraph">The right middle ground is a <strong>network-scoped bypass</strong>: Authelia skips the auth challenge only for requests originating from inside your trusted network, not for the whole world. In <code>configuration.yml</code>:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-52b9c7686a87400e3bb363332c33fa9b"><code>access_control:
  rules:
    - domain: search.yourdomain.com
      resources:
        - "^/search.*$"
      networks:
        - 192.168.1.0/24    # your LAN/VLAN — wherever Open WebUI's host lives
      policy: bypass

    - domain: search.yourdomain.com
      policy: two_factor</code></pre>



<p class="wp-block-paragraph">The <code>networks</code> key scopes the bypass to source IP, not URL path. That distinction matters: a path-based bypass with no network restriction means <em>anyone</em> hitting that path skips auth, which defeats the point of putting Authelia in front of an internet-facing service in the first place. Scoping by network means the bypass only ever fires for traffic that&#8217;s already inside your perimeter. It&#8217;s the same network-scoped-trust principle behind the anti-lockout rule in <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">Never Lock Yourself Out</a> — trusting a request because of <em>where</em> it comes from, not just what it claims to be.</p>



<p class="wp-block-paragraph">In Open WebUI&#8217;s admin settings (Admin Panel → Settings → Web Search), point it at:</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-6ee2331c07f7eeffa34ed29441b68dca"><code>http:&#47;&#47;searxng:8080/search?format=json</code></pre>



<p class="wp-block-paragraph">— the internal Docker service name, not the public domain. Since Open WebUI and SearXNG share a Docker network, there&#8217;s no reason to route this traffic out through Cloudflare and back in. Faster, and one less thing that can break.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 5 — Set it as your desktop default</h2>



<p class="wp-block-paragraph">In Firefox: <code>about:preferences#search</code> → <strong>Search Shortcuts</strong> → <strong>Add</strong>.</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-e96b303fa4bad39f69a0f9c23344819a"><code>Name: SearXNG
Search string: https://search.yourdomain.com/search?q=%s</code></pre>



<p class="wp-block-paragraph">Select it under <strong>Default Search Engine</strong>, and every address-bar search now routes through your own instance, behind your own auth, with zero telemetry leaving your network beyond the search itself.</p>



<p class="wp-block-paragraph">One practical note: with full Authelia 2FA in front of this, a long session expiration is worth setting deliberately, or you&#8217;ll be re-authenticating constantly for what should be a casual address-bar search:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-ed185c713c9d96fae5801722ce508034"><code>session:
  cookies:
    - name: authelia_session
      domain: yourdomain.com
      authelia_url: "https://auth.yourdomain.com"
      expiration: 90d
      inactivity: 90d</code></pre>



<p class="wp-block-paragraph">90 days with 2FA on login is a reasonable balance — the login event is still strong, it just doesn&#8217;t need to repeat constantly for a service you check daily.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Things that bit me, in case they bite you</h2>



<p class="wp-block-paragraph"><strong>Environment variable changes don&#8217;t apply on <code>restart</code>.</strong> I changed <code>SEARXNG_BASE_URL</code> in the compose file and ran <code>docker compose restart searxng</code>. Nothing changed — the container kept the environment it was originally created with. <code>restart</code> cycles the existing container; it does not recreate it. Editing an env var in <code>docker-compose.yml</code> needs:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-6ef10db3616a70aaa1c9c2b8982f1039"><code>docker compose up -d searxng</code></pre>



<p class="wp-block-paragraph"><code>up -d</code> diffs the desired state against the running container and recreates it if anything — including environment — has changed. This is the single most common trap in this entire build, and I hit it twice in one session before it stuck.</p>



<p class="wp-block-paragraph"><strong>A renamed domain needs the cert checked, even if you&#8217;re sure it&#8217;s covered.</strong> If you&#8217;re running a wildcard cert (<code>*.yourdomain.com</code>), a new subdomain is automatically covered — but it&#8217;s worth confirming with a direct check rather than assuming:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-cd0ea18cbefc0a8384d7d83b055a1923"><code>echo | openssl s_client -connect search.yourdomain.com:443 -servername search.yourdomain.com <strong>2</strong>&gt;/dev/null \
  | openssl x509 -noout -subject -issuer</code></pre>



<p class="wp-block-paragraph">If the browser throws a cert error referencing a domain you didn&#8217;t type, don&#8217;t assume the server is wrong before checking — browser-side HSTS caching and stale security-policy state can produce a cert error pointing at a hostname that has nothing to do with what&#8217;s actually being served. Verify the live cert from the command line first; it settles the question in one command instead of an hour of guessing. This is the same family of hostname-vs-certificate confusion covered in more depth in <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">Locking Down Authelia</a> — worth a re-read if this bites you here too.</p>



<p class="wp-block-paragraph"><strong>Long-running containers can carry stale internal DNS.</strong> If a container&#8217;s been up for days while your host&#8217;s network configuration shifted underneath it, Docker&#8217;s embedded resolver inside that container can be working from outdated upstream DNS servers — it has no reason to refresh unless the container restarts. The symptom is oddly specific: internal Docker-network traffic works fine, but anything needing to resolve a real internet or LAN hostname fails outright. The fix is just a restart of that specific container, not a config change.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Where this leaves you</h2>



<p class="wp-block-paragraph">A search engine you fully control, parked behind real authentication, reachable the same way from a browser tab or from your local LLM&#8217;s tool-calling. No query logs handed to a third party, no API quotas, no ads. It took a Caddyfile block, one Authelia access-control rule, and a Docker Compose service — most of an evening was actually spent chasing two completely unrelated DNS issues that had nothing to do with SearXNG itself, which feels like the real lesson of running anything self-hosted: the new thing usually works fine. It&#8217;s the old thing sitting quietly next to it that breaks.</p>



<p class="wp-block-paragraph">If you&#8217;re banning the inevitable scanners and bots that find this instance anyway, <a href="https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/">From App Logs to Firewall Blocks</a> closes the loop on the network side — taking fail2ban&#8217;s detections and turning them into OPNsense-level blocks before a retry ever reaches Caddy at all. </p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">More from the homelab security series</h2>



<p class="wp-block-paragraph">This post sits at the end of a fairly continuous build, in case you&#8217;re reading it without the others:</p>



<ol class="wp-block-list">
<li><a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">Bypassing CGNAT with an OPNsense WireGuard Tunnel</a> — getting traffic in at all, with no public IP</li>



<li><a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">Beyond the Tunnel: HAProxy, ACME, and VLAN Isolation</a> — deciding what&#8217;s exposed once it&#8217;s in</li>



<li><a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">Hardening Your Homelab&#8217;s Public Edge</a> — GeoIP and CrowdSec at the perimeter</li>



<li><a href="https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/">One Login to Rule Them All</a> — Authelia as SSO for Open WebUI and Nextcloud</li>



<li><a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">Locking Down Authelia</a> — real 2FA, real SMTP, and the TLS trap</li>



<li><a href="https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/">From App Logs to Firewall Blocks</a> — fail2ban detections becoming OPNsense WAN blocks</li>



<li><a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">Never Lock Yourself Out</a> — the safety net underneath all of the above</li>



<li><strong>This post</strong> — SearXNG behind Authelia, the newest piece on top of that stack</li>
</ol>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Locking Down Authelia: TOTP, Real SMTP, and the Hostname-vs-IP TLS Trap</title>
		<link>https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/</link>
					<comments>https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Fri, 19 Jun 2026 12:16:49 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[authelia]]></category>
		<category><![CDATA[certificate-validation]]></category>
		<category><![CDATA[dmz-stack]]></category>
		<category><![CDATA[email]]></category>
		<category><![CDATA[Homelab]]></category>
		<category><![CDATA[identity-management]]></category>
		<category><![CDATA[network-security]]></category>
		<category><![CDATA[self-hosted]]></category>
		<category><![CDATA[smtp]]></category>
		<category><![CDATA[TLS]]></category>
		<category><![CDATA[totp]]></category>
		<category><![CDATA[two-factor-authentication]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2214</guid>

					<description><![CDATA[Claude responded: Post excerptPost excerptA login page that renders isn't the same thing as authentication that's actually done. Here's how I turned on real TOTP two-factor for Authelia, bootstrapped the very first registration without a working mailbox using a filesystem notifier trick, and then tripped over a TLS certificate trap that taught me hostnames aren't an implementation detail you can swap for a raw IP — not once certificate validation enters the picture.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">There&#8217;s a particular kind of false confidence that comes from standing up Authelia in front of your apps and seeing the login page render. It looks done. You type a username and password, you get challenged, you get in. Box checked, right?</p>



<p class="wp-block-paragraph">Except a username-and-password-only Authelia instance is just a fancier login form sitting in front of the exact same single point of failure every other login form has: whatever password you picked. The entire reason to run a centralized auth layer in the first place is to get to do hard things — like enforcing real two-factor authentication — once, centrally, instead of bolting it onto every app individually. If you skip that part, you&#8217;ve built infrastructure for a feature you&#8217;re not actually using.</p>



<p class="wp-block-paragraph">So this post is about closing that gap: turning on real TOTP-based two-factor, and wiring up the notification system Authelia needs to actually deliver codes, links, and alerts — which sounds like a footnote but turned into its own small adventure once I hit a TLS certificate quirk that&#8217;s worth flagging for anyone else who runs into it.</p>



<h2 class="wp-block-heading">Why two-factor isn&#8217;t optional once you centralize auth</h2>



<p class="wp-block-paragraph">Once Authelia is gating <a href="https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/">Nextcloud, Open WebUI</a>, and whatever else lives behind it, that one login becomes the single key to the entire DMZ. That&#8217;s the whole point — but it cuts both ways. A compromised password used to mean a compromised app. Now it means a compromised everything, all at once, through the front door I built specifically to make access more convenient. Centralizing authentication without centralizing strong authentication is just centralizing risk — and it&#8217;s the same reason the DMZ network itself is <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">VLAN-isolated from the rest of the homelab</a> in the first place: a single compromised layer shouldn&#8217;t mean a compromised everything else too. </p>



<p class="wp-block-paragraph">Authelia supports TOTP out of the box, and enabling it for an <code>access_control</code> policy is a one-line change in <code>configuration.yml</code>:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-67d97775ddc2813cacba26d1dff0d2d5"><code>access_control:
  rules:
    - domain: openwebui.yourdomain.com
      policy: two_factor
    - domain: nextcloud.yourdomain.com
      policy: two_factor</code></pre>



<p class="wp-block-paragraph">That&#8217;s it on the policy side — flip <code>one_factor</code> to <code>two_factor</code> for any domain you want gated, restart Authelia, and the access control layer now requires a second factor before granting a session. I deliberately left <code>auth.yourdomain.com</code> itself — Authelia&#8217;s own login portal — on a <code>bypass</code> policy, since that&#8217;s the page doing the authenticating in the first place; there&#8217;s nothing to gate behind itself.</p>



<p class="wp-block-paragraph">The part that actually takes thought isn&#8217;t the policy line, though — it&#8217;s how the person setting up TOTP for the first time actually receives the QR code and registration link. Authelia&#8217;s identity-verification flow for first-time 2FA registration works by emailing you a link to confirm it&#8217;s really you before letting you scan a QR code and bind an authenticator app. No working notification channel, no registration. The login page rendering correctly was never actually proof the auth system was complete; it was proof exactly one feature — first-factor password checking — was complete.</p>



<h2 class="wp-block-heading">Bootstrapping without email: the filesystem notifier</h2>



<p class="wp-block-paragraph">Here&#8217;s a chicken-and-egg problem I didn&#8217;t expect going in: you generally need a working SMTP setup to register 2FA, but I hadn&#8217;t actually wired up SMTP yet when I wanted to register my own TOTP for the first time. Authelia&#8217;s answer to this is a notifier mode that doesn&#8217;t try to send anything anywhere — it just writes the notification to a file on disk:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-c922a1bede1d89177f90905c1ebb5dc7"><code>notifier:
  filesystem:
    filename: /config/notification.txt</code></pre>



<p class="wp-block-paragraph">With this in place, instead of an email landing in an inbox, the identity-verification link gets written straight into a text file inside the Authelia container. Reading it is just:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-82cfe32d36fdef6c6b8e0cc2ef1bd198"><code>docker exec authelia cat /data/notification.txt</code></pre>



<p class="wp-block-paragraph">Copy the link out, paste it into a browser, and the registration flow proceeds exactly as if a real email had arrived. This is a genuinely useful bootstrapping trick worth knowing about independent of whatever your final SMTP setup ends up being — any time Authelia needs to send something and you don&#8217;t yet trust (or haven&#8217;t yet built) the real delivery path, dropping back to the filesystem notifier lets you keep moving without blocking on infrastructure that isn&#8217;t ready yet.</p>



<h2 class="wp-block-heading">Wiring up real SMTP, and the trap waiting inside it</h2>



<p class="wp-block-paragraph">Bootstrapping aside, a filesystem notifier obviously isn&#8217;t a long-term answer — it only works because I have shell access to the box, which defeats the entire purpose of a notification system the moment any other person needs to register their own 2FA. The real fix is pointing Authelia at an actual mailbox.</p>



<p class="wp-block-paragraph">The notifier config for SMTP looks innocent enough:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-093e6e690cd6a2a63c319c63e57963be"><code>notifier:
  smtp:
    address: 'submissions://mailserver.domain.com:465'
    username: 'mymail@domain.com'
    password: 'REDACTED'
    sender: "Authelia &lt;mymail@maildomain.com&gt;"
    identifier: 'yourdomain.com'
    startup_check_address: 'mymail@maildomain.com'
    tls:
      skip_verify: <strong>false</strong>
      minimum_version: 'TLS1.2'</code></pre>



<p class="wp-block-paragraph">The <code>submissions://</code> scheme tells Authelia this is an implicit-TLS SMTP submission connection on port 465, rather than the older plaintext-then-STARTTLS dance on port 587. That part&#8217;s fine and fairly standard.</p>



<p class="wp-block-paragraph">What actually tripped me up was something that, in hindsight, is an obvious consequence of how TLS works, but is exactly the kind of thing you don&#8217;t think about until it bites you: my first instinct was to point the <code>address</code> field at the mail server&#8217;s raw IP rather than its hostname, since I already had the IP handy and it felt like one less moving part — no DNS resolution step, no dependency on DNS staying healthy. Authelia started up, attempted the connection, and immediately failed with <code>x509: cannot validate certificate for &lt;IP&gt; because it doesn't contain any IP SANs</code>.</p>



<p class="wp-block-paragraph">That error message, once you actually read it instead of just reflexively googling it, explains the whole problem. The mail server&#8217;s TLS certificate was issued for the hostname <code>mailserver.domain.com</code>. TLS certificates assert an identity — &#8220;I am this specific name&#8221; — and the client connecting is supposed to check that the name it asked to connect to matches the name on the certificate it received back. When I connected by raw IP, there was no hostname in the conversation for the certificate to match against; the certificate says &#8220;I&#8217;m <code>mailserver.domain.com</code>,&#8221; and the connection was nominally to <code>203.0.113.45</code> or whatever the IP happened to be, and those two things have no relationship as far as the TLS handshake is concerned, unless the certificate specifically lists that IP as an additional valid identity (an IP SAN, which this one didn&#8217;t have, because almost no certificates bother to).</p>



<p class="wp-block-paragraph">This is a small, almost embarrassing thing to get wrong, and also a genuinely common trap, because using a raw IP feels like it should be strictly safer or simpler — fewer dependencies, no DNS lookup that could fail or get hijacked. For plaintext or non-validated connections, sure. The moment TLS certificate validation enters the picture, the hostname isn&#8217;t an implementation detail you can route around — it&#8217;s load-bearing. The certificate&#8217;s whole job is binding a cryptographic identity to a name, and if you don&#8217;t connect using that name, there&#8217;s no name for the certificate to authenticate against, full stop. (The same hostname-vs-certificate confusion shows up again, in a slightly different shape, when <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">verifying a wildcard cert covers a new subdomain</a> — worth a read if this kind of TLS mismatch interests you beyond just SMTP.) </p>



<p class="wp-block-paragraph">The fix was simply using the hostname Authelia was always meant to use:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-f45e993b6f7ef0e41d18633d64daddb7"><code>address: 'submissions://mailserver.domain.com:465'</code></pre>



<p class="wp-block-paragraph">Once that one piece changed, the connection succeeded cleanly, the startup check passed, and Authelia logged a clean <code>Startup complete</code> with no certificate complaints.</p>



<h2 class="wp-block-heading">Confirming it actually works, end to end</h2>



<p class="wp-block-paragraph">Configuration that merely starts without errors and configuration that actually works are two different claims, and I&#8217;ve been burned often enough by the first one masquerading as the second that I always insist on testing the real flow before calling something done. With SMTP wired up correctly, I went back through the TOTP registration process one more time, this time as a genuine end-to-end test rather than the filesystem-notifier bootstrap — triggered the identity verification flow, and watched a real email land in the actual inbox a few seconds later, with a working confirmation link.</p>



<p class="wp-block-paragraph">From there, registering TOTP itself is almost anticlimactic after all that setup: scan the QR code with an authenticator app, enter the six-digit code it generates to confirm the binding succeeded, and from that point forward, every login to a <code>two_factor</code>-policy domain prompts for that code after the password. The login page, from the outside, looks exactly like it did before any of this work — username, password, and now an extra six digits. The difference is everything behind that screen actually does what centralizing authentication was supposed to buy in the first place.</p>



<h2 class="wp-block-heading">The actual lesson here</h2>



<p class="wp-block-paragraph">None of this was hard, exactly — every individual piece was a short config block and a restart. What made it worth writing up is how each step quietly assumed the previous one actually worked, and how easy it would have been to stop at &#8220;the login page renders&#8221; and call it done. Two-factor without a working notification channel isn&#8217;t two-factor, it&#8217;s a setting nobody can finish enabling. A notification channel that starts without errors isn&#8217;t necessarily a notification channel that delivers anything, if a hostname-vs-IP TLS mismatch is quietly swallowing every send attempt. The only way to know any of it actually works is to walk the whole path a real, first-time user would take, end to end, and watch what actually lands in an actual inbox.</p>



<p class="wp-block-paragraph">This same 2FA-gated <code>access_control</code> pattern gets reused directly in the next build — <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">Ditch Google</a>, which gates a self-hosted SearXNG instance behind exactly this TOTP setup, plus a network-scoped bypass rule for Open WebUI&#8217;s API calls. And if locking yourself out of your own auth layer is a worry — it should be, once you&#8217;ve made Authelia the single key to everything — <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">Never Lock Yourself Out</a> covers the equivalent safety net on the OPNsense side. </p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Never Lock Yourself Out: Building a Self-Updating Anti-Lockout Rule for OPNsense</title>
		<link>https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/</link>
					<comments>https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 12:44:46 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[anti-lockout]]></category>
		<category><![CDATA[CGNAT]]></category>
		<category><![CDATA[cloudflare]]></category>
		<category><![CDATA[dynamic dns]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[Homelab]]></category>
		<category><![CDATA[Network Security]]></category>
		<category><![CDATA[OPNsense]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2211</guid>

					<description><![CDATA[Two firewalls, one home connection behind CGNAT, and a problem I kept putting off: how do you build an anti-lockout rule for a remote firewall when your home IP changes and isn't even fully under your control? Here's the self-updating fix.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">I run two OPNsense boxes. One sits at home, quietly routing my homelab traffic and occasionally getting yelled at when I push a config change that breaks DNS resolution for the fourth time this month. The other lives in a data center, fronting the actual production stack behind bizr.net, cookinary.com, and numiscurio.com — Apache, PHP-FPM, the works, with <a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">GeoIP blocking, CrowdSec, and a fail2ban-to-OPNsense sync</a> already doing the perimeter hardening on that side. </p>



<p class="wp-block-paragraph">Two firewalls, two very different jobs. But they share a problem that took me longer than I&#8217;d like to admit to solve properly: how do you restrict management access on the remote box to &#8220;just me, at home&#8221; when your home connection doesn&#8217;t have a fixed IP — and worse, doesn&#8217;t even have a <em>real</em> IP from the internet&#8217;s point of view?</p>



<p class="wp-block-paragraph">This post walks through what I built: a self-updating allowlist that tracks my home IP automatically, even through CGNAT, without me touching either firewall again. If you&#8217;re running a similar two-site setup — homelab plus colo, or homelab plus VPS — this is the kind of plumbing that&#8217;s easy to skip and annoying to retrofit later.</p>



<h2 class="wp-block-heading">Why Bother Locking This Down At All</h2>



<p class="wp-block-paragraph">The webhosting firewall has a management GUI and SSH, like every OPNsense box does. In an ideal world, neither would ever touch the WAN interface — everything would go over VPN, full stop. In practice, there are legitimate reasons to want a direct fallback path: VPN daemons crash, tunnels flap, and at 2am when something&#8217;s on fire, you want a door that doesn&#8217;t depend on the thing that might be broken.</p>



<p class="wp-block-paragraph">The compromise I landed on is the same one most of us reach for eventually: keep the door, but restrict who can knock. An allow rule scoped to &#8220;traffic from my home IP only&#8221; on top of a default-deny stance shrinks the attack surface from &#8220;anyone on the internet&#8221; to &#8220;anyone who happens to be sitting at my house, or who happens to share my ISP&#8217;s NAT pool at this exact moment&#8221; — which, as I&#8217;ll get into, isn&#8217;t quite as airtight as it sounds, but it&#8217;s a meaningful improvement over leaving the GUI wide open.</p>



<p class="wp-block-paragraph">This is also just good practice independent of the CGNAT wrinkle. If you&#8217;ve got a static IP at home, the IP-allowlist part of this is trivial — one alias, one rule, done. The interesting part of my setup, and the reason this post exists, is what happens when your home connection doesn&#8217;t give you a stable address to allowlist in the first place.</p>



<h2 class="wp-block-heading">The CGNAT Catch</h2>



<p class="wp-block-paragraph">My home ISP, like most residential providers these days, doesn&#8217;t hand out a unique public IPv4 address per customer. Instead, I sit behind Carrier-Grade NAT: a whole pool of subscribers shares a smaller set of public IPs, and the address my OPNsense&#8217;s WAN interface reports is a private one in the 100.64.0.0/10 range — not my actual internet-facing address at all.</p>



<p class="wp-block-paragraph">This is the same CGNAT wrinkle that made <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">getting a WireGuard tunnel through to this homelab in the first place</a> its own separate problem — there, the issue was inbound traffic having nowhere to land; here, it&#8217;s an outbound-facing allowlist having nothing stable to allowlist. Same root cause, two different symptoms.</p>



<p class="wp-block-paragraph">This matters a lot for dynamic DNS. The naive approach — point a DDNS client at your WAN interface and have it push that address to a DNS record — completely falls apart under CGNAT. You&#8217;d faithfully update your DNS record every few minutes with an address nobody on the internet can route to. The fix isn&#8217;t complicated once you know it&#8217;s needed, but it&#8217;s a &#8220;you don&#8217;t know what you don&#8217;t know&#8221; trap: the DDNS client has to ask an <em>external</em> service what your IP looks like from the outside, rather than trusting what your own interface thinks it is.</p>



<h2 class="wp-block-heading">Building a Self-Updating Allowlist</h2>



<p class="wp-block-paragraph">With that constraint in mind, the chain I built has three links:</p>



<ol class="wp-block-list">
<li>A DNS record on Cloudflare that always points at my real home IP.</li>



<li>A DDNS client on the home OPNsense box that keeps that record current — sourcing the IP from an external lookup service instead of the WAN interface, specifically because of the CGNAT issue above.</li>



<li>A firewall alias on the webhosting OPNsense box that resolves that same hostname on a timer and feeds the result straight into the pf table, with a rule referencing the alias instead of a hardcoded address.</li>
</ol>



<p class="wp-block-paragraph">The elegant part is that none of this needs glue scripts or cron jobs duct-taped together. OPNsense&#8217;s alias system natively understands FQDNs in Host-type aliases — it resolves them on a configurable interval (300 seconds by default) and keeps the underlying table in sync on its own. Once the chain is wired up, it just runs.</p>



<h3 class="wp-block-heading">Step 1 — A DNS Record and a Properly Scoped API Token</h3>



<p class="wp-block-paragraph">On the Cloudflare side, I added a plain A record — <code>home.yourdomain.com</code> — with the proxy turned off (grey cloud, &#8220;DNS only&#8221;). That last bit matters more than it looks: if the orange-cloud proxy is on, anything resolving that hostname gets back one of Cloudflare&#8217;s edge IPs instead of my home address, which would quietly break the whole scheme in a way that&#8217;s annoying to debug.</p>



<p class="wp-block-paragraph">For authentication, I skipped the Global API Key — it grants account-wide access, which is a lot of blast radius for something that only needs to touch one DNS record — in favor of a scoped API token limited to <code>Zone / DNS / Edit</code> permissions on the specific zone. Good habit generally: minimum necessary privilege, even for the small stuff.</p>



<h3 class="wp-block-heading">Step 2 — DDNS on the Home Firewall</h3>



<p class="wp-block-paragraph">OPNsense doesn&#8217;t ship dynamic DNS support out of the box; it&#8217;s a plugin (<code>os-ddclient</code>). Once installed, the configuration lives under Services → Dynamic DNS, and the account setup is where the CGNAT-specific decision actually gets made.</p>



<p class="wp-block-paragraph">The provider is Cloudflare, the token goes in the password field (with a slightly odd quirk — the username field has to literally contain the word &#8220;token&#8221; rather than an email address, which is how the plugin distinguishes API-token auth from the legacy global-key auth). The zone is the bare domain, the hostname is the full subdomain I want updated.</p>



<p class="wp-block-paragraph">The setting that actually solves the CGNAT problem is &#8220;Check IP method.&#8221; Left on &#8220;Interface,&#8221; the client would read the WAN interface&#8217;s address — the private CGNAT one — and dutifully push garbage upstream. Pointed instead at a web-based IP-checking service, the client asks an external host what address the request arrived from, which is the real, routable, internet-facing IP, regardless of how many layers of NAT sit between my router and the actual internet.</p>



<p class="wp-block-paragraph">It&#8217;s a small dropdown selection with a big practical consequence, and it&#8217;s the kind of detail that&#8217;s invisible until you&#8217;ve had a DDNS setup silently failing for a week because nobody told you the interface address wasn&#8217;t the address that mattered.</p>



<h3 class="wp-block-heading">Step 3 — Alias and Rule on the Webhosting Firewall</h3>



<p class="wp-block-paragraph">On the colo box, the configuration is almost anticlimactic by comparison. A new Host-type alias with the DDNS hostname as its content, and OPNsense takes care of the rest — resolving it on its own schedule and populating the pf table transparently. Any rule referencing that alias automatically tracks whatever the hostname currently resolves to.</p>



<p class="wp-block-paragraph">The rule itself is a single allow entry, sourced from the alias, scoped to the specific port the management interface listens on, sitting above the default-deny rule that covers everything else. From the rule&#8217;s perspective, it looks exactly like a static IP allowlist. The dynamic part is entirely hidden inside the alias. (This same idea — trusting a request because of where it&#8217;s coming from, rather than what it claims to be — is exactly what the <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">network-scoped Authelia bypass rule for Open WebUI&#8217;s search calls</a> does at the application layer instead of the firewall layer.) </p>



<h2 class="wp-block-heading">What Could Still Go Wrong</h2>



<p class="wp-block-paragraph">I want to be honest about the limits of this setup, because IP-based access control always sounds more solid than it actually is.</p>



<p class="wp-block-paragraph">First, there&#8217;s a convergence gap. If my home IP changes, there&#8217;s a window — typically a few minutes — between the DDNS client noticing and pushing the update, and the remote firewall&#8217;s alias re-resolving the hostname. During that window, the old IP is still allowed and the new one isn&#8217;t yet. If the only path into the box were this rule, a badly timed IP rotation could lock me out at the worst possible moment.</p>



<p class="wp-block-paragraph">Second, and more fundamentally: CGNAT means the public IP I&#8217;m allowlisting isn&#8217;t uniquely mine. It&#8217;s shared across a pool of subscribers on the same ISP infrastructure. Anyone else sitting behind the same NAT pool at the same moment would also satisfy the source-IP check. That doesn&#8217;t make the rule useless — it still meaningfully narrows things down from &#8220;the entire internet&#8221; to &#8220;people on my ISP&#8217;s network right now&#8221; — but it&#8217;s not the same security guarantee a unique static IP would give you, and I don&#8217;t want to oversell it as one.</p>



<p class="wp-block-paragraph">Because of both of these, this rule isn&#8217;t my primary path into the box. I already run <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">a WireGuard tunnel between the two sites</a>, with <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">HAProxy, ACME, and VLAN isolation</a> deciding what&#8217;s actually reachable on the other end of it, and that&#8217;s where day-to-day management traffic goes. This dynamic allowlist exists as the fallback — the door I can still use if the tunnel itself is down — not as the front line. And I&#8217;ve kept a separate, non-network access path (console/out-of-band) available too, specifically so that a mistimed IP change during a real outage can&#8217;t compound into a full lockout.</p>



<h2 class="wp-block-heading">The Bigger Lesson</h2>



<p class="wp-block-paragraph">None of this is exotic infrastructure — a DNS record, a small plugin, two firewall objects. But it&#8217;s a good small-scale example of something that shows up constantly in the day job too: a control that looks complete in isolation often has a hidden dependency that only surfaces under specific conditions. &#8220;Just allowlist the IP&#8221; is a perfectly fine instruction right up until someone&#8217;s home connection sits behind carrier-grade NAT, at which point the naive implementation fails silently instead of loudly.</p>



<p class="wp-block-paragraph">The fix, in both the homelab and the enterprise version of this problem, is the same: don&#8217;t treat any single control as sufficient on its own. Layer the dynamic allowlist behind the VPN, keep an out-of-band path alive, and build in the assumption that any one piece — DNS propagation, a NAT pool reassignment, a tunnel daemon crashing — will eventually fail at an inconvenient time. The goal isn&#8217;t a system that never breaks. It&#8217;s one where a single break doesn&#8217;t turn into a 2am lockout. The same defense-in-depth instinct shows up again later in <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">locking down Authelia&#8217;s own 2FA setup</a> — never trusting that one layer rendering correctly means the whole chain behind it actually works. </p>



<p class="wp-block-paragraph">That&#8217;s the version of &#8220;secure by default&#8221; I actually trust — not because it&#8217;s clever, but because it assumes its own cleverness will eventually be wrong about something.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>From App Logs to Firewall Blocks: Building a fail2ban-to-OPNsense Ban Pipeline</title>
		<link>https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/</link>
					<comments>https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 11:45:59 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[automation]]></category>
		<category><![CDATA[caddy]]></category>
		<category><![CDATA[dmz-stack]]></category>
		<category><![CDATA[Fail2Ban]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[Homelab]]></category>
		<category><![CDATA[intrusion-prevention]]></category>
		<category><![CDATA[network-security]]></category>
		<category><![CDATA[OPNsense]]></category>
		<category><![CDATA[self-hosted]]></category>
		<category><![CDATA[WireGuard]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2208</guid>

					<description><![CDATA[fail2ban catches the bad guys, but if it's only banning them at the backend, every attacker still gets a free trip through OPNsense and HAProxy before being told no. Here's how to close that gap: a script that exports every jail's banned IPs into a flat file, a tightly locked-down Caddy route to serve it, and an OPNsense URL Table alias that turns it into a live WAN block rule — so the next retry never makes it past the front gate.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><em>art of the ongoing series on hardening the DMZ stack. This one assumes you already have fail2ban watching your app logs and banning offenders locally — the question this post answers is: why stop at the app server when you could stop them at the front gate?</em></p>



<p class="wp-block-paragraph">For a long time, my fail2ban setup did exactly what fail2ban is supposed to do, and I was perfectly happy with it. Watch the logs, count the failures, ban the IP at the host firewall level via nftables. WordPress brute-forcers, badbots scraping for <code>wp-login.php</code>, the usual cast of characters — all of them got banned, all on the backend server itself.</p>



<p class="wp-block-paragraph">The problem with that picture only became obvious once I actually thought about where the ban was happening. My backend, the box running Apache and ISPConfig and all the actual application logic, sits behind <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">OPNsense and HAProxy</a>, with <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">a WireGuard tunnel</a> carrying the traffic between the two boxes in the first place. Every single request that gets banned by fail2ban has already traveled all the way from the internet, through OPNsense, through HAProxy, before fail2ban even gets a chance to say no. The ban was real, but it was the last possible line of defense, not the first. Every blocked request still cost me a hop through the entire stack before being rejected.</p>



<p class="wp-block-paragraph">That&#8217;s the itch this post scratches: take the bans fail2ban is already correctly deciding on, and push them out to the actual edge — OPNsense itself — so the next attempt from that IP never even reaches HAProxy, let alone the backend. The attacker gets stopped at the door instead of in the living room.</p>



<h2 class="wp-block-heading">Why this isn&#8217;t a five-minute job</h2>



<p class="wp-block-paragraph">The annoying part of this problem is that fail2ban and OPNsense don&#8217;t speak the same language. fail2ban lives on a Linux box and bans IPs using nftables or iptables rules, scoped entirely to that machine. OPNsense is a separate firewall appliance running pf, with its own rule engine and its own way of thinking about IP lists, called aliases. There&#8217;s no built-in bridge between the two. fail2ban has no idea OPNsense exists, and OPNsense has no idea fail2ban exists. If you want one to inform the other, you have to build that bridge yourself.</p>



<p class="wp-block-paragraph">OPNsense, fortunately, gives you a reasonable on-ramp: a <strong>URL Table (IPs)</strong> alias type, found under <strong>Firewall → Aliases</strong>. Instead of manually typing a static list of IPs into an alias, you point this alias type at a URL, and OPNsense periodically fetches that URL, expecting a plain text file with one IP per line, and uses the contents as a dynamically updating block list. That&#8217;s the missing piece — if I can get fail2ban to maintain a text file with all its currently-banned IPs, and serve that file somewhere OPNsense can reach, the rest is just OPNsense doing what it already knows how to do.</p>



<p class="wp-block-paragraph">So the pipeline has three parts: a script that asks fail2ban &#8220;who&#8217;s currently banned, across every jail you&#8217;re running&#8221; and writes that to a file; a way to serve that file so OPNsense can fetch it; and an OPNsense-side alias and firewall rule that actually uses the list to block traffic at the WAN edge.</p>



<h2 class="wp-block-heading">Part one: asking fail2ban what it knows</h2>



<p class="wp-block-paragraph">fail2ban already tracks every jail&#8217;s banned IPs internally — you can query any individual jail with <code>fail2ban-client status &lt;jailname&gt;</code>, and it&#8217;ll show you a <code>Banned IP list</code> line. The annoying bit is that if you&#8217;re running several jails (and most reasonably hardened setups are — separate jails for WordPress login attempts, badbots, 404 floods, and so on), you need to loop over all of them and merge the results, deduplicated, into one list.</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code"><code><strong>#!/bin/bash</strong>
OUTPUT_FILE="/home/user/dmz-stack/caddy/banlist/banned.txt"
TMP_FILE="$(mktemp)"

for jail in $(fail2ban-client status | grep "Jail list" | sed 's/^.*://;s/,//g'); do
    fail2ban-client status "$jail" | grep "Banned IP list" | sed 's/^.*://' | tr ' ' '\n'
done | grep -v '^$' | sort -u &gt; "$TMP_FILE"

mv "$TMP_FILE" "$OUTPUT_FILE"
chmod 644 "$OUTPUT_FILE"</code></pre>



<p class="wp-block-paragraph">This is deliberately unfancy. <code>fail2ban-client status</code> with no jail name lists every active jail; the loop asks each one for its banned IPs, strips the label text fail2ban prepends, and splits the space-separated list onto individual lines. Sorting and deduplicating means it doesn&#8217;t matter if the same IP shows up in two jails — it lands in the output file exactly once. The <code>mktemp</code>-then-<code>mv</code> pattern matters more than it looks like it should: writing directly to the final file risks OPNsense fetching a half-written file mid-update, while writing to a temp file and atomically moving it into place guarantees whoever reads <code>banned.txt</code> always sees a complete, consistent snapshot.</p>



<p class="wp-block-paragraph">Drop this in <code>/usr/local/bin/export-fail2ban-bans.sh</code>, make it executable, and you&#8217;ve got the first link in the chain. Run it manually once and <code>cat</code> the output file to sanity-check it&#8217;s actually populated with real-looking IPs before moving on.</p>



<h2 class="wp-block-heading">Part two: serving the file without opening a new can of worms</h2>



<p class="wp-block-paragraph">Now OPNsense needs to fetch this file over HTTP. The lazy option is to drop it somewhere your existing web server already serves — but I didn&#8217;t want a list of banned IPs sitting in a publicly browsable directory, even an obscure one, so I gave it its own dedicated, narrowly scoped route instead.</p>



<p class="wp-block-paragraph">Since I already run Caddy as my reverse proxy in front of everything else, adding one more tightly restricted route was the path of least resistance:</p>



<p class="wp-block-paragraph">caddyfile</p>



<pre class="wp-block-code"><code>:8090 {
  @opnsense remote_ip 172.18.32.1
  handle @opnsense {
    root * /srv/banlist
    file_server
  }
  handle {
    abort
  }
}</code></pre>



<p class="wp-block-paragraph">That <code>remote_ip</code> matcher is doing the actual security work here — only requests originating from OPNsense&#8217;s own WireGuard IP get served anything; everyone else hits <code>abort</code>, which just drops the connection outright rather than politely saying &#8220;no&#8221; with an HTTP error (politely saying no still tells a port-scanner something is listening). Putting this on its own port, <code>8090</code>, separate from your normal <code>80</code>/<code>443</code> traffic, also means a casual look at access logs for your real sites never gets cluttered with OPNsense&#8217;s periodic polling requests. Mount the directory containing <code>banned.txt</code> into the container, expose the port in your compose file, and this half is done.</p>



<h2 class="wp-block-heading">Part three: getting OPNsense to actually act on it</h2>



<p class="wp-block-paragraph">Inside OPNsense&#8217;s web UI, go to <strong>Firewall → Aliases</strong> and create a new alias. Set the type to <strong>URL Table (IPs)</strong>, point the content field at the internal address of wherever you&#8217;re serving the file from — in my case <code>http://172.18.32.11:8090/banned.txt</code>, reachable over WireGuard — and set a refresh frequency. I run mine on a 5-minute cadence, which, combined with fail2ban&#8217;s own cron job re-running the export script every five minutes too, means worst-case latency between a ban happening and OPNsense enforcing it at the edge is comfortably under ten minutes. That&#8217;s not instant, but it&#8217;s a different category of protection than &#8220;eventually gets blocked after wasting a hop through my whole stack every single time.&#8221;</p>



<p class="wp-block-paragraph">Once the alias exists and OPNsense has successfully fetched it at least once (you can confirm this under the alias&#8217;s own details view, which shows the current resolved IP count), the last step is actually using it. Go to <strong>Firewall → Rules → WAN</strong>, add a new rule with action <strong>Block</strong>, source set to your new alias, and — this part actually matters — drag the rule to the very top of the WAN rule list. Firewall rules are evaluated top-to-bottom, first match wins, and if some broader &#8220;allow&#8221; rule sits above your block rule, the block never gets a chance to fire.</p>



<h2 class="wp-block-heading">Testing it without waiting for a real attacker</h2>



<p class="wp-block-paragraph">I didn&#8217;t want to wait around for an actual bot to get banned just to confirm the whole pipeline worked end to end, so I manually banned a couple of made-up test IPs directly through fail2ban (<code>fail2ban-client set &lt;jail&gt; banip 203.0.113.99</code>), ran the export script by hand, and watched the alias refresh in the OPNsense UI to confirm the count ticked up and the test IP appeared in the resolved list. Unbanning the same way and re-running the export confirmed it cleared correctly too. This kind of synthetic round-trip test is worth doing deliberately rather than trusting the pipeline blind — it&#8217;s much easier to debug a test IP that you know should or shouldn&#8217;t be there than to debug a real attacker&#8217;s IP weeks later while also wondering whether the pipeline ever worked at all.</p>



<h2 class="wp-block-heading">What this actually buys you</h2>



<p class="wp-block-paragraph">It&#8217;s tempting to think of this as a marginal optimization — fail2ban was already blocking these IPs, so who cares if the block happens one hop earlier? But the difference compounds in ways that aren&#8217;t obvious until you&#8217;ve lived with it. Every blocked request that used to traverse OPNsense, HAProxy, and reach the application layer before being rejected was still consuming connection slots, CPU cycles, and log lines at every layer along the way. Pushing the block to the WAN edge means a banned attacker&#8217;s retry traffic gets silently dropped before it ever touches HAProxy&#8217;s connection table, which matters more than you&#8217;d expect once you&#8217;re also running Suricata and CrowdSec at that same edge — fewer packets reaching the inspection layers means cleaner signal in those tools too, since they&#8217;re not wading through traffic that&#8217;s already a known, settled case. <a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">Hardening Your Homelab&#8217;s Public Edge</a> covers that GeoIP/CrowdSec layer in full — worth noting that post describes a <em>different</em> fail2ban-to-OPNsense sync method (a direct API push from fail2ban itself) than the pull-based, flat-file approach built here, so treat them as two alternative implementations rather than the same pipeline described twice. </p>



<p class="wp-block-paragraph">There&#8217;s also a quieter benefit: once this pipeline exists, it becomes the natural place to plug in anything else that produces a list of bad IPs. CrowdSec&#8217;s own community blocklist already feeds OPNsense through a similar mechanism; Suricata&#8217;s alert-driven blocks could, in principle, feed the same kind of alias. The pattern — local detection, exported as a flat list, consumed by the edge firewall as a dynamic alias — turns out to be a reusable shape for tying together security tools that were never designed to talk to each other directly. fail2ban doesn&#8217;t need to know OPNsense exists, and OPNsense doesn&#8217;t need to know fail2ban exists. They just both need to agree on the shape of a text file, and that turns out to be enough.</p>



<p class="wp-block-paragraph">This same &#8220;trust based on network origin, not credentials&#8221; idea reappears in two later posts: the <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">network-scoped Authelia bypass</a> for Open WebUI&#8217;s search calls, and the <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">self-updating anti-lockout alias</a> for remote firewall management — both are the same alias-driven, dynamically-resolved trust pattern used here, just applied to allowing traffic instead of blocking it.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>One Login to Rule Them All: Wiring Authelia as SSO for Open WebUI and Nextcloud</title>
		<link>https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/</link>
					<comments>https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 11:30:17 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[authelia]]></category>
		<category><![CDATA[caddy]]></category>
		<category><![CDATA[dmz-stack]]></category>
		<category><![CDATA[forward-auth]]></category>
		<category><![CDATA[Homelab]]></category>
		<category><![CDATA[identity-management]]></category>
		<category><![CDATA[nextcloud]]></category>
		<category><![CDATA[oidc]]></category>
		<category><![CDATA[open-webui]]></category>
		<category><![CDATA[reverse-proxy]]></category>
		<category><![CDATA[self-hosted]]></category>
		<category><![CDATA[sso]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2204</guid>

					<description><![CDATA[Authelia gives you one login screen, but underneath it you're actually running two completely different integration patterns. Open WebUI never knows Authelia exists — Caddy vets every request at the edge and hands the app a verdict. Nextcloud's user_oidc plugin talks to Authelia directly, parsing signed tokens and making its own decisions about identity. Same login box, two very different trust relationships — and the difference matters more than it looks like it should.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Here&#8217;s the thing nobody tells you when you start adding Authelia to a homelab: &#8220;SSO&#8221; is not one integration pattern. It&#8217;s two, and they don&#8217;t look anything alike under the hood, even though from the browser they feel identical — type your username, tap your TOTP code, land on the app. Same login screen, same muscle memory, same warm feeling of having Solved Authentication Forever. (If you haven&#8217;t actually turned on real TOTP yet and are still running password-only, <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">Locking Down Authelia</a></p>



<p class="wp-block-paragraph">Under the hood, one of my apps has no idea Authelia exists. The other one talks to it directly, as a first-class citizen, using a protocol with its own handshake, its own tokens, its own opinions about what a &#8220;user&#8221; even is. Get this distinction wrong — or worse, not realize it&#8217;s a distinction at all — and you&#8217;ll spend an evening debugging a problem that looks like a bug but is actually a category error. I know this because I did exactly that a few weeks later, but that&#8217;s a story for a different post.</p>



<p class="wp-block-paragraph">For today: how do you actually wire Authelia in front of Open WebUI and Nextcloud, and why are these two setups fundamentally different shapes, not just different flavors of the same thing?</p>



<h2 class="wp-block-heading">The app that doesn&#8217;t know it&#8217;s protected</h2>



<p class="wp-block-paragraph">Open WebUI, like a lot of self-hosted tools, was never built with an external identity provider in mind. It has its own login form, its own user table, its own idea of &#8220;logged in.&#8221; If I want Authelia sitting in front of it, I&#8217;m not integrating with Open WebUI&#8217;s authentication — I&#8217;m overriding it from the outside, before any of its own login logic ever runs.</p>



<p class="wp-block-paragraph">This is forward-auth, and the mental model is simpler than the name suggests: Caddy intercepts every request to <code>openwebui.yourdomain.com</code> and, before it reverse-proxies anything to Open WebUI, makes a side-channel call to Authelia asking one question — &#8220;is this person allowed in?&#8221; If Authelia says yes, Caddy lets the request through to Open WebUI exactly as if nothing happened. If Authelia says no, the visitor gets redirected to a login page, and Open WebUI never even sees the request.</p>



<p class="wp-block-paragraph">The forward-auth block in my Caddyfile looks roughly like this:</p>



<p class="wp-block-paragraph">caddyfile</p>



<pre class="wp-block-code"><code>(forward_auth_block) {
  forward_auth authelia:9091 {
    uri /api/verify?rd=https://authelia.yourdomain.com
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
  }
}

openwebui.yourdomain.com:80 {
  import forward_auth_block
  reverse_proxy openwebui:8080
}</code></pre>



<p class="wp-block-paragraph">Two things matter here that are easy to skim past. First, <code>copy_headers</code> — once Authelia approves the request, it hands back a set of headers identifying who just logged in, and Caddy stitches those onto the request before it reaches Open WebUI. Second, and this is the part that actually matters for day-to-day reliability: Open WebUI still has no idea any of this happened. It sees a request arrive, optionally with some <code>Remote-User</code>-style headers it may or may not even read, and that&#8217;s it. As far as Open WebUI&#8217;s own code is concerned, Authelia doesn&#8217;t exist.</p>



<p class="wp-block-paragraph">That&#8217;s the whole trick of forward-auth. It&#8217;s a bouncer standing in front of a door, not a doorman the building hired. The building&#8217;s own systems are completely unaware there&#8217;s anyone checking IDs outside. This is also exactly why it works for literally any app — Open WebUI, a dashboard, an internal wiki, whatever — regardless of whether that app has ever heard of OIDC, SAML, or any authentication standard at all. If it speaks HTTP, you can put a forward-auth gate in front of it. (This is the exact same <code>forward_auth_block</code> reused later, almost verbatim, to gate <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">a self-hosted SearXNG instance</a> behind Authelia too.)</p>



<p class="wp-block-paragraph">The tradeoff is just as structural. Because the protected app doesn&#8217;t know about Authelia, it also doesn&#8217;t <em>really</em> know who&#8217;s logged in beyond whatever you choose to pass through those headers. There&#8217;s no rich user object, no group claims baked into a token the app can inspect on its own, no session the app controls. Everything the app &#8220;knows&#8221; about the user is whatever you handed it in a header, and if that header mapping is wrong or missing, the app just falls back to whatever its own default behavior is — which, for Open WebUI, meant new accounts landing in a &#8220;pending&#8221; status that I had to manually promote the first time around. The app isn&#8217;t broken; it&#8217;s just genuinely in the dark about the bigger picture.</p>



<h2 class="wp-block-heading">The app that wants to know you personally</h2>



<p class="wp-block-paragraph">Nextcloud is a different animal entirely, because <code>user_oidc</code> isn&#8217;t a gate in front of Nextcloud — it&#8217;s a plugin running <em>inside</em> Nextcloud that speaks OpenID Connect directly to Authelia. There&#8217;s no bouncer here. Nextcloud itself walks up to Authelia, introduces itself with a client ID and secret, and asks for a proper, structured answer: who is this person, what&#8217;s their email, what groups are they in, and here&#8217;s a signed token to prove you&#8217;re not lying to me.</p>



<p class="wp-block-paragraph">This is OIDC done the way the spec actually intends it, and it shows in how much more Nextcloud knows once the dance is over. Setting it up means configuring an actual client relationship between the two systems, not just a header-passing convention:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code"><code>docker exec -u www-data nextcloud php occ user_oidc:provider authelia \
  --clientid="nextcloud" \
  --clientsecret="..." \
  --discoveryuri="https://authelia.yourdomain.com/.well-known/openid-configuration"</code></pre>



<p class="wp-block-paragraph">That discovery URI is doing a lot of quiet work. Nextcloud fetches it once, learns where Authelia&#8217;s authorization endpoint, token endpoint, and signing keys all live, and from then on the two systems are talking a real protocol with real cryptographic guarantees — not just trusting whatever headers happen to show up. When I log in through Nextcloud&#8217;s web UI, I&#8217;m redirected to Authelia, I authenticate there (TOTP and all), and Authelia hands back a signed JWT containing claims: <code>email</code>, <code>groups</code>, <code>name</code>, <code>sub</code>. Nextcloud reads that token directly and uses it to provision or update my account.</p>



<p class="wp-block-paragraph">This is the crucial difference. Open WebUI never sees a token — it sees headers Caddy chose to forward. Nextcloud sees an actual cryptographically signed identity document and parses it itself. That gives Nextcloud a much richer, more reliable picture of who&#8217;s logging in — group membership for access control, email for notifications, display name for the UI — all sourced directly from the identity provider rather than relayed secondhand through a proxy&#8217;s header rewriting.</p>



<p class="wp-block-paragraph">It also means Nextcloud has opinions about <em>how</em> it builds a user from that token, and those opinions matter more than they look like they should. By default, <code>user_oidc</code> uses the <code>sub</code> claim — a stable, unique identifier Authelia generates — as the internal Nextcloud account ID. That&#8217;s the technically correct choice for uniqueness, and it&#8217;s also a UUID, not anything resembling a username. I didn&#8217;t think about this at all when I set it up, because why would I — the login worked, I could see my files, case closed.</p>



<p class="wp-block-paragraph">It came back to bite me weeks later in a completely different context (ask me about the desktop sync client some other time), but the shape of the problem is worth flagging here: with forward-auth, the protected app never had to make a decision about identity at all, because it was never given the opportunity. With native OIDC, the protected app is making real decisions — how to map claims to accounts, what becomes the username, whether to provision automatically or require a pre-existing account — and every one of those decisions is a place a default can surprise you.</p>



<h2 class="wp-block-heading">Same login screen, two different trust relationships</h2>



<p class="wp-block-paragraph">Step back far enough and the distinction is really about where the trust boundary sits. With forward-auth, the trust boundary is at the edge — Caddy and Authelia have a private conversation, and the app behind them is simply handed a verdict. The app trusts Caddy to have done its job; it has no independent way to verify anything, and it doesn&#8217;t need one, because it was never designed to.</p>



<p class="wp-block-paragraph">With native OIDC, the trust boundary moves inside the app itself. Nextcloud doesn&#8217;t trust Caddy&#8217;s opinion about who&#8217;s logged in — Caddy, in fact, is barely involved in the authentication conversation at all beyond passing bytes back and forth. Nextcloud independently verifies a cryptographic token against keys it fetched from Authelia&#8217;s discovery document. It&#8217;s a direct relationship between the two systems, with the reverse proxy reduced to plumbing.</p>



<p class="wp-block-paragraph">Neither pattern is strictly better — they solve different problems. Forward-auth is the right tool when you want to blanket-protect something that was never built with SSO in mind, and you&#8217;re willing to accept that the protected app will only ever know what you choose to tell it. Native OIDC is the right tool when the app actually needs to reason about identity — assign permissions by group, send mail to the right address, distinguish &#8220;logged in as Marco&#8221; from &#8220;logged in as some anonymous header value&#8221; — and you&#8217;re willing to take on the deeper integration work and the protocol-level decisions that come with it.</p>



<p class="wp-block-paragraph">What I&#8217;d tell past-me, a few weeks before the desktop sync client incident: when you wire up native OIDC, go look at exactly what claim becomes the account ID, on purpose, before you start using the account for anything real. It&#8217;s a five-minute check during setup. It&#8217;s a multi-hour debugging session if you skip it and only find out later, indirectly, through a symptom that looks like it&#8217;s about something else entirely.</p>



<p class="wp-block-paragraph">Same login screen. Two completely different conversations happening behind it. Worth knowing which one you&#8217;re having before you need to debug it.</p>



<p class="wp-block-paragraph">This same forward-auth-versus-native-protocol distinction is worth keeping in mind for <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">the network-scoped Authelia bypass</a> too — Open WebUI&#8217;s web-search calls to SearXNG hit an API directly, with no forward-auth bouncer able to intercept them the normal way, which is exactly why that post needed a different kind of trust rule instead.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>The Quiet Lie of the Performance Rating: When Reviews Serve the Budget, Not the People</title>
		<link>https://bizr.net/the-quiet-lie-of-the-performance-rating-when-reviews-serve-the-budget-not-the-people/</link>
					<comments>https://bizr.net/the-quiet-lie-of-the-performance-rating-when-reviews-serve-the-budget-not-the-people/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Tue, 26 May 2026 13:01:14 +0000</pubDate>
				<category><![CDATA[Leadership]]></category>
		<category><![CDATA[Calibration]]></category>
		<category><![CDATA[Compensation]]></category>
		<category><![CDATA[Honest Feedback]]></category>
		<category><![CDATA[HR]]></category>
		<category><![CDATA[Modern Leadership]]></category>
		<category><![CDATA[Performance Management]]></category>
		<category><![CDATA[Performance Reviews]]></category>
		<category><![CDATA[Workplace Culture]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2200</guid>

					<description><![CDATA[

The performance rating you receive each year is meant to reflect how you performed. In many organizations, it actually reflects how the company performed — and how much it can afford to pay out this cycle. A hard look at why ratings often serve the budget rather than the people, and what honest leaders can do about it.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Every year, in offices around the world, the same ritual plays out. Managers sit down with their teams, walk through carefully worded forms, and assign a rating — a number, a tier, a label. <em>Exceeds expectations. Meets expectations. Needs development.</em> The conversation is framed as feedback. The form is framed as fairness. The system is framed as performance management.</p>



<p class="wp-block-paragraph">And in many organizations, much of it is theater.</p>



<p class="wp-block-paragraph">Not because the managers are dishonest. Not because HR has bad intentions. But because the rating that gets written down at the end of the year is shaped by forces that have very little to do with how someone actually performed — and a great deal to do with the company&#8217;s financial targets, headcount plans, and budget for the next merit cycle. The result is a system that quietly trades its stated purpose (developing people) for an unstated one (managing cost).</p>



<p class="wp-block-paragraph">This post is about that gap. Why it exists, how it works, what it costs, and what honest alternatives look like.</p>



<p class="wp-block-paragraph">It&#8217;s an uncomfortable topic. It should be. The people on the receiving end of these ratings deserve a more honest conversation about what they actually represent.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Stated Purpose vs. the Real One</h3>



<p class="wp-block-paragraph">Officially, the performance review exists to:</p>



<ul class="wp-block-list">
<li>Give people structured feedback on their work.</li>



<li>Identify development needs and growth opportunities.</li>



<li>Inform decisions about pay, promotion, and progression.</li>



<li>Document performance for HR and legal purposes.</li>
</ul>



<p class="wp-block-paragraph">That&#8217;s the cover story, and most of it is even true — partially. The system <em>does</em> do those things, some of the time.</p>



<p class="wp-block-paragraph">But sitting underneath all of that is a different reality. In most large organizations, the performance review is also — and often primarily — <strong>a budget allocation mechanism dressed up as a meritocracy</strong>. The ratings determine the bonus pool distribution. They feed into the merit increase matrix. They justify (or block) promotions, which carry cost implications of their own. They feed into stack rankings, calibration sessions, and — in harder years — into the decisions about who stays and who goes.</p>



<p class="wp-block-paragraph">The moment a rating has a direct line to a financial outcome, it stops being a pure assessment of performance. It becomes a <em>negotiation</em> between three things: how someone actually performed, what the manager believes they deserve, and what the company can afford to pay out this year.</p>



<p class="wp-block-paragraph">Guess which one usually wins.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">How the Distortion Actually Happens</h3>



<p class="wp-block-paragraph">It doesn&#8217;t usually look like fraud. It looks like process. Here are the most common mechanisms by which performance ratings get quietly bent to fit financial targets — patterns documented across years of HR research and reported by managers in companies of every size.</p>



<h4 class="wp-block-heading">Forced Distributions and &#8220;Calibration&#8221;</h4>



<p class="wp-block-paragraph">Many organizations operate, formally or informally, on a forced distribution: a set percentage of employees must receive each rating. Maybe 10% top, 70% middle, 20% bottom — or some variation. The stated reason is &#8220;to avoid grade inflation.&#8221; The actual effect is to cap how many people can be rated as high performers, regardless of how many <em>actually</em> performed at that level.</p>



<p class="wp-block-paragraph">If your team had a great year and ten people genuinely deserved the top rating, the system doesn&#8217;t care. It will let through two or three. The rest will be told they &#8220;met expectations&#8221; — which, in the language of most companies, quietly means &#8220;didn&#8217;t quite get there.&#8221;</p>



<p class="wp-block-paragraph">Calibration sessions, where managers gather to &#8220;align ratings across the organization,&#8221; are where this often happens. The conversation is framed as fairness — and sometimes it is. But it&#8217;s also the room where a senior leader can say &#8220;we can&#8217;t have that many fours this year&#8221; and watch ratings get adjusted downward across teams that never compared notes.</p>



<h4 class="wp-block-heading">The Merit Budget Working Backwards</h4>



<p class="wp-block-paragraph">Here&#8217;s the mechanic that&#8217;s rarely said out loud but is almost universal: <strong>the merit increase budget is set before the ratings are finalized</strong>. Finance decides what the company can afford to spend on raises and bonuses for the year. That number gets divided across business units. Each unit gets a budget. And the ratings — which determine who gets what slice of that budget — must add up to something the budget can support.</p>



<p class="wp-block-paragraph">If finance says the total merit pool is 3%, then the ratings <em>must</em> distribute in a way that the average payout is roughly 3%. If too many people are rated as top performers, the math doesn&#8217;t work. So ratings get adjusted until the math does work. The performance assessment, in other words, is constrained by a financial envelope that was decided independently of how anyone actually performed.</p>



<p class="wp-block-paragraph">This is not a conspiracy. It&#8217;s just how most compensation systems are built. But it means the rating you receive is, in part, a function of how good or bad the company&#8217;s financial year was — not how good or bad <em>yours</em> was.</p>



<h4 class="wp-block-heading">The &#8220;Save the Rating for Next Year&#8221; Move</h4>



<p class="wp-block-paragraph">In a tight year, managers are often told — explicitly or implicitly — to hold back top ratings. The reasoning sounds reasonable: <em>don&#8217;t blow the budget now; we&#8217;ll recognize them next year</em>. The problem is that next year arrives with a new budget constraint, a new set of priorities, and often a new manager. The rating saved for later rarely materializes.</p>



<p class="wp-block-paragraph">Worse, the employee never knows. They were told they &#8220;met expectations,&#8221; and they walk away believing that&#8217;s an honest assessment of their year — when in fact their manager spent the calibration session arguing for a higher rating and lost.</p>



<h4 class="wp-block-heading">Ratings as Layoff Documentation</h4>



<p class="wp-block-paragraph">In organizations preparing for restructuring, performance ratings sometimes serve another function entirely: building a paper trail. If the company anticipates needing to reduce headcount, the ratings issued in the year before can be used to justify who&#8217;s let go. This is rarely explicit. But the people who get marked down in a &#8220;quiet&#8221; year are often the same ones who appear on a list six months later when &#8220;performance-based&#8221; reductions are announced.</p>



<p class="wp-block-paragraph">Anyone who has worked in HR or leadership long enough has seen this pattern. It&#8217;s one of the most corrosive uses of the performance review system — because it weaponizes a tool that employees were told existed to help them grow.</p>



<h4 class="wp-block-heading">The Promotion Cap</h4>



<p class="wp-block-paragraph">Promotions almost always carry a cost: higher salary, higher benefits, sometimes higher equity. So in organizations where the promotion budget is constrained, the rating that would justify a promotion — the top tier, usually — becomes scarce by design. Managers are told there&#8217;s a &#8220;limit&#8221; on how many promotions can happen this cycle. That limit translates directly into a limit on how many top ratings can be given out, regardless of who earned them.</p>



<p class="wp-block-paragraph">People who deserved promotion get told they&#8217;re &#8220;almost there&#8221; or &#8220;ready next cycle.&#8221; Often, they&#8217;re not wrong about being ready. They&#8217;re wrong about why the answer was no.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Why Smart Organizations Do This Anyway</h3>



<p class="wp-block-paragraph">It would be easy to frame this as villainous. It isn&#8217;t, mostly. The system exists for understandable reasons, and most of the people running it are doing their best inside a structure they didn&#8217;t design.</p>



<p class="wp-block-paragraph">The pressures driving rating distortion are real:</p>



<ul class="wp-block-list">
<li><strong>Cost predictability matters to a business.</strong> Investors, boards, and CFOs need to forecast personnel expense. A performance system that produces unconstrained ratings produces unconstrained costs.</li>



<li><strong>Pure meritocracy is harder than it sounds.</strong> If every manager rates their own team honestly, ratings drift upward over time — partly because managers genuinely want to advocate for their people, partly because nobody wants to deliver bad news. Some form of calibration is genuinely necessary.</li>



<li><strong>Legal and equity considerations require consistency.</strong> Wildly inconsistent ratings across managers create legal exposure and real fairness problems, especially when patterns correlate with protected characteristics.</li>



<li><strong>Limited resources are a real constraint.</strong> No company has an infinite bonus pool. Choices have to be made. The question is whether those choices are made honestly or hidden inside the language of performance.</li>
</ul>



<p class="wp-block-paragraph">The problem isn&#8217;t that financial constraints exist. The problem is that the <em>system pretends they don&#8217;t</em> — and asks employees to interpret their rating as a pure assessment of their work when it isn&#8217;t.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">What This Costs</h3>



<p class="wp-block-paragraph">The cost of dressing up budget decisions as performance assessments is significant, even if it doesn&#8217;t show up on any spreadsheet.</p>



<p class="wp-block-paragraph"><strong>Trust erodes.</strong> Employees aren&#8217;t naive. After a few cycles, most of them figure out that the rating doesn&#8217;t quite mean what HR says it means. The slogan becomes the joke. People stop taking the feedback seriously because they can sense it&#8217;s been pre-shaped by something other than feedback.</p>



<p class="wp-block-paragraph"><strong>High performers leave.</strong> The person who genuinely outperformed and got told they &#8220;met expectations&#8221; doesn&#8217;t argue. They update their resume. The market knows what they&#8217;re worth even if their employer pretends not to.</p>



<p class="wp-block-paragraph"><strong>Development conversations get hollow.</strong> When the rating is the headline, the conversation around it collapses into justification. The manager spends the meeting defending a number instead of having the harder, more useful conversation about what the person actually did well, where they&#8217;re struggling, and what they should work on next.</p>



<p class="wp-block-paragraph"><strong>Managers lose credibility.</strong> The people forced to deliver ratings they don&#8217;t believe in eventually stop being trusted by their teams. Their feedback becomes suspect. Their authority to coach diminishes. They become message-carriers for a system, not leaders of people.</p>



<p class="wp-block-paragraph"><strong>The wrong behaviors get reinforced.</strong> When ratings are tied to visible, measurable outputs (because those are easiest to defend in calibration), the work that <em>can&#8217;t</em> be neatly measured — mentoring others, fixing problems before they become visible, building culture, taking on unglamorous work — gets quietly punished. People learn to optimize for what gets rated, not for what matters.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">What Honest Alternatives Look Like</h3>



<p class="wp-block-paragraph">There&#8217;s no clean, scalable replacement for the performance review. Companies that have tried to abolish them entirely have mostly ended up reinventing them under different names. But there <em>are</em> honest improvements that move the system closer to its stated purpose. Some of them:</p>



<p class="wp-block-paragraph"><strong>Separate the conversations.</strong> Pay decisions and development feedback don&#8217;t have to live in the same meeting, on the same form, or even in the same quarter. When they&#8217;re fused, the development conversation always loses. Splitting them lets each one breathe — and lets feedback be feedback, not the preamble to a number.</p>



<p class="wp-block-paragraph"><strong>Be honest about the budget.</strong> Tell people the truth: &#8220;The merit pool this year is X percent. Ratings distribute within that envelope. Here&#8217;s how I argued for your slice of it.&#8221; This is uncomfortable. It&#8217;s also vastly more respectful than pretending the budget doesn&#8217;t exist.</p>



<p class="wp-block-paragraph"><strong>Drop the forced distribution where possible.</strong> Or at least, name it explicitly when it&#8217;s in play. The pretense that ratings are independent of distribution caps is one of the most corrosive parts of the system.</p>



<p class="wp-block-paragraph"><strong>Make calibration transparent.</strong> Tell people their rating was discussed in calibration, what was raised, and what was concluded. Managers should be willing to say &#8220;I argued for a higher rating; here&#8217;s why it didn&#8217;t go through.&#8221; It&#8217;s hard. It&#8217;s also how trust gets rebuilt.</p>



<p class="wp-block-paragraph"><strong>Decouple performance from pay where you can.</strong> Continuous feedback, manager discretion on smaller and more frequent recognition, and project-based bonuses can do more for motivation than the annual rating ever did — without distorting feedback into a budget mechanism.</p>



<p class="wp-block-paragraph"><strong>Train managers to actually give feedback.</strong> Most of what passes for performance management is just paperwork because the underlying skill — giving direct, specific, useful feedback in real time — is rare. Investing in that skill does more for actual performance than any ratings system.</p>



<p class="wp-block-paragraph"><strong>Audit for patterns.</strong> If certain groups consistently get rated lower, or certain managers consistently rate harder, that&#8217;s data worth taking seriously. The honest version of calibration uses this data to surface bias, not to hide it.</p>



<p class="wp-block-paragraph">None of these are silver bullets. The underlying tension — that companies need to manage cost while also developing people — won&#8217;t disappear. But the difference between an honest system and a dishonest one isn&#8217;t whether the tension exists. It&#8217;s whether the people inside the system are told the truth about it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">What Leaders Can Actually Do</h3>



<p class="wp-block-paragraph">If you&#8217;re a manager working inside a system you didn&#8217;t design — and most of us are — there are still things within your control. They matter more than the form on the screen.</p>



<p class="wp-block-paragraph"><strong>Tell your people the truth about the system.</strong> Don&#8217;t pretend the rating is a pure measurement. When you have to deliver a rating that doesn&#8217;t reflect what you believe about someone&#8217;s performance, say so. Quietly, privately, honestly. <em>&#8220;This rating doesn&#8217;t capture how I actually see your year. Here&#8217;s what I think you did well, and here&#8217;s why I couldn&#8217;t get the rating I wanted for you.&#8221;</em> That conversation costs you nothing and earns enormous trust.</p>



<p class="wp-block-paragraph"><strong>Fight in the room.</strong> Calibration is where the real decisions get made. Show up prepared. Make the case. Lose with grace when you have to, but never let a member of your team be downgraded without an argument. If you don&#8217;t fight for them in the room, you&#8217;re not really their manager — you&#8217;re an administrator.</p>



<p class="wp-block-paragraph"><strong>Give feedback all year.</strong> The annual review should never be the first time someone hears something important about their work. If it is, that&#8217;s a failure of management, not a feature of the system. Build a habit of small, frequent, honest conversations, and the annual rating becomes a footnote.</p>



<p class="wp-block-paragraph"><strong>Distinguish what you control from what you don&#8217;t.</strong> You probably don&#8217;t control the budget. You almost certainly don&#8217;t control the rating scale. But you do control how you talk to your people, how you advocate for them, and whether the relationship you build with them is honest. That&#8217;s the part that actually matters.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Bigger Picture</h3>



<p class="wp-block-paragraph">The performance review isn&#8217;t broken because the people inside it are bad. It&#8217;s broken because it&#8217;s been asked to do two incompatible jobs at the same time — develop people and allocate budget — and pretends those jobs are the same job.</p>



<p class="wp-block-paragraph">They&#8217;re not. And the longer organizations pretend otherwise, the more they erode the trust that makes any feedback system work in the first place.</p>



<p class="wp-block-paragraph">The leaders who navigate this best are the ones who refuse to pretend. They acknowledge the constraints, advocate fiercely for their people, give honest feedback all year long, and treat the annual rating as what it actually is: an imperfect output of a system shaped by forces well beyond performance. They don&#8217;t pretend the rating is the truth. They make sure their people hear the truth from them directly.</p>



<p class="wp-block-paragraph">That&#8217;s the part the form can&#8217;t do. That&#8217;s the part that&#8217;s still, and always will be, the job of a human leader.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/the-quiet-lie-of-the-performance-rating-when-reviews-serve-the-budget-not-the-people/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Human-Centric Leadership: Leading People in the Age of Automation</title>
		<link>https://bizr.net/human-centric-leadership-leading-people-in-the-age-of-automation/</link>
					<comments>https://bizr.net/human-centric-leadership-leading-people-in-the-age-of-automation/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Tue, 26 May 2026 12:11:29 +0000</pubDate>
				<category><![CDATA[Leadership]]></category>
		<category><![CDATA[Collaboration]]></category>
		<category><![CDATA[Creativity]]></category>
		<category><![CDATA[Emotional Intelligence]]></category>
		<category><![CDATA[Future of Work]]></category>
		<category><![CDATA[Human-Centric Leadership]]></category>
		<category><![CDATA[Leadership in the Age of AI]]></category>
		<category><![CDATA[Modern Leadership]]></category>
		<category><![CDATA[People-First]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2185</guid>

					<description><![CDATA[As AI absorbs the analytical and administrative core of knowledge work, a leader's primary value shifts to unlocking what only humans can do: creativity, collaboration, and emotional connection. Human-centric leadership isn't soft — it's the only kind of leadership that will still matter in ten years.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">For most of the last century, organizations were designed like machines. People were &#8220;resources.&#8221; Work was &#8220;input.&#8221; Performance was &#8220;output.&#8221; The whole vocabulary of management — human resources, headcount, workforce capacity, productivity per FTE — quietly treated employees as components in a system that produced results. It worked, more or less, when the work itself was repetitive, measurable, and analytical.</p>



<p class="wp-block-paragraph">That world is ending.</p>



<p class="wp-block-paragraph">Technology and AI are quietly absorbing the parts of a job that used to define competence: pulling reports, drafting emails, summarizing meetings, analyzing spreadsheets, writing first-draft code. The skills that used to make someone a &#8220;high performer&#8221; — speed, accuracy, technical fluency — are increasingly things a machine does for free, around the clock, without complaint.</p>



<p class="wp-block-paragraph">What&#8217;s left? Exactly the things machines can&#8217;t do: imagination, judgment, trust, empathy, the ability to read a room and the ability to change one. These are the deeply human capabilities that no model, however large, can authentically replicate. And unlocking them in other people is what leadership now actually means.</p>



<p class="wp-block-paragraph">This is what human-centric leadership is about — and it&#8217;s not a soft alternative to &#8220;real&#8221; management. It&#8217;s the only kind of leadership that will still matter ten years from now.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Core Idea</h3>



<p class="wp-block-paragraph">Human-centric leadership puts people at the absolute center of organizational strategy. It treats employees as whole human beings rather than as resources, inputs, or interchangeable units of capacity.</p>



<p class="wp-block-paragraph">That sounds obvious. It isn&#8217;t.</p>



<p class="wp-block-paragraph">Most organizations still optimize for the machine model under a thin layer of modern language. They publish values about &#8220;people first&#8221; while running performance systems that reward only what&#8217;s measurable. They talk about empathy in town halls while structuring incentives around individual output. They preach psychological safety while penalizing the people who actually speak up.</p>



<p class="wp-block-paragraph">Human-centric leadership is the discipline of closing that gap. It means designing the <em>real</em> system — the one made of decisions, structures, and daily behaviors — around the assumption that the people in it are full human beings with curiosity, fears, ambitions, off days, and ideas worth hearing. Not in a sentimental way. In a strategic one.</p>



<p class="wp-block-paragraph">Because here&#8217;s the uncomfortable truth: if the analytical and administrative work is being automated, then the only differentiator left is <em>what humans do together</em>. And that depends entirely on whether they&#8217;re being led by someone who understands them as humans, or managed by someone who still sees them as headcount.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">What&#8217;s Actually Shifting</h3>



<p class="wp-block-paragraph">The shift isn&#8217;t subtle. A leader&#8217;s job used to be roughly:</p>



<ul class="wp-block-list">
<li>Plan the work.</li>



<li>Assign the work.</li>



<li>Track the work.</li>



<li>Report on the work.</li>
</ul>



<p class="wp-block-paragraph">Most of that is now either automated or about to be. AI can plan, assign, track, and report — often better than a human manager can, and certainly faster. If that&#8217;s all a leader does, the role doesn&#8217;t have a long future.</p>



<p class="wp-block-paragraph">What replaces it is harder, and far more interesting:</p>



<ul class="wp-block-list">
<li><strong>Creating clarity</strong> in environments where information moves faster than understanding.</li>



<li><strong>Building trust</strong> in teams that are remote, hybrid, and increasingly cross-cultural.</li>



<li><strong>Unlocking creativity</strong> in work where there&#8217;s no playbook, because the playbook was written by the last generation for the last problem.</li>



<li><strong>Holding meaning</strong> when the work is changing so fast that people lose sight of why it matters.</li>



<li><strong>Coaching judgment</strong> — helping people get better at the calls that <em>can&#8217;t</em> be reduced to a model.</li>
</ul>



<p class="wp-block-paragraph">None of those things show up in a dashboard. All of them determine whether the team succeeds.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Three Capabilities That Now Define a Leader</h3>



<p class="wp-block-paragraph">If I had to compress human-centric leadership into the three capabilities that matter most right now, they&#8217;d be these.</p>



<h4 class="wp-block-heading">1. Creativity — and creating space for it</h4>



<p class="wp-block-paragraph">AI is excellent at <em>combining</em> what already exists. It&#8217;s not yet good at <em>imagining</em> what doesn&#8217;t. That gap is where genuine value is created — new products, new approaches, new ways of solving old problems. But creativity is fragile. It dies under constant urgency, micromanagement, and fear of looking foolish.</p>



<p class="wp-block-paragraph">A human-centric leader&#8217;s job is to protect the conditions where creativity is possible: slack in the schedule, permission to explore, tolerance for half-formed ideas, and visible support for the person who proposes something that might not work. That&#8217;s not a culture perk. It&#8217;s a strategic asset.</p>



<h4 class="wp-block-heading">2. Collaboration — the actual kind, not the meeting kind</h4>



<p class="wp-block-paragraph">Most organizations have mistaken <em>coordination</em> for <em>collaboration</em>. Coordination is making sure two teams don&#8217;t step on each other. Collaboration is two people thinking together and producing something neither could have produced alone. The first is logistics. The second is leadership.</p>



<p class="wp-block-paragraph">Real collaboration depends on trust, psychological safety, and a shared sense that everyone in the room is treated as a peer regardless of title. None of that emerges by accident. It&#8217;s built — slowly, deliberately, and mostly through how the leader behaves when things get hard. Who they listen to. Who they interrupt. Whose ideas they credit. Whose mistakes they cover. These are the small acts that either build collaboration or quietly kill it.</p>



<h4 class="wp-block-heading">3. Emotional connection — without making it weird</h4>



<p class="wp-block-paragraph">This is the one most leaders shy away from, because the vocabulary is loaded. &#8220;Emotional&#8221; sounds soft. &#8220;Connection&#8221; sounds vague. But the underlying capability is concrete: the ability to notice what someone is actually feeling, take it seriously, and respond in a way that respects them as a person.</p>



<p class="wp-block-paragraph">It shows up in small moments. The leader who notices someone has gone quiet in meetings and asks them about it privately. The one who acknowledges that a reorg is genuinely hard before launching into the rationale for it. The one who says &#8220;I don&#8217;t know&#8221; instead of bluffing. The one who remembers what&#8217;s going on in your life when they ask how you&#8217;re doing — and waits for the real answer.</p>



<p class="wp-block-paragraph">People don&#8217;t follow leaders who are merely competent. They follow leaders who make them feel <em>seen</em>. In a world where AI handles the competence, the seeing is the job.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">What This Looks Like in Practice</h3>



<p class="wp-block-paragraph">Human-centric leadership isn&#8217;t a posture or a tone of voice. It&#8217;s a set of choices that show up in how you actually run a team. A few that matter most:</p>



<p class="wp-block-paragraph"><strong>Designing work around strengths, not roles.</strong> Job descriptions are increasingly fictional. The work that actually needs to be done rarely fits neatly inside a title. Human-centric leaders pay close attention to what each person is genuinely good at and energized by, and shape the work to draw on it — not just because it&#8217;s nicer, but because it produces dramatically better results.</p>



<p class="wp-block-paragraph"><strong>Treating one-on-ones as the most important meeting on the calendar.</strong> Not status updates. Real conversations. About the work, the person, what&#8217;s hard, what&#8217;s exciting, what they need from you. If you cancel them when things get busy, you&#8217;re sending the message that the relationship is the first thing to go under pressure. That message lands.</p>



<p class="wp-block-paragraph"><strong>Making it safe to disagree with you.</strong> If the people around you only ever agree, you&#8217;re not leading a team — you&#8217;re leading an echo. The signal you send when someone pushes back is one of the most consequential things you do as a leader. Defend the disagreement, even when it stings, and the team will keep telling you the truth.</p>



<p class="wp-block-paragraph"><strong>Being honest about uncertainty.</strong> The temptation to pretend you have a plan when you don&#8217;t is enormous. Resist it. People can handle &#8220;I don&#8217;t know yet, here&#8217;s how I&#8217;m thinking about it&#8221; far better than they can handle confident certainty that later turns out to be wrong. The first builds trust. The second burns it.</p>



<p class="wp-block-paragraph"><strong>Caring about the whole person without overreaching.</strong> This is the balance. You&#8217;re not their therapist, their friend, or their family. But you are a significant presence in their working life, and how you treat them matters. Notice the human. Ask the questions. Respect the boundaries they set. Don&#8217;t pry — but don&#8217;t pretend the rest of their life doesn&#8217;t exist either.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Honest Counterweight</h3>



<p class="wp-block-paragraph">Human-centric leadership is not soft. It is not the absence of accountability. It is not &#8220;being nice&#8221; instead of being clear. Some of the most human-centric leaders I&#8217;ve worked with are also the most demanding — because they take the people on their team seriously enough to hold them to a high standard, and to tell them the truth when something isn&#8217;t working.</p>



<p class="wp-block-paragraph">The mistake some leaders make when they hear &#8220;human-centric&#8221; is to drift toward conflict avoidance: softening every message, never giving hard feedback, protecting people from challenge. That isn&#8217;t humane. It&#8217;s the opposite. It deprives people of the information they need to grow, and it quietly tells them you don&#8217;t believe they can handle the truth.</p>



<p class="wp-block-paragraph">The real version is harder. It&#8217;s caring about people enough to be honest with them. It&#8217;s holding a high bar <em>because</em> you respect them, not in spite of it. It&#8217;s giving feedback that&#8217;s direct, specific, and rooted in a genuine belief that they can rise to it.</p>



<p class="wp-block-paragraph">That kind of leadership is rare. It&#8217;s also exactly what the next decade demands.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Bigger Picture</h3>



<p class="wp-block-paragraph">The shift to human-centric leadership isn&#8217;t a trend or a generational preference. It&#8217;s a structural response to a structural change. As AI absorbs the analytical and administrative core of knowledge work, the value of leadership migrates upward — away from coordination and toward the deeply human capabilities that determine whether a team can actually do work that matters.</p>



<p class="wp-block-paragraph">Leaders who get this will build organizations where people do their best work, stay longer, and create things that genuinely move the world forward. Leaders who don&#8217;t will spend the next ten years wondering why their best people keep leaving for places that feel more alive.</p>



<p class="wp-block-paragraph">The choice isn&#8217;t really a choice anymore. The machines are getting better at the machine parts of the job. What&#8217;s left is the human parts. And those have always been what leadership was actually about — we just had the luxury of pretending otherwise for a while.</p>



<p class="wp-block-paragraph">That luxury is over. The leaders who recognize it first will define what the next era of work looks like.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/human-centric-leadership-leading-people-in-the-age-of-automation/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Hardening Your Homelab&#8217;s Public Edge: GeoIP Blocking, CrowdSec, and Syncing Fail2Ban with OPNsense</title>
		<link>https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/</link>
					<comments>https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Mon, 25 May 2026 22:32:44 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[CrowdSec]]></category>
		<category><![CDATA[Defense in Depth]]></category>
		<category><![CDATA[Fail2Ban]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[GeoIP]]></category>
		<category><![CDATA[HAProxy]]></category>
		<category><![CDATA[Network Security]]></category>
		<category><![CDATA[OPNsense]]></category>
		<category><![CDATA[Self-Hosting]]></category>
		<category><![CDATA[Threat Intelligence]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2149</guid>

					<description><![CDATA[Exposing services to the internet means accepting a constant flood of automated attacks. Here's how I cut that noise dramatically with GeoIP blocking, CrowdSec, and a Fail2Ban-to-OPNsense sync that turns one webserver's detection into a network-wide block.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Part of the ongoing series on running a self-hosted edge behind a CGNAT-bypassing WireGuard tunnel. If you haven&#8217;t read the <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">WireGuard tunnel post</a> or the <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">HAProxy follow-up</a>, start there — this post builds on that architecture.</p>



<p class="wp-block-paragraph">Once you have public services running behind OPNsense and HAProxy, you quickly notice something: <strong>the internet is noisy.</strong> Within hours of exposing port 443, the logs start filling with probes for <code>/wp-login.php</code>, <code>/.env</code>, random PHP shells, and credential stuffing attempts against anything that looks like a login page. Most of it is automated. Almost none of it is your real users.</p>



<p class="wp-block-paragraph">This post is about the three layers I use to keep that noise out — and to make sure that when something <em>does</em> slip through, it doesn&#8217;t get a second try:</p>



<ol class="wp-block-list">
<li><strong>GeoIP blocking at the firewall</strong>, to drop traffic from regions I have no business serving.</li>



<li><strong>CrowdSec</strong>, to share threat intelligence and block known bad actors automatically.</li>



<li><strong>Syncing Fail2Ban logs from my webservers back to OPNsense</strong>, so that an attack on a single host turns into a network-wide block.</li>
</ol>



<p class="wp-block-paragraph">Each layer is simple on its own. Together, they cut the attack surface dramatically.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Mental Model</h3>



<p class="wp-block-paragraph">Before the config, the philosophy:</p>



<ul class="wp-block-list">
<li><strong>The firewall should do as much filtering as possible, as early as possible.</strong> A packet that&#8217;s dropped at OPNsense never reaches HAProxy, never reaches the webserver, and never burns CPU on your application.</li>



<li><strong>The webserver knows things the firewall doesn&#8217;t.</strong> It sees failed logins, application-level abuse, and patterns the firewall can&#8217;t infer from packet headers alone. That knowledge needs to flow <em>back</em> to the firewall.</li>



<li><strong>Reputation beats reaction.</strong> Blocking an attacker after they&#8217;ve hit you is fine. Blocking them before they&#8217;ve hit you — because someone else already reported them — is better.</li>
</ul>



<p class="wp-block-paragraph">GeoIP handles bulk noise. CrowdSec handles shared reputation. Fail2Ban-to-OPNsense sync handles what only your own services can see. The three together form a feedback loop.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Layer 1: GeoIP Blocking in OPNsense</h3>



<p class="wp-block-paragraph">GeoIP isn&#8217;t a security solution by itself — a determined attacker will use a VPN or a cloud instance in a country you allow. But it&#8217;s an extraordinarily cheap way to drop the <em>volume</em> of automated attacks, which mostly originate from a predictable handful of regions.</p>



<h4 class="wp-block-heading">Setup</h4>



<p class="wp-block-paragraph">OPNsense uses <strong>MaxMind&#8217;s GeoLite2</strong> database via aliases. The high-level steps:</p>



<ol class="wp-block-list">
<li><strong>Get a free MaxMind license key.</strong> Register at maxmind.com and generate a license key under your account.</li>



<li><strong>Configure the GeoIP source in OPNsense.</strong> Under <em>Firewall → Aliases → GeoIP settings</em>, paste the MaxMind download URL with your license key. OPNsense will pull the database on a schedule.</li>



<li><strong>Create GeoIP aliases.</strong> Under <em>Firewall → Aliases</em>, create a new alias of type <strong>GeoIP</strong>, and pick the countries you want to group together. I keep two aliases:
<ul class="wp-block-list">
<li><code>GeoIP_Allow</code> — countries where my actual users live.</li>



<li><code>GeoIP_HighRisk</code> — countries that show up disproportionately in my logs as sources of automated attacks.</li>
</ul>
</li>



<li><strong>Apply the alias in your WAN-facing rules.</strong> On the firewall rule that allows inbound traffic to HAProxy (port 443 from the WireGuard tunnel), add a <strong>block rule above it</strong> that drops traffic from <code>GeoIP_HighRisk</code>. Or, if you want to be stricter, use a default-deny model: only allow traffic from <code>GeoIP_Allow</code>, and let everything else fall to a deny rule.</li>
</ol>



<h4 class="wp-block-heading">A Word of Caution</h4>



<p class="wp-block-paragraph">GeoIP blocking is a blunt tool. Two things to watch for:</p>



<ul class="wp-block-list">
<li><strong>Legitimate users on VPNs or mobile carriers</strong> sometimes appear from unexpected countries. If you serve a global audience, default-deny will hurt you. Default-allow with a high-risk blocklist is usually the better compromise.</li>



<li><strong>CDN and monitoring traffic</strong> (uptime checks, search engine crawlers) may originate from regions you&#8217;d otherwise block. Whitelist their IP ranges explicitly <em>above</em> the GeoIP rule.</li>
</ul>



<p class="wp-block-paragraph">I treat GeoIP as <strong>layer one of noise reduction</strong>, not as security. It&#8217;s the bouncer who turns away the obvious troublemakers before they get to the door.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Layer 2: CrowdSec</h3>



<p class="wp-block-paragraph"><a href="https://www.crowdsec.net/" target="_blank" rel="noopener">CrowdSec</a> is the layer I&#8217;m most enthusiastic about. The short pitch: it&#8217;s a modern, open-source alternative to Fail2Ban that not only detects local attacks but also <strong>shares anonymized signals with a community blocklist</strong>. If a thousand other CrowdSec users are being hit by a particular IP, you benefit from that knowledge automatically.</p>



<h4 class="wp-block-heading">Architecture</h4>



<p class="wp-block-paragraph">There are two pieces:</p>



<ul class="wp-block-list">
<li><strong>The CrowdSec agent</strong> — reads logs, detects malicious behavior, and reports it.</li>



<li><strong>The bouncer</strong> — enforces decisions. There&#8217;s an official <strong>OPNsense plugin</strong> that acts as a firewall bouncer, pulling the blocklist and applying it as a pf table.</li>
</ul>



<p class="wp-block-paragraph">You can run the agent on OPNsense itself, on your webservers, or both. I run agents on the webservers (where the interesting logs live) and the bouncer on OPNsense (where blocks are most effective).</p>



<h4 class="wp-block-heading">Setup on OPNsense</h4>



<ol class="wp-block-list">
<li><strong>Install the OPNsense CrowdSec plugin</strong> from <em>System → Firmware → Plugins</em> (<code>os-crowdsec</code>).</li>



<li><strong>Enroll the instance with the CrowdSec Console</strong> (free tier is fine for a homelab). Enrollment lets you manage decisions across multiple agents from one dashboard.</li>



<li><strong>Enable the firewall bouncer.</strong> It will create a pf table that&#8217;s automatically populated with malicious IPs from both your local detections and the community blocklist.</li>



<li><strong>Add a block rule</strong> referencing the CrowdSec table, placed near the top of your WAN/tunnel-facing rules — <em>above</em> GeoIP and any allow rules.</li>
</ol>



<h4 class="wp-block-heading">Setup on the Webservers</h4>



<ol class="wp-block-list">
<li><strong>Install the CrowdSec agent</strong> on each webserver (Debian/Ubuntu packages are straightforward).</li>



<li><strong>Enable the relevant parsers and scenarios</strong> — <code>nginx</code>, <code>apache2</code>, <code>sshd</code>, <code>http-cve</code>, <code>base-http-scenarios</code>, etc. CrowdSec ships with a hub of community-maintained detection rules.</li>



<li><strong>Forward decisions to OPNsense.</strong> There are two ways:
<ul class="wp-block-list">
<li>Run a <strong>local bouncer</strong> on each webserver (e.g., the iptables or nginx bouncer) so blocks happen at the host level too.</li>



<li>More importantly, configure the webserver agent to <strong>share decisions with the central OPNsense agent</strong> via the CrowdSec API. This is where one webserver&#8217;s detection becomes a firewall-level block for everything.</li>
</ul>
</li>
</ol>



<h4 class="wp-block-heading">Why I Like It</h4>



<p class="wp-block-paragraph">CrowdSec gets you three things Fail2Ban alone doesn&#8217;t:</p>



<ul class="wp-block-list">
<li><strong>Community blocklist.</strong> You&#8217;re blocking known-bad IPs before they ever touch your services.</li>



<li><strong>Centralized decisions.</strong> Multiple agents, one source of truth, one bouncer enforcing at the firewall.</li>



<li><strong>Better scenarios.</strong> Detection rules are written and maintained by a community, not by you at 2 a.m. after an incident.</li>
</ul>



<p class="wp-block-paragraph">It doesn&#8217;t replace Fail2Ban entirely in my setup — but it carries most of the load.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Layer 3: Syncing Fail2Ban Logs from Webservers to OPNsense</h3>



<p class="wp-block-paragraph">Some things Fail2Ban catches that CrowdSec scenarios might not — custom application logs, niche services, anything you&#8217;ve written a bespoke filter for. I still want those bans enforced at the firewall, not just on the host that detected them.</p>



<p class="wp-block-paragraph">The goal: <strong>when Fail2Ban bans an IP on any webserver, that IP gets blocked at OPNsense for everyone.</strong></p>



<p class="wp-block-paragraph">There are a few ways to do this. Here&#8217;s the approach I use, in order of complexity. <em>(Note: this is the API-push method. <a href="https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/">From App Logs to Firewall Blocks</a> documents a different, pull-based method for the same goal — exporting bans to a flat file that OPNsense fetches on a timer via a URL Table alias, rather than pushing them via API call on every ban. Worth treating as two alternative approaches rather than the same pipeline twice.)</em> </p>



<h4 class="wp-block-heading">Option A: Fail2Ban → OPNsense Alias via API (Cleanest)</h4>



<p class="wp-block-paragraph">OPNsense exposes a REST API that can manipulate aliases. The flow:</p>



<ol class="wp-block-list">
<li><strong>Create a host-type alias on OPNsense</strong> called something like <code>Fail2Ban_Sync</code>. This alias will hold the IPs to block.</li>



<li><strong>Add a firewall rule</strong> that drops traffic from <code>Fail2Ban_Sync</code>, placed at the top of your WAN/tunnel rules.</li>



<li><strong>On each webserver, configure a Fail2Ban action</strong> that, on ban, calls the OPNsense API to add the IP to the alias, and on unban, removes it.</li>
</ol>



<p class="wp-block-paragraph">The action script lives in <code>/etc/fail2ban/action.d/opnsense-alias.conf</code> and uses <code>curl</code> to hit the OPNsense API. You&#8217;ll need an API key with permission to modify aliases — create a dedicated user with the minimum scope, and store the credentials in a file readable only by root.</p>



<p class="wp-block-paragraph">A simplified outline of the action:</p>



<p class="wp-block-paragraph">ini</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-125a08921b228109c89eb07636bb598f"><code>&#91;Definition]
actionban   = curl -s -u "$API_KEY:$API_SECRET" \
              -X POST "https://opnsense.lan/api/firewall/alias_util/add/Fail2Ban_Sync" \
              -H "Content-Type: application/json" \
              -d '{"address":"&lt;ip&gt;"}'

actionunban = curl -s -u "$API_KEY:$API_SECRET" \
              -X POST "https://opnsense.lan/api/firewall/alias_util/delete/Fail2Ban_Sync" \
              -H "Content-Type: application/json" \
              -d '{"address":"&lt;ip&gt;"}'</code></pre>



<p class="wp-block-paragraph">Reference that action in your <code>jail.local</code>:</p>



<p class="wp-block-paragraph">ini</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-0e8f2fdc72c8e718204f4cf6e97d07d7"><code>&#91;nginx-http-auth]
enabled  = true
action   = opnsense-alias
filter   = nginx-http-auth
logpath  = /var/log/nginx/error.log
maxretry = 3
bantime  = 86400</code></pre>



<p class="wp-block-paragraph">Now every ban on the webserver is mirrored to OPNsense within seconds.</p>



<h4 class="wp-block-heading">Option B: Shipping Logs Centrally and Letting CrowdSec Handle It</h4>



<p class="wp-block-paragraph">If you&#8217;re already running CrowdSec, you can also ship webserver logs to a central CrowdSec agent (or use the CrowdSec multi-server setup), and let CrowdSec handle the detection-to-block pipeline. This is cleaner long-term but means moving away from Fail2Ban for those specific filters.</p>



<p class="wp-block-paragraph">I run a hybrid: CrowdSec for the well-known patterns, Fail2Ban-with-API-sync for the bespoke ones I haven&#8217;t ported yet.</p>



<h4 class="wp-block-heading">Option C: Syslog Forwarding</h4>



<p class="wp-block-paragraph">You can also forward Fail2Ban logs to OPNsense via syslog and parse them there. This is more fragile (parsing logs you didn&#8217;t generate is always a tax) and I&#8217;d recommend the API approach instead.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Putting It Together: Rule Order Matters</h3>



<p class="wp-block-paragraph">The order of firewall rules on the WAN-facing / tunnel-facing interface matters a lot. Mine looks roughly like this, top to bottom:</p>



<ol class="wp-block-list">
<li><strong>Allow</strong> known-good monitoring and CDN IP ranges (explicit whitelist).</li>



<li><strong>Block</strong> CrowdSec table.</li>



<li><strong>Block</strong> Fail2Ban_Sync alias.</li>



<li><strong>Block</strong> GeoIP_HighRisk alias.</li>



<li><strong>Allow</strong> HTTPS to HAProxy from the tunnel.</li>



<li><strong>Default deny</strong> (implicit).</li>
</ol>



<p class="wp-block-paragraph">The principle: <strong>cheap, high-confidence blocks first; expensive or broader rules later.</strong> A packet from a known-bad IP gets dropped before GeoIP even has to look at it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Operational Notes</h3>



<p class="wp-block-paragraph">A few things I&#8217;ve learned running this setup:</p>



<ul class="wp-block-list">
<li><strong>Whitelist yourself.</strong> Add your own admin IPs (and your monitoring) to a <code>Whitelist</code> alias that&#8217;s referenced <em>above</em> all block rules. The day you lock yourself out because Fail2Ban banned your home IP is the day you build this safeguard — or better, build it before that day, the way <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">Never Lock Yourself Out</a> describes for a dynamic, CGNAT-aware home IP rather than a static one. </li>



<li><strong>Set sensible bantimes.</strong> Permanent bans grow your alias forever and eventually cause performance issues. I use 24 hours for most jails, 7 days for repeat offenders, and a separate manual alias for permanent blocks.</li>



<li><strong>Monitor the size of your aliases.</strong> If your <code>Fail2Ban_Sync</code> alias is growing by thousands of entries a day, something is misconfigured — probably a filter that&#8217;s too aggressive.</li>



<li><strong>Test from outside.</strong> Use a VPN or a phone on cellular to verify that blocks actually work from the public side, not just from your LAN.</li>



<li><strong>Keep an audit trail.</strong> Log every API call to OPNsense from your webservers. If you ever wonder why an IP is blocked, you want to be able to answer that question.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Bigger Picture</h3>



<p class="wp-block-paragraph">What I like about this setup isn&#8217;t any one tool — it&#8217;s that <strong>each layer has a clear job</strong> and the layers compose cleanly:</p>



<ul class="wp-block-list">
<li><strong>GeoIP</strong> removes bulk noise based on geography.</li>



<li><strong>CrowdSec</strong> removes traffic from IPs that other people have already flagged.</li>



<li><strong>Fail2Ban sync</strong> turns local detections into network-wide blocks.</li>



<li><strong>HAProxy</strong> (<a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">from the previous post</a>) decides what gets routed where. </li>



<li><strong>VLAN segmentation</strong> (<a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">also from the previous post</a>) ensures that even successful intrusions have a tiny blast radius. </li>
</ul>



<p class="wp-block-paragraph">No single layer is doing all the work. If one fails or is misconfigured, the others still hold. That&#8217;s the part worth taking away — not the specific commands, but the layered, composable design. It&#8217;s the same principle that applies to almost any system worth running: small, focused pieces that each do their job well, and that fail in ways the rest of the system can absorb.</p>



<p class="wp-block-paragraph">The same composable, defense-in-depth instinct shows up again in <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">Locking Down Authelia</a> — never trusting that one layer rendering correctly (a login page) means the whole chain behind it (2FA, real SMTP delivery, TLS validation) actually works. </p>



<p class="wp-block-paragraph">If you&#8217;re running a similar setup or solving the same problem differently, drop a note. The best part of the homelab community is comparing notes — and the best part of layered security is that everyone&#8217;s stack teaches you something new.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Beyond the Tunnel: Adding HAProxy, ACME, and VLAN Isolation Behind My OPNsense WireGuard Setup</title>
		<link>https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/</link>
					<comments>https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Mon, 25 May 2026 21:55:25 +0000</pubDate>
				<category><![CDATA[Networking]]></category>
		<category><![CDATA[ACME]]></category>
		<category><![CDATA[HAProxy]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[Network Segmentation]]></category>
		<category><![CDATA[OPNsense]]></category>
		<category><![CDATA[Reverse Proxy]]></category>
		<category><![CDATA[Self-Hosting]]></category>
		<category><![CDATA[TLS]]></category>
		<category><![CDATA[VLAN]]></category>
		<category><![CDATA[WireGuard]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2143</guid>

					<description><![CDATA[The WireGuard tunnel got traffic into my homelab. HAProxy, ACME, and a strict VLAN policy decide what happens next. A follow-up on how I expose services safely — and what I deliberately keep hidden.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"> n the <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">previous post</a>, I walked through how I escaped my ISP&#8217;s CGNAT prison using a WireGuard tunnel between my OPNsense firewall and a small VPS with a public IP. That solved the <em>&#8220;how do I reach my homelab from the outside&#8221;</em> problem.</p>



<p class="wp-block-paragraph">But getting traffic <em>into</em> the network is only half the story. Once it arrives, you need a clean, secure, and maintainable way to actually route it to the right service — without accidentally exposing things you didn&#8217;t mean to. That&#8217;s where the next layer comes in: <strong>HAProxy as a reverse proxy, automatic certificates via ACME, and a separate VLAN for services that should never see the public internet.</strong></p>



<p class="wp-block-paragraph">Here&#8217;s how I think about it, and how I built it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Problem: One Tunnel, Many Services</h3>



<p class="wp-block-paragraph">The WireGuard tunnel gives me a single entry point. But behind that entry point, I have:</p>



<ul class="wp-block-list">
<li>A handful of services I <em>want</em> to expose (public-facing dashboards, a Git server, a few self-hosted apps).</li>



<li>A much larger pile of services I <em>never</em> want exposed (internal monitoring, IPMI interfaces, admin panels, backup systems, anything with a weak default).</li>



<li>TLS certificates I don&#8217;t want to manage by hand for every service.</li>
</ul>



<p class="wp-block-paragraph">Throwing all of that on one flat network is asking for trouble. So the design splits things up — physically, logically, and at the proxy layer.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Architecture</h3>



<p class="wp-block-paragraph">At a high level, traffic flows like this:</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-e63aba22c38d5288fa43006372541d35"><code>Internet → VPS (public IP) → WireGuard tunnel → OPNsense
                                                    ↓
                                            HAProxy (DMZ VLAN)
                                            ↙              ↘
                                  Public services       Internal-only services
                                  (DMZ VLAN)            (separate VLAN, no proxy exposure)</code></pre>



<p class="wp-block-paragraph">Three things matter here:</p>



<ol class="wp-block-list">
<li><strong>HAProxy lives on a dedicated DMZ VLAN.</strong> It&#8217;s the only thing that talks to the tunnel-facing side. If it gets compromised, the blast radius is limited to that VLAN.</li>



<li><strong>Public services sit on the DMZ VLAN too</strong>, but only the ones I&#8217;ve explicitly chosen to expose. (This is the same DMZ VLAN that, a few posts later, ends up getting <a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">GeoIP blocking, CrowdSec, and a fail2ban sync</a> layered on top of exactly this HAProxy entry point.) </li>



<li><strong>Internal-only services live on a separate VLAN entirely.</strong> HAProxy can route to them only if I configure it to — and for most of them, I never do. They&#8217;re reachable only over the LAN or via WireGuard from my admin devices.</li>
</ol>



<p class="wp-block-paragraph">The VLAN separation is enforced at OPNsense with firewall rules, not just at the proxy layer. Defense in depth: even if HAProxy were misconfigured, the firewall wouldn&#8217;t let it reach where it shouldn&#8217;t.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">HAProxy on OPNsense: Why and How</h3>



<p class="wp-block-paragraph">OPNsense ships with an HAProxy plugin, and for a small homelab it&#8217;s more than enough. I chose HAProxy over alternatives like Nginx Proxy Manager, Caddy, or Traefik for a few reasons:</p>



<ul class="wp-block-list">
<li>It&#8217;s already integrated into OPNsense — no extra container or VM to maintain.</li>



<li>The configuration model maps cleanly to how I think about traffic: <em>frontends</em>, <em>backends</em>, <em>ACLs</em>, and <em>rules</em>.</li>



<li>It handles SNI-based routing well, which means I can serve multiple HTTPS services on a single IP and port.</li>
</ul>



<p class="wp-block-paragraph">The setup is roughly:</p>



<ul class="wp-block-list">
<li><strong>One frontend on port 443</strong>, listening on the DMZ-side interface that receives traffic from the WireGuard tunnel.</li>



<li><strong>Backends per service</strong>, each pointing to the internal IP and port of the actual application.</li>



<li><strong>ACLs based on SNI / Host header</strong>, routing <code>git.example.com</code> to the Git backend, <code>dash.example.com</code> to the dashboard backend, and so on.</li>



<li>A default backend that returns a generic 404 — anything that doesn&#8217;t match an explicit rule gets nothing useful.</li>
</ul>



<p class="wp-block-paragraph">That last point matters. Whitelist what you expose. Don&#8217;t blacklist what you don&#8217;t.</p>



<p class="wp-block-paragraph">(Caddy ends up playing a closely related role one layer further in, in front of the Docker-based DMZ stack rather than at the OPNsense/HAProxy edge — see <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">Ditch Google</a> for that next hop, where the same &#8220;explicit allow, default reject&#8221; philosophy shows up again as a Caddy <code>forward_auth</code> block instead of an HAProxy ACL.) </p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Automatic Certificates with ACME</h3>



<p class="wp-block-paragraph">Managing TLS by hand is a recipe for outages and weekend fire drills. OPNsense has an ACME Client plugin that integrates well with HAProxy.</p>



<p class="wp-block-paragraph">My setup uses <strong>DNS-01 challenges</strong> rather than HTTP-01, for two reasons:</p>



<ol class="wp-block-list">
<li><strong>It works for internal-only hostnames too.</strong> I can issue valid Let&#8217;s Encrypt certificates for services that aren&#8217;t reachable from the public internet, because the validation happens via DNS, not via an inbound HTTP request.</li>



<li><strong>It avoids opening port 80</strong> to the outside world for the sole purpose of cert renewal. The WireGuard tunnel + HAProxy already handles 443; I&#8217;d rather not introduce another exposed port.</li>
</ol>



<p class="wp-block-paragraph">The ACME client talks to my DNS provider&#8217;s API, creates the TXT record, gets the cert issued, and hands it off to HAProxy. Renewals happen automatically. I check the logs once a month, and that&#8217;s about it.</p>



<p class="wp-block-paragraph">A small but important detail: I keep a <strong>separate account and API token</strong> scoped only to the DNS zone used for ACME challenges. If that token leaks, the damage is limited to one zone — not my entire DNS setup. (Worth noting since one TLS mismatch born from exactly this kind of &#8220;which name am I actually using&#8221; confusion ends up costing a whole debugging session in <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">Locking Down Authelia</a> — a raw IP standing in for a hostname, rather than a DNS-01 zone scoping issue, but the same family of TLS-identity gotcha.) </p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">How I Decide What to Expose</h3>



<p class="wp-block-paragraph">This is the part that&#8217;s less about config files and more about discipline. My rule of thumb:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>A service gets a public hostname only if it needs one. Everything else stays internal.</strong></p>
</blockquote>



<p class="wp-block-paragraph">Concretely, I ask three questions before exposing anything:</p>



<ol class="wp-block-list">
<li><strong>Does someone outside my LAN actually need to reach this?</strong> If the answer is &#8220;it would be convenient,&#8221; that&#8217;s not a yes. Convenient is what WireGuard is for.</li>



<li><strong>Does the service have proper authentication, ideally with MFA?</strong> If it&#8217;s a service with a single shared password or no auth, it doesn&#8217;t get a public route. Period. (This is exactly the bar <a href="https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/">Authelia, wired up as SSO with real TOTP</a>, ends up clearing for Open WebUI and Nextcloud a few posts later — neither one had MFA on its own.)</li>



<li><strong>Am I willing to keep it patched?</strong> Exposing something means owning its security posture. If I won&#8217;t keep up with updates, it doesn&#8217;t go on the proxy.</li>
</ol>



<p class="wp-block-paragraph">Anything that fails those checks lives on the internal VLAN and is reachable only via WireGuard from my own devices. That covers the vast majority of my homelab: monitoring, dashboards, IPMI, backup UIs, the lot.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Mental Model</h3>



<p class="wp-block-paragraph">The way I think about this whole layered setup:</p>



<ul class="wp-block-list">
<li><strong>WireGuard + VPS</strong> is the <em>front door</em>. It decides who can knock.</li>



<li><strong>HAProxy</strong> is the <em>receptionist</em>. It decides where the visitor is allowed to go.</li>



<li><strong>VLANs and firewall rules</strong> are the <em>locked doors inside the building</em>. Even if the receptionist makes a mistake, most rooms are still inaccessible.</li>



<li><strong>ACME</strong> keeps the locks well-oiled without me having to think about it.</li>
</ul>



<p class="wp-block-paragraph">No single layer is doing all the work. Each one is simple, focused, and replaceable. If I ever swap HAProxy for Caddy or Traefik, the rest of the architecture doesn&#8217;t have to change.</p>



<p class="wp-block-paragraph">That&#8217;s the part I find most satisfying — not the specific tools, but the way the layers compose. It&#8217;s also, honestly, how I&#8217;ve come to think about a lot of things outside the homelab: small, well-defined responsibilities beat one tool trying to do everything.</p>



<p class="wp-block-paragraph">Everything from here builds on this same foundation: <a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">GeoIP/CrowdSec hardening</a> and <a href="https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/">fail2ban-to-firewall syncing</a> on the WAN side, <a href="https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/">Authelia SSO</a> and <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">real 2FA</a> on the app side, <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">a self-hosted search engine</a> behind that same Authelia layer, and <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">a dynamic anti-lockout rule</a> for the management plane underneath all of it. </p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
