The short version
I run a DMZ behind three firewalls at home. It’s not because I collect tin-foil hats. It’s because I self-host, I break things, and I don’t want a compromised media server pivoting into my password vault. The stack is boring on purpose: clean segmentation, strict egress, no blind port forwards, and least-privilege between every hop. The result is an environment that’s fast enough for gaming and streaming, but resilient when something inevitably gets popped.
If you want the longer story with configs, keep reading.
Why a DMZ still matters in 2021
Self-hosting is amazing until random containers and IoT tat start phoning home. Modern attacks love lateral movement. A classic DMZ is still a good pattern for untrusted or internet-facing services. I use it for reverse proxies, media apps, small web toys, telemetry gateways, and anything I don’t fully trust. The DMZ reduces blast radius, gives me clean monitoring points, and makes incident response less chaotic.
Threat model (so you know what we’re solving)
- I expose a few HTTP(S) services and sometimes SSH.
- I run a mix of x86 boxes, a Proxmox host, and a Raspberry Pi cluster.
- Family devices live on a “trusted” LAN. IoT lives on its own penance VLAN.
- I want remote access without leaving inbound holes on the WAN.
- Assume a DMZ host can and will be compromised.
Design goals:
- Contain compromise to the DMZ.
- Make pivoting to LAN painful.
- Keep management out-of-band.
- Keep performance decent and power bills sane.
The three-firewall layout
[Internet]
|
[FW1: ISP CPE] → bridge or dumb NAT, no services
|
[FW2: Edge firewall/router]
|-- VLAN 10: LAN (trusted)
|-- VLAN 20: MGMT (OOB admin)
|-- VLAN 30: DMZ (untrusted)
|-- VLAN 40: IOT (least-trust)
|
[FW3: DMZ guard] → a tiny firewall/router between VLAN 30 and everything else
|
[Reverse proxy / services in DMZ]
- FW1: Whatever the ISP hands you. Ideally bridged so FW2 holds the public address. If you can’t bridge, treat FW1 as an outer stateless-ish filter with UPnP off and no port forwards.
- FW2: Your main router (OPNsense/pfSense). This does VLANs, DHCP, DNS, VPN, IDS/IPS, egress filtering, and is the default gateway for every segment except the DMZ.
- FW3: A dedicated guard between the DMZ and the rest. It only sees DMZ-to-LAN/MGMT traffic. Mine is a fanless box with two NICs, but a router-on-a-stick with a Layer 3 switch and ACLs also works. This is where least-privilege east-west rules live.
Why three? Because splitting responsibilities keeps rulesets sane and lowers the chance that a big “allow” on FW2 quietly opens a path from DMZ to LAN. If FW3 dies, my LAN still routes; if FW2 gets messy, FW3 still blocks DMZ pivots.
Network segmentation that doesn’t fight you
On FW2 I run VLANs with explicit trust levels:
- LAN (VLAN 10): Workstations, laptops, dev boxes.
- MGMT (VLAN 20): Hypervisors, switches, out-of-band controllers, jump host. No internet by default.
- DMZ (VLAN 30): Reverse proxy, web apps, media, public-facing stuff.
- IOT (VLAN 40): Cameras, TVs, speakers. No lateral to LAN; pinholed to proxy or specific services.
Rule of thumb: default deny everywhere, then add explicit destinations and ports. Do not let the DMZ talk to LAN DNS or file shares. Give the DMZ its own resolver and a single NTP source you control.
Baseline firewall rules
FW2 (edge) WAN
- Block all inbound. No port forwards unless you absolutely must.
- Allow established/related.
- Optional: ICMP rate-limit, drop bogons, disable NAT reflection.
FW2 (edge) LAN → WAN
- Allow LAN to WAN with egress restrictions: 80/443, DoT/DoH if you use them, NTP to your box, and block sketchy outbound by default. Create “developer” exceptions if you need random ports for work.
FW2 (edge) DMZ → WAN
-
Default deny outbound, then explicitly allow:
- HTTP/HTTPS to package registries and your container registries
- DNS to the DMZ resolver only
- NTP to your time source
- Block SMTP, random high ports and anything P2P-ish unless you mean it
FW3 (DMZ guard) DMZ ↔ LAN/MGMT
- Default deny both ways.
- Pinhole only what the reverse proxy needs to reach backends. Prefer pull-from-LAN into DMZ over push-from-DMZ into LAN.
- Allow jump-host SSH/RDP into DMZ, not the other way around.
- No DMZ to MGMT, ever, except to a bastion with MFA.
Reverse proxy pattern
I front most HTTP services with a reverse proxy in the DMZ. Caddy or Traefik are great. The proxy terminates TLS, does header hygiene, and talks to backends through FW3 pinholes. Backends usually live on the LAN, bound to loopback or a backend-only VLAN so they are not accidentally exposed.
Example Caddyfile
myapps.example.com {
encode gzip zstd
log {
output file /var/log/caddy/myapps.log
}
@api path /api/*
handle @api {
reverse_proxy http://10.10.10.50:8080
}
handle {
reverse_proxy http://10.10.10.60:3000
}
header {
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
Content-Security-Policy "default-src 'self'"
}
}
Hard rule: don’t let clients bypass the proxy. Bind backends to 127.0.0.1
on their hosts or to a backend-only VLAN. If you run containers, publish services on an internal network, not the host’s main interface.
Remote access without opening the castle gate
I prefer no inbound ports for day-to-day remote use. Two approaches I’ve used:
- Cloudflare Zero Trust + WARP + Tunnel
cloudflared
runs in the DMZ and makes outbound-only tunnels to Cloudflare.- I publish individual apps (dashboards, small APIs) behind Access policies with SSO and device posture checks.
- For SSH and RDP, I use Access for Infrastructure so clients authenticate via my IdP and short-lived certs.
- This keeps the WAN clean and removes the need for public port forwards.
- WireGuard for full-network access when I need layer-3 reachability
- If I absolutely need it, I run WireGuard on FW2 and only open UDP 51820 with strict peer lists and MFA on the jump host. Most of the time I still prefer the Zero Trust approach above.
WireGuard peer (client) example
[Interface]
Address = 10.6.0.2/32
PrivateKey = <client_private>
DNS = 10.10.20.2 # your resolver on MGMT
[Peer]
PublicKey = <server_public>
AllowedIPs = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Endpoint = my.home.ip:51820
PersistentKeepalive = 25
Tip: if you can avoid a listener entirely, do it. Publishing SSH via Zero Trust with short-lived certs is far nicer than babysitting keys and fail2ban.
IDS/IPS where it helps
I run Suricata inline on FW2 for WAN and on a SPAN port feeding a sensor for the DMZ. I start in alert-only, then promote stable rulesets to drop. Don’t enable every rule. Tune DNS, TLS, and HTTP rules for the apps you actually run. Ship EVE JSON logs to Loki and pivot from there during incidents.
Minimal suricata.yaml
snippet for EVE:
eve-log:
enabled: yes
filetype: regular
filename: eve.json
community-id: true
types:
- alert
- dns
- http
- tls
- flow
IPv6 sanity
IPv6 isn’t scary. Run stateful firewalling and RA Guard on the switch if it supports it. No need for NAT66. Give each VLAN a delegated prefix, keep the same deny-by-default posture, and publish only what you intend via the reverse proxy or Zero Trust. Privacy extensions on clients, fixed addresses for servers.
Host hardening and egress rules that save you later
Even perfect perimeters get bypassed. On every DMZ box:
- Auto patching on a tight cadence.
- Application allowlists where feasible.
ufw
/nftables
/Windows Firewall locked down to the proxy and necessary registries.- Outbound only to what’s needed. Most compromises beacon. Killing egress kills a lot of damage.
Common pitfalls I’ve hit (so you don’t have to)
- NAT reflection creating weird hairpins. Use split DNS instead.
- UPnP/NAT-PMP left enabled globally. If you must use it for a console, lock it to that MAC and a tiny ephemeral range.
- Catch-all allow rules on the DMZ guard. Audit FW3 regularly.
- Reverse proxy bypass by hitting a backend directly. Bind backends to loopback or a private backend VLAN.
- Overzealous IPS dropping your video calls. Start with alert-only, promote slowly.
Concrete build guide (OPNsense/pfSense flavored)
1) Create VLANs on the firewall
- VLAN 10 LAN, 20 MGMT, 30 DMZ, 40 IOT on the trunk to your switch.
- Assign interfaces, give each a /24 IPv4 and an IPv6 /64 from your delegated prefix.
2) Configure the switch
- Trunk the firewall uplink with VLANs tagged.
- Access ports for each segment. No native VLAN on the trunk. Enable RA Guard if available.
3) Baseline rules
- On each interface: default deny, then allow what you need.
- On DMZ: allow DNS to your DMZ resolver, HTTP/HTTPS egress to registries, NTP to your clock. Deny the rest.
- On MGMT: no internet by default. Allow your jump host out for updates via a proxy if needed.
4) Reverse proxy in the DMZ
- Deploy Caddy/Traefik as a systemd service or container.
- Terminate TLS, enforce HSTS, set sane security headers.
- Pinholes on FW3 only to the exact backends the proxy needs.
5) Remote access
- Prefer Cloudflare Zero Trust with an outbound-only tunnel, Access policies, and device posture checks.
- Only if you need full layer-3, add WireGuard on FW2 with specific peers.
6) Observability
- Send firewall, DHCP, DNS, and Suricata logs to a central box (Loki/Elastic/Grafana). Time sync everything.
Testing the setup
- From outside: scan your public IP for open TCP/UDP. You should see nothing you didn’t mean to expose.
- From the DMZ: try to resolve and reach LAN-only names. It should fail unless you explicitly allowed it.
- Kill the reverse proxy and confirm backends stay unreachable from the internet.
- Pull a Suricata test rule that triggers on known patterns and confirm you see alerts.
When three firewalls is overkill
If you don’t self-host or you only expose one boring service, you can collapse FW3 into strict inter-VLAN rules on FW2. The separate DMZ guard shines when you host multiple apps, iterate often, or want a blast shield between hobby services and your primary network.
What I’d change next
- Swap the DMZ guard for a Layer 3 switch with hardware ACLs and sFlow to a collector.
- Add per-app service accounts and mTLS between proxy and backends.
- Automate Access policies from IaC so changes are code-reviewed.