Part of the ongoing series on running a self-hosted edge behind a CGNAT-bypassing WireGuard tunnel. If you haven’t read the WireGuard tunnel post or the HAProxy follow-up, start there — this post builds on that architecture.
Once you have public services running behind OPNsense and HAProxy, you quickly notice something: the internet is noisy. Within hours of exposing port 443, the logs start filling with probes for /wp-login.php, /.env, 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.
This post is about the three layers I use to keep that noise out — and to make sure that when something does slip through, it doesn’t get a second try:
- GeoIP blocking at the firewall, to drop traffic from regions I have no business serving.
- CrowdSec, to share threat intelligence and block known bad actors automatically.
- Syncing Fail2Ban logs from my webservers back to OPNsense, so that an attack on a single host turns into a network-wide block.
Each layer is simple on its own. Together, they cut the attack surface dramatically.
The Mental Model
Before the config, the philosophy:
- The firewall should do as much filtering as possible, as early as possible. A packet that’s dropped at OPNsense never reaches HAProxy, never reaches the webserver, and never burns CPU on your application.
- The webserver knows things the firewall doesn’t. It sees failed logins, application-level abuse, and patterns the firewall can’t infer from packet headers alone. That knowledge needs to flow back to the firewall.
- Reputation beats reaction. Blocking an attacker after they’ve hit you is fine. Blocking them before they’ve hit you — because someone else already reported them — is better.
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.
Layer 1: GeoIP Blocking in OPNsense
GeoIP isn’t a security solution by itself — a determined attacker will use a VPN or a cloud instance in a country you allow. But it’s an extraordinarily cheap way to drop the volume of automated attacks, which mostly originate from a predictable handful of regions.
Setup
OPNsense uses MaxMind’s GeoLite2 database via aliases. The high-level steps:
- Get a free MaxMind license key. Register at maxmind.com and generate a license key under your account.
- Configure the GeoIP source in OPNsense. Under Firewall → Aliases → GeoIP settings, paste the MaxMind download URL with your license key. OPNsense will pull the database on a schedule.
- Create GeoIP aliases. Under Firewall → Aliases, create a new alias of type GeoIP, and pick the countries you want to group together. I keep two aliases:
GeoIP_Allow— countries where my actual users live.GeoIP_HighRisk— countries that show up disproportionately in my logs as sources of automated attacks.
- Apply the alias in your WAN-facing rules. On the firewall rule that allows inbound traffic to HAProxy (port 443 from the WireGuard tunnel), add a block rule above it that drops traffic from
GeoIP_HighRisk. Or, if you want to be stricter, use a default-deny model: only allow traffic fromGeoIP_Allow, and let everything else fall to a deny rule.
A Word of Caution
GeoIP blocking is a blunt tool. Two things to watch for:
- Legitimate users on VPNs or mobile carriers 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.
- CDN and monitoring traffic (uptime checks, search engine crawlers) may originate from regions you’d otherwise block. Whitelist their IP ranges explicitly above the GeoIP rule.
I treat GeoIP as layer one of noise reduction, not as security. It’s the bouncer who turns away the obvious troublemakers before they get to the door.
Layer 2: CrowdSec
CrowdSec is the layer I’m most enthusiastic about. The short pitch: it’s a modern, open-source alternative to Fail2Ban that not only detects local attacks but also shares anonymized signals with a community blocklist. If a thousand other CrowdSec users are being hit by a particular IP, you benefit from that knowledge automatically.
Architecture
There are two pieces:
- The CrowdSec agent — reads logs, detects malicious behavior, and reports it.
- The bouncer — enforces decisions. There’s an official OPNsense plugin that acts as a firewall bouncer, pulling the blocklist and applying it as a pf table.
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).
Setup on OPNsense
- Install the OPNsense CrowdSec plugin from System → Firmware → Plugins (
os-crowdsec). - Enroll the instance with the CrowdSec Console (free tier is fine for a homelab). Enrollment lets you manage decisions across multiple agents from one dashboard.
- Enable the firewall bouncer. It will create a pf table that’s automatically populated with malicious IPs from both your local detections and the community blocklist.
- Add a block rule referencing the CrowdSec table, placed near the top of your WAN/tunnel-facing rules — above GeoIP and any allow rules.
Setup on the Webservers
- Install the CrowdSec agent on each webserver (Debian/Ubuntu packages are straightforward).
- Enable the relevant parsers and scenarios —
nginx,apache2,sshd,http-cve,base-http-scenarios, etc. CrowdSec ships with a hub of community-maintained detection rules. - Forward decisions to OPNsense. There are two ways:
- Run a local bouncer on each webserver (e.g., the iptables or nginx bouncer) so blocks happen at the host level too.
- More importantly, configure the webserver agent to share decisions with the central OPNsense agent via the CrowdSec API. This is where one webserver’s detection becomes a firewall-level block for everything.
Why I Like It
CrowdSec gets you three things Fail2Ban alone doesn’t:
- Community blocklist. You’re blocking known-bad IPs before they ever touch your services.
- Centralized decisions. Multiple agents, one source of truth, one bouncer enforcing at the firewall.
- Better scenarios. Detection rules are written and maintained by a community, not by you at 2 a.m. after an incident.
It doesn’t replace Fail2Ban entirely in my setup — but it carries most of the load.
Layer 3: Syncing Fail2Ban Logs from Webservers to OPNsense
Some things Fail2Ban catches that CrowdSec scenarios might not — custom application logs, niche services, anything you’ve written a bespoke filter for. I still want those bans enforced at the firewall, not just on the host that detected them.
The goal: when Fail2Ban bans an IP on any webserver, that IP gets blocked at OPNsense for everyone.
There are a few ways to do this. Here’s the approach I use, in order of complexity.
Option A: Fail2Ban → OPNsense Alias via API (Cleanest)
OPNsense exposes a REST API that can manipulate aliases. The flow:
- Create a host-type alias on OPNsense called something like
Fail2Ban_Sync. This alias will hold the IPs to block. - Add a firewall rule that drops traffic from
Fail2Ban_Sync, placed at the top of your WAN/tunnel rules. - On each webserver, configure a Fail2Ban action that, on ban, calls the OPNsense API to add the IP to the alias, and on unban, removes it.
The action script lives in /etc/fail2ban/action.d/opnsense-alias.conf and uses curl to hit the OPNsense API. You’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.
A simplified outline of the action:
ini
[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":"<ip>"}'
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":"<ip>"}'
Reference that action in your jail.local:
ini
[nginx-http-auth]
enabled = true
action = opnsense-alias
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
maxretry = 3
bantime = 86400
Now every ban on the webserver is mirrored to OPNsense within seconds.
Option B: Shipping Logs Centrally and Letting CrowdSec Handle It
If you’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.
I run a hybrid: CrowdSec for the well-known patterns, Fail2Ban-with-API-sync for the bespoke ones I haven’t ported yet.
Option C: Syslog Forwarding
You can also forward Fail2Ban logs to OPNsense via syslog and parse them there. This is more fragile (parsing logs you didn’t generate is always a tax) and I’d recommend the API approach instead.
Putting It Together: Rule Order Matters
The order of firewall rules on the WAN-facing / tunnel-facing interface matters a lot. Mine looks roughly like this, top to bottom:
- Allow known-good monitoring and CDN IP ranges (explicit whitelist).
- Block CrowdSec table.
- Block Fail2Ban_Sync alias.
- Block GeoIP_HighRisk alias.
- Allow HTTPS to HAProxy from the tunnel.
- Default deny (implicit).
The principle: cheap, high-confidence blocks first; expensive or broader rules later. A packet from a known-bad IP gets dropped before GeoIP even has to look at it.
Operational Notes
A few things I’ve learned running this setup:
- Whitelist yourself. Add your own admin IPs (and your monitoring) to a
Whitelistalias that’s referenced above all block rules. The day you lock yourself out because Fail2Ban banned your home IP is the day you build this safeguard. - Set sensible bantimes. 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.
- Monitor the size of your aliases. If your
Fail2Ban_Syncalias is growing by thousands of entries a day, something is misconfigured — probably a filter that’s too aggressive. - Test from outside. Use a VPN or a phone on cellular to verify that blocks actually work from the public side, not just from your LAN.
- Keep an audit trail. 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.
The Bigger Picture
What I like about this setup isn’t any one tool — it’s that each layer has a clear job and the layers compose cleanly:
- GeoIP removes bulk noise based on geography.
- CrowdSec removes traffic from IPs that other people have already flagged.
- Fail2Ban sync turns local detections into network-wide blocks.
- HAProxy (from the previous post) decides what gets routed where.
- VLAN segmentation (also from the previous post) ensures that even successful intrusions have a tiny blast radius.
No single layer is doing all the work. If one fails or is misconfigured, the others still hold. That’s the part worth taking away — not the specific commands, but the layered, composable design. It’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.
If you’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’s stack teaches you something new.
