Split-Horizon DNS#
What Is It?#
Split-horizon DNS means the same domain name points to different addresses depending on where you are. When you type jellyfin.yourdomain.ca into your browser:
- At home → it resolves to your Traefik
server’s internal IP (e.g.,
10.10.0.100) — traffic stays on your local network - On the internet → it resolves to your public VPS IP (e.g.,
203.0.113.50) — traffic goes through a tunnel
Think of it like a restaurant with two entrances: one for locals (the back door, fast and direct) and one for visitors (the front door, through the lobby). Both entrances lead to the same restaurant, but the path is different depending on who you are.
Why Does It Matter?#
Without split-horizon DNS, you’d have one of two problems:
Problem 1: Everything Goes Through the Internet#
If your domain only resolves to your public VPS IP, even traffic from inside your house would leave your network, travel to the VPS, then tunnel back home. That’s slow and wasteful — like driving around the block to reach your neighbour.
Problem 2: You Need Two Addresses#
Without split-horizon, you’d have to bookmark https://10.10.0.100 for home use and https://jellyfin.yourdomain.ca for internet use. That’s confusing and breaks features that rely on a consistent URL (like SSO redirects and app-to-app links).
Split-horizon gives you the best of both worlds: one URL that always works, routing through the fastest path available.
How PSW Sets It Up#
PSW uses two DNS providers that work together, both built on top of the networking setup:
Local DNS (PSW-Managed)#
The Local DNS provider creates internal DNS records that point every app subdomain to the Traefik reverse proxy’s internal IP. Where those records live depends on your router mode :
- OPNsense mode — records live in Unbound
on your OPNsense
router, written via the
opnsense_unboundbackend. - ISP-router mode — records live in AdGuard Home
running on the bootstrap target
, written via the
adguard_homebackend. AdGuard upstreams to a recursive Unbound sidecar on the same target so non-PSW queries (Google, GitHub, etc.) resolve recursively too.
When a device on your home network asks “where is jellyfin.yourdomain.ca?”, the local-DNS server answers with the internal IP — so the request goes directly to Traefik without ever leaving your network.
Your phone (home WiFi)
→ asks <PSW DNS server>: "where is jellyfin.yourdomain.ca?"
→ server answers: "10.10.0.100" (Traefik's internal IP)
→ phone connects directly to Traefik
→ Traefik forwards to Jellyfin's target
<PSW DNS server>is your OPNsense IP in mode A, and the bootstrap target’s IP in mode C. In both cases, set it as the LAN’s “DNS server” / DHCP option 6 so every device gets it automatically.
Recursive resolution (the privacy bit)#
The local-DNS server doesn’t just answer for *.yourdomain.ca — it also resolves every other DNS query (Google, GitHub, your bank). PSW always configures it for recursive resolution: queries go straight from root → TLD → authoritative servers. No public resolver (Cloudflare, Quad9, Google) sees your DNS traffic.
- OPNsense mode — Unbound on the OPNsense box runs in recursive mode (
general.forwarding = 0). PSW asserts this on every bootstrap and convergence cycle; if someone toggles forwarding back on in the OPNsense GUI, the next prepare-step fails loudly. - ISP-router mode — AdGuard’s only upstream is
127.0.0.1:5335(the Unbound sidecar on the same target). The sidecar has zero forwarders configured. Same guarantee.
This is the property a forwarder-mode setup can’t give you: even Cloudflare’s well-intentioned 1.1.1.1 would still see every domain you ever query. Recursive eliminates that exposure.
WAN DNS (Cloudflare)#
The WAN DNS provider creates public DNS records on Cloudflare . These records point app subdomains to your VPS’s public IP, where Pangolin (the tunnel server) forwards traffic through a WireGuard tunnel to your self-hosted solution.
When someone on the internet asks “where is jellyfin.yourdomain.ca?”, Cloudflare answers with the VPS IP — and the traffic reaches your self-hosted solution through the secure tunnel.
Your phone (coffee shop WiFi)
→ asks Cloudflare: "where is jellyfin.yourdomain.ca?"
→ Cloudflare answers: "203.0.113.50" (VPS public IP)
→ phone connects to Pangolin on the VPS
→ Pangolin tunnels the request to your self-hosted solution via WireGuard
→ Traefik forwards to Jellyfin's targetHow They Don’t Conflict#
The reason this works is DNS query order. Devices on your home network are configured (via DHCP ) to ask the PSW DNS server first — that’s OPNsense in mode A, the bootstrap target in mode C. Either way, that server has the internal records and answers immediately; the device never asks Cloudflare.
Devices outside your network don’t know about your local DNS, so they ask public DNS servers, which eventually reach Cloudflare for the public records.
When Records Are Created#
PSW manages DNS records automatically — first during bootstrap , then on every convergence tick:
| Event | Local DNS | WAN DNS |
|---|---|---|
| Bootstrap, before core apps deploy | Records created for every core app (auth.<domain>, git.<domain>, …) pointing to the bootstrap target | — |
| App deployed with a subdomain | Record created pointing to Traefik IP | — |
App exposed with psw remote expose | — | Record created pointing to VPS IP |
| App removed | Record deleted | Record deleted |
Bootstrap pushes local DNS before deploying any core app because Forgejo’s setup reconciler waits for https://auth.<domain>/.well-known/openid-configuration to resolve — without DNS in place first, that reconciler would time out. Bootstrap actually does it in two steps: step 5 makes the local-DNS backend ready (a fast API check in OPNsense mode; AdGuard + Unbound container deploy in ISP-router mode), then step 6 pushes the records. See the full bootstrap step list
.
Both providers are idempotent
— running convergence multiple times doesn’t create duplicate records, and every record PSW writes carries a psw-managed marker so orphan cleanup
can remove stragglers later without touching anything you added by hand.
The Full Picture#
Here’s how a request flows through the system for an exposed app:
┌──────────────────────────────────────────────────────────┐
│ AT HOME │
│ │
│ Browser → OPNsense DNS → 10.10.0.100 (Traefik) → App │
│ "jellyfin.yourdomain.ca = 10.10.0.100" │
│ Direct, fast, stays on LAN │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ ON THE INTERNET │
│ │
│ Browser → Cloudflare DNS → 203.0.113.50 (VPS/Pangolin) │
│ → WireGuard tunnel → Traefik → App │
│ "jellyfin.yourdomain.ca = 203.0.113.50" │
│ Encrypted, through tunnel │
└──────────────────────────────────────────────────────────┘Same URL, same app, same HTTPS certificate — but the path adapts to where you are.
HTTPS Certificates#
Split-horizon DNS works seamlessly with HTTPS because Traefik
handles certificates using the ACME
protocol (via Let’s Encrypt
). The certificate is issued for jellyfin.yourdomain.ca regardless of which IP the domain resolves to — so HTTPS works from both home and the internet.
Traefik uses the DNS-01 challenge (proving domain ownership by creating a DNS record via the Cloudflare API) rather than the HTTP-01 challenge (which would require the server to be publicly reachable). This means certificates work even for apps that aren’t exposed to the internet.
Stale DNS on Your Workstation#
Right after PSW creates or changes a DNS record, your workstation may still
serve the old answer — Linux’s systemd-resolved
caches every lookup, and the cache survives past the record’s TTL when the
upstream resolver itself was caching. Result: a brand-new
dashboard.yourdomain.ca shows a “site can’t be reached” error in your
browser even though the record exists in Cloudflare
.
The fix is one command:
resolvectl flush-cachesThat drops every cached entry. The next browser request re-asks the upstream
resolver and gets the fresh answer. If you’re not on systemd-resolved (some
distros use dnsmasq
or
NetworkManager
with its own cache), the
equivalent commands are sudo systemctl restart dnsmasq or
nmcli general reload.
This is rarely needed in practice — most lookups hit a fresh upstream — but it’s the first thing to try when a brand-new app’s URL doesn’t resolve and you’re sure PSW shipped the record. The wizard’s “Open Dashboard” button shows the same hint after bootstrap completes.
Key Ideas#
- One URL everywhere —
app.yourdomain.caworks from home and from the internet - Fast at home — local DNS routes traffic directly to Traefik, no tunnel needed
- Secure outside — internet traffic goes through an encrypted WireGuard tunnel via Pangolin
- Automatic — PSW creates and removes DNS records during convergence via providers
- Transparent — your apps don’t know or care which path traffic took; they just see HTTPS requests