Table of Contents
Runbook 15: Split-DNS and LAN-Only Services
How I made a reverse-proxied service reachable from the LAN and the tailnet but blocked from the raw internet, using split-DNS instead of fighting nginx. The first case was the Transmission web UI, but the pattern is meant to be reusable for every future LAN-only service. Notes-to-self that double as a blog draft; verify the exact IPs against the boxes before publishing.
Key facts I keep needing: OPNsense (itg-net-opn) runs Unbound, the resolver for 10.10.10.0/24. SWAG on itg-prd-web (10.10.10.83) fronts every *.itguyeric.com on 443. The cert is a wildcard *.itguyeric.com via Cloudflare DNS-01, so internal clients hitting the same hostname get a valid cert with no warning. The Tailscale subnet router from Runbook 13 advertises 10.10.10.0/24 and SNATs subnet traffic by default.
The problem: an IP allowlist that can never match
I wanted the Transmission web UI LAN-only while keeping the BitTorrent peer port (51413) reachable from the WAN so MyAnonaMouse can probe connectability. The peer port is a clean OPNsense port-forward that never touches SWAG, so that half is easy: leave the NAT rule alone.
The web UI is proxied by SWAG, so I added the obvious nginx guard to transmission.subdomain.conf, at the server level so it covers both location / and the regex /rpc block (the /rpc API is the dangerous one, and a regex location outranks the prefix /, so a rule placed only in location / leaves the real control endpoint wide open):
allow 10.10.10.0/24;
deny all;
Then everything got a 403. Not just my phone on cellular, which is correct, but my Mac over Tailscale too, which is not. That second failure is the whole lesson.
The error log told the truth:
access forbidden by rule, client: 174.196.33.167, server: transmission.*, request: "POST /transmission/rpc HTTP/2.0", host: "transmission.itguyeric.com"
That client: is a real public IP, not a Cloudflare edge address and not the gateway. So nginx is seeing true client IPs; the rule works perfectly. The reason even my own devices were blocked is more basic: every client, LAN and tailnet included, was resolving transmission.itguyeric.com to the public IP, going out to the internet, and hairpinning back through the WAN. So they all arrived at nginx wearing a public source address, never a 10.10.10.x. Tailscale did not save me because the subnet route only carries traffic destined for 10.10.10.x, and DNS never handed my Mac a 10.10.10.x to aim at.
This is also why only Transmission broke and no other subdomain did. The others have no IP restriction, so hairpinning through the public path is invisible. The moment you filter by IP, that public-path arrangement can no longer tell "me" from "the internet."
The fix: split-DNS
Make LAN and tailnet clients resolve the subdomains to SWAG's internal address, so they reach nginx with a 10.10.10.x source. Two pieces, and one rule that is easy to get wrong.
The names point at the reverse proxy, not the app
Every split-DNS name resolves to the SWAG box (10.10.10.83), never to the box the app runs on. SWAG terminates TLS, applies the allowlist, and proxies on to the backend (Transmission itself lives on the book box at 10.10.10.226:9091, but the client never talks to it directly). I pointed the override at the wrong host once and got ERR_ADDRESS_UNREACHABLE, because nothing serves 443 there. So a name that loads over the public path but dies internally almost always means the override is aimed at the app instead of SWAG.
Unbound host overrides (LAN clients)
Services, Unbound DNS, Overrides, Host Overrides. Add one specific override per name that needs to resolve internally, each pointing at SWAG:
- Host
transmission, Domainitguyeric.com, TypeA, IP10.10.10.83.
Keep any name that lives on its own box as its own override (Host git to 10.10.10.24, the Forge SSH host). Specific overrides render as transparent local-data and coexist without complaint.
The tempting shortcut is a single wildcard (Host *) so every future service resolves to SWAG with no new entry. Do not do it through the Overrides UI. OPNsense renders a wildcard override as a local-zone ... redirect, and a redirect zone returns its one answer for every name in the zone, so it forbids any specific exception below the apex. The moment a wildcard and a specific entry (my git) share a domain, Unbound refuses to start:
error: local-data in redirect zone must reside at top of zone, not at git.itguyeric.com IN A 10.10.10.24
fatal error: failed local-zone, local-data configuration
Run configctl unbound check after every override change; it names the offending line. If you genuinely want the uniform wildcard, it has to be a transparent zone (where a wildcard and specific exceptions coexist, closest match wins). Current OPNsense has no Custom options box for Unbound, so that lives in an include file: find the include glob with grep include /var/unbound/unbound.conf, drop a .conf there declaring local-zone: "itguyeric.com." transparent plus the wildcard and exception local-data lines, and remove the itguyeric.com GUI overrides so the zone is not declared twice. For a handful of services the specific overrides are simpler, and that is where I left it.
Local host data is local-data, so DNS rebind protection does not apply (it only inspects forwarded upstream answers).
Tailscale split-DNS (remote clients)
The overrides only help devices querying Unbound. On the LAN that is automatic. Over Tailscale the client is still on public DNS, so point it back: Tailscale admin console, DNS, Nameservers, add a custom nameserver set to the OPNsense LAN IP (10.10.10.1 or whatever itg-net-opn is on the LAN), and enable "Restrict to search domain" for itguyeric.com.
Then a tailnet query for itguyeric.com goes to Unbound over the tunnel, returns the internal 10.10.10.83, and the client routes there through the subnet router. Because Tailscale SNATs subnet-route traffic to the router's own LAN IP by default, the request lands at SWAG as a 10.10.10.x and passes the allowlist. This relies on the Runbook 13 subnet route being approved and the client accepting routes.
Verify
- LAN:
dig transmission.itguyeric.comreturns10.10.10.83; the page loads. - Tailscale, off the LAN: the same
digreturns the internal IP; the page loads;sudo podman exec swag grep -i forbidden /config/log/nginx/error.logshows no new denies for me. - Public or cellular: still resolves the public Cloudflare record, still comes in the WAN, still 403. Correct.
- After changing any override, flush the client cache or you keep getting the old answer: on the Mac
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder, and clear Chrome's resolver atchrome://net-internals/#dns.
Gotchas, in the order they bit
- Put the allow/deny in the server block, not
location /. The/rpcregex location outranks the prefix location, so a rule only inlocation /leaves the control API reachable. Oneallow/denypair at server scope blankets both. - Read the denied
client:before choosing a fix. A Cloudflare edge IP would mean orange-cloud proxying and a real-IP problem; the gateway IP would mean NAT reflection; a real client IP means the rule is fine and the path is wrong. Same 403, three different fixes. The log line decides. - Point the override at SWAG, not the app's own box. The name resolves to the reverse proxy (
10.10.10.83); aiming it at the backend (.226, or a fat-fingered.126) givesERR_ADDRESS_UNREACHABLEbecause nothing serves 443 there. - A UI wildcard override is a redirect zone, and it cannot hold exceptions. Mixing a wildcard with any specific override in the same domain is a fatal
local-data in redirect zoneerror and Unbound will not start. Use specific overrides, or atransparent-zone include file for a true wildcard.configctl unbound checknames the bad line. - Current OPNsense has no Unbound Custom options box. Custom directives go in an include file under the glob shown by
grep include /var/unbound/unbound.conf, not a GUI textarea. - Tailscale SNAT is load-bearing. Tailnet clients arrive as
10.10.10.xonly because subnet-route SNAT is on by default. With--snat-subnet-routes=falsethey arrive as100.64.0.0/10and the allowlist blocks them again; keep SNAT on or add the CGNAT range to the allow. - Use the hostname, not a raw IP, internally. The wildcard cert matches
*.itguyeric.com, sohttps://transmission.itguyeric.comfrom inside is clean; a rawhttps://10.10.10.83throws a cert mismatch.
Related setting worth doing at the same time
Plex tags local 10.10.10.x clients as "Remote" until you tell it its own LAN: Settings, Network, "LAN Networks" = 10.10.10.0/24. Same class of "the server does not know what counts as local" problem, different app. It affects which bandwidth limits Plex applies, not transcoding.
Runbooks
The build
- Image Mode & Base
- Storage
- Hostnames & DNS
- Registry
- Actions Runner
- The Pipeline
- Nightly Auto-Deploy
- First Workload
- VSCode Cockpit
- Hugo Auto-Deploy
- Cloudflare & Kobo
- Runner Provisioning
- Tailscale Router
- SWAG & Website
- Split-DNS
- Plex
- Media Library Support
- The Day After
- Self-Hosted Media
- Hypervisor Joins the Fleet
- The Matrix Homeserver
Reference