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 GeoIP blocking, CrowdSec, and a fail2ban-to-OPNsense sync already doing the perimeter hardening on that side.
Two firewalls, two very different jobs. But they share a problem that took me longer than I’d like to admit to solve properly: how do you restrict management access on the remote box to “just me, at home” when your home connection doesn’t have a fixed IP — and worse, doesn’t even have a real IP from the internet’s point of view?
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’re running a similar two-site setup — homelab plus colo, or homelab plus VPS — this is the kind of plumbing that’s easy to skip and annoying to retrofit later.
Why Bother Locking This Down At All
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’s on fire, you want a door that doesn’t depend on the thing that might be broken.
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 “traffic from my home IP only” on top of a default-deny stance shrinks the attack surface from “anyone on the internet” to “anyone who happens to be sitting at my house, or who happens to share my ISP’s NAT pool at this exact moment” — which, as I’ll get into, isn’t quite as airtight as it sounds, but it’s a meaningful improvement over leaving the GUI wide open.
This is also just good practice independent of the CGNAT wrinkle. If you’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’t give you a stable address to allowlist in the first place.
The CGNAT Catch
My home ISP, like most residential providers these days, doesn’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’s WAN interface reports is a private one in the 100.64.0.0/10 range — not my actual internet-facing address at all.
This is the same CGNAT wrinkle that made getting a WireGuard tunnel through to this homelab in the first place its own separate problem — there, the issue was inbound traffic having nowhere to land; here, it’s an outbound-facing allowlist having nothing stable to allowlist. Same root cause, two different symptoms.
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’d faithfully update your DNS record every few minutes with an address nobody on the internet can route to. The fix isn’t complicated once you know it’s needed, but it’s a “you don’t know what you don’t know” trap: the DDNS client has to ask an external service what your IP looks like from the outside, rather than trusting what your own interface thinks it is.
Building a Self-Updating Allowlist
With that constraint in mind, the chain I built has three links:
- A DNS record on Cloudflare that always points at my real home IP.
- 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.
- 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.
The elegant part is that none of this needs glue scripts or cron jobs duct-taped together. OPNsense’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.
Step 1 — A DNS Record and a Properly Scoped API Token
On the Cloudflare side, I added a plain A record — home.yourdomain.com — with the proxy turned off (grey cloud, “DNS only”). That last bit matters more than it looks: if the orange-cloud proxy is on, anything resolving that hostname gets back one of Cloudflare’s edge IPs instead of my home address, which would quietly break the whole scheme in a way that’s annoying to debug.
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 Zone / DNS / Edit permissions on the specific zone. Good habit generally: minimum necessary privilege, even for the small stuff.
Step 2 — DDNS on the Home Firewall
OPNsense doesn’t ship dynamic DNS support out of the box; it’s a plugin (os-ddclient). Once installed, the configuration lives under Services → Dynamic DNS, and the account setup is where the CGNAT-specific decision actually gets made.
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 “token” 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.
The setting that actually solves the CGNAT problem is “Check IP method.” Left on “Interface,” the client would read the WAN interface’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.
It’s a small dropdown selection with a big practical consequence, and it’s the kind of detail that’s invisible until you’ve had a DDNS setup silently failing for a week because nobody told you the interface address wasn’t the address that mattered.
Step 3 — Alias and Rule on the Webhosting Firewall
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.
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’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’s coming from, rather than what it claims to be — is exactly what the network-scoped Authelia bypass rule for Open WebUI’s search calls does at the application layer instead of the firewall layer.)
What Could Still Go Wrong
I want to be honest about the limits of this setup, because IP-based access control always sounds more solid than it actually is.
First, there’s a convergence gap. If my home IP changes, there’s a window — typically a few minutes — between the DDNS client noticing and pushing the update, and the remote firewall’s alias re-resolving the hostname. During that window, the old IP is still allowed and the new one isn’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.
Second, and more fundamentally: CGNAT means the public IP I’m allowlisting isn’t uniquely mine. It’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’t make the rule useless — it still meaningfully narrows things down from “the entire internet” to “people on my ISP’s network right now” — but it’s not the same security guarantee a unique static IP would give you, and I don’t want to oversell it as one.
Because of both of these, this rule isn’t my primary path into the box. I already run a WireGuard tunnel between the two sites, with HAProxy, ACME, and VLAN isolation deciding what’s actually reachable on the other end of it, and that’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’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’t compound into a full lockout.
The Bigger Lesson
None of this is exotic infrastructure — a DNS record, a small plugin, two firewall objects. But it’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. “Just allowlist the IP” is a perfectly fine instruction right up until someone’s home connection sits behind carrier-grade NAT, at which point the naive implementation fails silently instead of loudly.
The fix, in both the homelab and the enterprise version of this problem, is the same: don’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’t a system that never breaks. It’s one where a single break doesn’t turn into a 2am lockout. The same defense-in-depth instinct shows up again later in locking down Authelia’s own 2FA setup — never trusting that one layer rendering correctly means the whole chain behind it actually works.
That’s the version of “secure by default” I actually trust — not because it’s clever, but because it assumes its own cleverness will eventually be wrong about something.
