14 KiB
Network integration — OPNsense, VLANs, and whether to port-forward anything
Short answer: don't port-forward any of this to the WAN. Nothing in this repo needs to be reachable from the public internet, and forwarding it would trade a huge amount of security for very little. If you want to reach the household stack while away from home, the answer is a VPN back into your own network, not opening ports on the firewall. The rest of this document explains why, and how to segment things internally with VLANs so a compromised IoT device (cameras especially — see Phase 5/20's "zero WAN egress" guardrail) can't reach the rest of your network either.
This is guidance, not automation — nothing under this repo touches your OPNsense
config, same convention as this project's HA-integration catalog entries
(docs/project-plan.md §2): you apply this by hand in the OPNsense web UI.
1. Why no port forward
Every custom service this repo builds (identity, pantry-vision, transit,
admin-canvas, digest-web/admin-web/pantry-web/identity-web, chores,
trash-calendar) is a small stdlib-http.server/nginx process, bearer-token
gated where it needs to be, but none of it was built or hardened with "reachable
from the raw internet" as a threat model — no rate limiting, no WAF, no
DDoS/abuse handling, no security audit. Home Assistant, Grocy, Frigate, Node-RED,
Portainer, Netdata, and every other off-the-shelf piece of the stack are the same
story: capable, actively maintained software, but not written or configured here
with public exposure in mind. Port-forwarding any of them turns "a bug in one of
these" into "a bug reachable by the entire internet, scanning for it constantly."
None of them need to be reachable from the internet in the first place — every
real use case (checking the dashboard from work, registering a guest while
out, planning a trip) is solved just as well by a VPN.
2. Remote access: WireGuard, not forwarded HTTP ports
OPNsense ships a WireGuard implementation (Instances/Peers under VPN → WireGuard,
built in since 22.1 — no separate package needed on current OPNsense). Set up one
instance on the firewall and a peer per device you want remote access from (your
phone, a laptop). The only thing that ever needs forwarding is one UDP port
for WireGuard itself (commonly 51820, but pick anything free) — and that's a
categorically different exposure than forwarding this stack's HTTP services
directly: WireGuard silently drops any packet that isn't from an already-paired,
cryptographically-authenticated peer, so an internet-wide scanner sees nothing to
attack at all, versus a live HTTP endpoint answering every request that reaches it.
Once connected, your phone/laptop is (virtually) on your LAN/VLAN and reaches
identity/pantry-vision/HA/etc. at their normal LAN IPs — no per-service
forwarding, no bearer tokens exposed to the WAN, nothing else to configure network-
side per new service this repo adds later.
If you don't need remote access at all, skip this section entirely — every service in this stack works purely on the LAN with zero WAN configuration.
2.1 Split tunnel — route the smart-home VLAN, nothing else
AllowedIPs on the client profile is both the route table and WireGuard's crypto
ACL, so it alone decides what enters the tunnel. Route the smart-home VLAN and leave
everything else on the phone's own connection:
[Peer]
AllowedIPs = 192.168.30.0/24 # the smart-home VLAN, adjust to your real subnet
# NOT 0.0.0.0/0 (sends all your traffic home for no reason)
# NOT 192.168.0.0/16 — see the collision warning below
Never route the whole 192.168.0.0/16. Practically every café, hotel and hotspot
LAN lives on 192.168.0.0/24 or 192.168.1.0/24; routing the entire /16 makes the
phone send that network's own gateway down the tunnel, and you lose connectivity on
that Wi-Fi altogether. Route only your actual VLAN prefix — and if your home LAN is
itself on 192.168.1.0/24, put the smart-home VLAN somewhere unlikely
(192.168.73.0/24, say) so the clash can't arise in the first place.
Two client-side settings that are easy to get wrong together:
- Android "always-on VPN": on. "Block connections without VPN" (lockdown): OFF.
Lockdown drops everything not traversing the tunnel, which with a split tunnel kills
all non-
192.168.xtraffic on the device. - Leave
DNS =unset. Reach services by LAN IP (http://192.168.30.x:8090for ntfy). No internal DNS needed, and your DNS queries don't get pulled home while you're out.
This is just §3's rule 4 (route WireGuard peers into the smart-home VLAN, not Trusted LAN) expressed on the client side.
2.2 Decided: ntfy stays LAN-only, no DMZ, no port forward
identity's arrival notifications (Phase 6b — "tell me when someone gets home") were
the first thing in this project with a real argument for WAN exposure, so the
alternatives were weighed properly and rejected:
| Option | Verdict |
|---|---|
| ntfy stays in the compose stack, reached over WireGuard | Chosen. identity → ntfy is a container-to-container call that never reaches OPNsense at all — no firewall rule, no DMZ, no certificates. ntfy's Android app does instant delivery straight to a LAN server, so it works at home with nothing configured and away over the split tunnel above |
| ntfy in a DMZ, port-forwarded | Rejected. Buys only "notifications without the VPN connected," at the cost of a public listener, TLS/reverse-proxy upkeep, and mandatory ntfy auth — an exposed topic leaks "the house is empty right now", which is materially worse than the usual leak of a self-hosted service |
| ntfy in a DMZ, reached from the smart-home VLAN by NAT reflection (hairpin) instead of an inter-VLAN rule | Rejected. It does avoid a new rule — the smart-home VLAN's existing blanket outbound-WAN allow already covers traffic to your own public IP — but it makes a purely internal notification path depend on the ISP and external DNS being up, so two devices metres apart stop talking during a WAN outage. CGNAT breaks it outright, and the rule it avoids (smart-home → DMZ:443, one host, one port, outbound) was the safe direction anyway; DMZ → smart-home stays denied either way |
The household uses no Apple devices, which removes the one genuine forcing
function for exposure: ntfy's iOS app can only be woken by Apple's APNs, so a
self-hosted server would need upstream-base-url relaying through ntfy.sh — WAN
egress through a third party, in an otherwise fully-local stack. Android needs none of
that. Revisit this table only if an Apple device ever joins the household.
3. VLAN segmentation — not a DMZ, a blast-radius boundary
A traditional DMZ exists to host something the WAN needs to reach. Nothing here needs that (§1), so "do I need a DMZ" isn't really the right question — the useful question is whether an IoT device on this network should be able to reach your laptop, NAS, or anything else you actually care about if it's ever compromised. Zigbee/RuView nodes, ESPHome BLE proxies, and especially the Tapo cameras (Phase 20 — IP cameras have a genuinely bad industry-wide security track record) are the class of device this matters most for. The fix is a dedicated VLAN, not a DMZ.
A reasonable split for this project's device inventory:
| VLAN | What goes on it | Internet access | Reaches trusted LAN? |
|---|---|---|---|
| Trusted LAN (existing) | Your own laptops/desktops, phones (when not on the VPN) | Yes | — |
| Smart-home VLAN | The container host, kiosks/thin-clients/touch panels, RuView nodes, ESPHome BLE proxies, Zigbee coordinator | Yes (needed: Ollama model pulls if not fully pre-cached, container image pulls, NTP, and any digest-engine ingestion sources — mail/Telegram/Discord/news feeds/FRED/Stooq all reach out to real external APIs, see digest-engine/README.md) |
No, by default — see below |
| Camera VLAN | Tapo pan/tilt cameras (Phase 20), the existing peephole cam (Phase 5) | No — block entirely | No |
Rules to actually write in OPNsense (Firewall → Rules → <VLAN interface>):
- Inter-VLAN default-deny. OPNsense's default "allow all outbound, block inbound from other interfaces" behavior already gets you most of the way — just don't add a blanket "Smart-home VLAN → Trusted LAN, any/any" rule. Add narrow allow rules only for what's actually needed (e.g. if you want to browse to Home Assistant from a trusted-LAN laptop without a VPN, that's a Trusted LAN → Smart-home VLAN rule scoped to port 8123, not the reverse direction).
- Camera VLAN: block outbound to the WAN entirely, and block Camera VLAN →
every other VLAN except the one narrow path Frigate needs (RTSP/API from the
container host's own interface, since Frigate is what actually pulls camera
streams — the camera itself never needs to initiate anything toward the
container host, only receive the stream pull, so even that path can often be
a single allow rule scoped to the container host's IP and the camera's RTSP
port). This is the same invariant this project's own testing checklist already
states for the peephole cam (
docs/project-plan.md's Phase 5 guardrail, "are cameras verified to have zero WAN egress?") — it applies identically to every Tapo camera added in Phase 20. - Smart-home VLAN → Trusted LAN: deny by default, add narrow exceptions only if you have a real reason (e.g. Nextcloud/CalDAV living on a trusted-LAN machine rather than inside this stack — then just that one host:port, not the whole VLAN).
- WireGuard peers land wherever you configure the tunnel's allowed-IPs to
route — typically you'd route a WireGuard peer into the Smart-home VLAN
(so your phone, once connected, can reach
identity/pantry-vision/HA the same way it would sitting on that VLAN at home), not into Trusted LAN.
None of this is automated by anything in this repo — the container host's own network interface/VLAN tagging, and every rule above, is configured by hand in OPNsense, same "nothing under this repo builds the HA/network side" convention as the rest of this project's integration catalog entries.
4. Full port inventory (LAN-only — none of these get forwarded, see §1)
Every port setup-container-host.sh publishes, current as of Phase 20. ENABLE_*
column shows which are opt-in vs. always-on with the base stack.
| Port | Service | Always on? | Auth |
|---|---|---|---|
| 8123 | Home Assistant | Yes | HA's own login |
| 1883 | Mosquitto (MQTT) | Yes | MQTT username/password (mosquitto/config) |
| 8080 | zigbee2mqtt frontend | Yes | none by default — VERIFY you've set frontend.auth if this VLAN isn't fully trusted |
| 1880 | Node-RED | Yes | Node-RED's own login (if enabled) |
| 3000 | Homepage dashboard | ENABLE_HOMEPAGE (default on) |
none |
| 8090 | ntfy | ENABLE_NTFY (default on) |
ntfy's own auth (if configured) — off by default, treat the topic name as the only barrier until you set one. Carries identity's arrival notifications ("X just got home"), so an exposed topic leaks when the house is empty — this is the reason §2.2 keeps it LAN-only. Reach it from a phone at http://<container-host>:8090, at home or over the split tunnel |
| 9000 | Portainer | ENABLE_PORTAINER (default on) |
Portainer's own login |
| 445 | Gallery SMB share | ENABLE_GALLERY_SMB (default off) |
SMB username/password you set (GALLERY_SMB_USERNAME/PASSWORD) |
| 9925 | Mealie | ENABLE_MEALIE (default off) |
Mealie's own login |
| 5000, 8554, 8555 | Frigate (web UI, RTSP, WebRTC) | Yes (Phase 5+) | Frigate's own login for the web UI; RTSP/WebRTC unauthenticated on the LAN by Frigate's own design — this is exactly why the camera VLAN's isolation (§3) matters, not the port itself |
| 9283 | Grocy | Yes (Phase 1/7) | Grocy's own login |
| 8091 | digest-web | ENABLE_DIGEST_ENGINE |
none — read-only rendered output, no credentials in it by design |
| 8094 | admin-web | ENABLE_ADMIN_CANVAS |
none — same reasoning as digest-web |
| 8095 | pantry-vision | ENABLE_PANTRY_VISION |
bearer token (PANTRY_VISION_TOKEN) |
| 8096 | pantry-web | ENABLE_PANTRY_VISION |
none |
| 8097 | identity | ENABLE_IDENTITY |
bearer token (IDENTITY_TOKEN) |
| 8098 | identity-web | ENABLE_IDENTITY |
none |
| 8099 | transit | ENABLE_TRANSIT |
bearer token (TRANSIT_TOKEN) |
| 8100 | OpenTripPlanner (OTP) | ENABLE_TRIP_PLANNING |
none — OTP has no built-in auth; this is why it's only ever called server-side by transit, never exposed to a kiosk browser directly |
| ~8095 (VERIFY) | Music Assistant | ENABLE_MUSIC_ASSISTANT |
Music Assistant's own auth (if configured) — also flagged as an assumed/unverified port that collides with PANTRY_VISION_PORT, see docs/project-plan.md open decision #31 — resolve the actual port before relying on this table for it |
admin-canvas (port 8092) is deliberately not in this table — it has no
ports: mapping in the generated compose file at all, reachable only from other
containers on the compose network (i.e. Home Assistant), by design.
5. What's still unverified here
This entire document was written against setup-container-host.sh's current
ports: mappings, not tested against a real OPNsense instance — the VLAN/firewall
rule guidance in §3 is standard practice for this class of network, not something
run against your specific hardware/interface names. Confirm your OPNsense version
actually ships WireGuard where you expect (VPN menu) before planning around it,
and treat the port table in §4 as something to re-check against a live
docker compose ps after deployment, not a permanent guarantee — a new
ENABLE_* service added to the script later needs a new row here too.