In the previous post, I walked through how I escaped my ISP’s CGNAT prison using a WireGuard tunnel between my OPNsense firewall and a small VPS with a public IP. That solved the “how do I reach my homelab from the outside” problem.
But getting traffic into 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’t mean to. That’s where the next layer comes in: HAProxy as a reverse proxy, automatic certificates via ACME, and a separate VLAN for services that should never see the public internet.
Here’s how I think about it, and how I built it.
The Problem: One Tunnel, Many Services
The WireGuard tunnel gives me a single entry point. But behind that entry point, I have:
- A handful of services I want to expose (public-facing dashboards, a Git server, a few self-hosted apps).
- A much larger pile of services I never want exposed (internal monitoring, IPMI interfaces, admin panels, backup systems, anything with a weak default).
- TLS certificates I don’t want to manage by hand for every service.
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.
The Architecture
At a high level, traffic flows like this:
Internet → VPS (public IP) → WireGuard tunnel → OPNsense
↓
HAProxy (DMZ VLAN)
↙ ↘
Public services Internal-only services
(DMZ VLAN) (separate VLAN, no proxy exposure)
Three things matter here:
- HAProxy lives on a dedicated DMZ VLAN. It’s the only thing that talks to the tunnel-facing side. If it gets compromised, the blast radius is limited to that VLAN.
- Public services sit on the DMZ VLAN too, but only the ones I’ve explicitly chosen to expose.
- Internal-only services live on a separate VLAN entirely. HAProxy can route to them only if I configure it to — and for most of them, I never do. They’re reachable only over the LAN or via WireGuard from my admin devices.
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’t let it reach where it shouldn’t.
HAProxy on OPNsense: Why and How
OPNsense ships with an HAProxy plugin, and for a small homelab it’s more than enough. I chose HAProxy over alternatives like Nginx Proxy Manager, Caddy, or Traefik for a few reasons:
- It’s already integrated into OPNsense — no extra container or VM to maintain.
- The configuration model maps cleanly to how I think about traffic: frontends, backends, ACLs, and rules.
- It handles SNI-based routing well, which means I can serve multiple HTTPS services on a single IP and port.
The setup is roughly:
- One frontend on port 443, listening on the DMZ-side interface that receives traffic from the WireGuard tunnel.
- Backends per service, each pointing to the internal IP and port of the actual application.
- ACLs based on SNI / Host header, routing
git.example.comto the Git backend,dash.example.comto the dashboard backend, and so on. - A default backend that returns a generic 404 — anything that doesn’t match an explicit rule gets nothing useful.
That last point matters. Whitelist what you expose. Don’t blacklist what you don’t.
Automatic Certificates with ACME
Managing TLS by hand is a recipe for outages and weekend fire drills. OPNsense has an ACME Client plugin that integrates well with HAProxy.
My setup uses DNS-01 challenges rather than HTTP-01, for two reasons:
- It works for internal-only hostnames too. I can issue valid Let’s Encrypt certificates for services that aren’t reachable from the public internet, because the validation happens via DNS, not via an inbound HTTP request.
- It avoids opening port 80 to the outside world for the sole purpose of cert renewal. The WireGuard tunnel + HAProxy already handles 443; I’d rather not introduce another exposed port.
The ACME client talks to my DNS provider’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’s about it.
A small but important detail: I keep a separate account and API token 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.
How I Decide What to Expose
This is the part that’s less about config files and more about discipline. My rule of thumb:
A service gets a public hostname only if it needs one. Everything else stays internal.
Concretely, I ask three questions before exposing anything:
- Does someone outside my LAN actually need to reach this? If the answer is “it would be convenient,” that’s not a yes. Convenient is what WireGuard is for.
- Does the service have proper authentication, ideally with MFA? If it’s a service with a single shared password or no auth, it doesn’t get a public route. Period.
- Am I willing to keep it patched? Exposing something means owning its security posture. If I won’t keep up with updates, it doesn’t go on the proxy.
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.
The Mental Model
The way I think about this whole layered setup:
- WireGuard + VPS is the front door. It decides who can knock.
- HAProxy is the receptionist. It decides where the visitor is allowed to go.
- VLANs and firewall rules are the locked doors inside the building. Even if the receptionist makes a mistake, most rooms are still inaccessible.
- ACME keeps the locks well-oiled without me having to think about it.
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’t have to change.
That’s the part I find most satisfying — not the specific tools, but the way the layers compose. It’s also, honestly, how I’ve come to think about a lot of things outside the homelab: small, well-defined responsibilities beat one tool trying to do everything.
