One Name, Two Wrong Answers
You set up jellyfin.home.example.com with a public A record pointing at your WAN IP and a reverse proxy behind it. From a coffee shop it works. From your couch it hangs, because your router does not support NAT loopback and the packet goes out and never comes back. Then you turn on your VPN, and the phone either resolves the public IP again or cannot resolve the internal names at all.
The fix is split-horizon DNS. The same name answers with the LAN IP when the question comes from inside (home or VPN) and with the public IP when it comes from outside. You keep one hostname, one TLS certificate, and one bookmark.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/networking/split-horizon-dns-home-lab-vpn
This post uses home.example.com as the internal zone and 192.168.10.0/24 as the LAN. Swap in your own. Versions are current as of October 2026: AdGuard Home v0.107.79 and Unbound 1.26.1.
The Plan in Four Moves
- Run a resolver on the LAN that answers your service names with LAN IPs.
- Make every client at home use that resolver.
- Make every VPN client use that resolver, and make sure the VPN routes the LAN subnet.
- Stop browsers and phones from going around it.
Moves 1 and 2 are the DNS half. Moves 3 and 4 are where most people lose an evening.
Pick a Resolver (Opinion Included)
You have three realistic options.
- AdGuard Home: use it if you already run it. DNS rewrites are a form field in the UI, and you get ad blocking for free.
- Unbound: use it if you want config as code that lives in git and passes a syntax check before it goes live.
- dnsmasq: fine if it is already on your router or Pi-hole. Nothing here needs it as a new install.
Do not run two of them in a chain just to have both. Pick one and put it on a static IP.
Unbound: Config as Code
Unbound answers from local-data and resolves everything else normally. The zone type decides what happens to names you did not list. From the 1.26.1 man page:
transparent: if a name matcheslocal-data, answer it. Otherwise resolve the query normally. If the name exists inlocal-databut not with the requested record type, return NOERROR with no data.static: if a name matcheslocal-data, answer it. Otherwise answer NODATA or NXDOMAIN. Names you forgot simply do not exist.
For a home lab, transparent is the safe default. A forgotten name still resolves to its public answer instead of vanishing.
server: interface: 0.0.0.0 port: 53 do-ip6: no do-daemonize: no username: "" chroot: ""
# Who may ask: loopback, home LAN, WireGuard subnet. Everyone else is refused. access-control: 127.0.0.0/8 allow access-control: 172.16.0.0/12 allow access-control: 192.168.10.0/24 allow access-control: 10.8.0.0/24 allow
# Rebind protection: strip RFC1918 answers for public names. private-address: 10.0.0.0/8 private-address: 172.16.0.0/12 private-address: 192.168.0.0/16 private-domain: "home.example.com."
local-zone: "home.example.com." transparent local-data: "jellyfin.home.example.com. 300 IN A 192.168.10.20" local-data: "nextcloud.home.example.com. 300 IN A 192.168.10.20" local-data: "grafana.home.example.com. 300 IN A 192.168.10.21"Two parts of that file need explaining. The do-daemonize, username, and chroot lines exist because the container image runs Unbound in the foreground as its main process. Without do-daemonize: no, my test container started and exited at once with code 0.
The other is the rebind block. private-address removes A, AAAA, SVCB, and HTTPS answers that match the listed ranges, which blocks DNS rebinding attacks. The man page says no private addresses are configured by default, and that local-data you configured is allowed to contain private addresses without any extra setting. So the local-data above works even without private-domain.
Where private-domain matters: names that Unbound resolves from upstream, not from local-data. If your public zone has a record lab.home.example.com pointing at a LAN IP, and you set private-address but not private-domain, Unbound drops that answer. Listing the domain as private-domain allows it, including its subdomains. I tested the config above, not the upstream-record case, so treat that second paragraph as the man page’s claim.
Run it and test it:
services: unbound: image: klutchell/unbound:latest container_name: unbound restart: unless-stopped ports: - "53:53/udp" - "53:53/tcp" volumes: - ./unbound/unbound.conf:/etc/unbound/unbound.conf:rodocker run --rm -v "$PWD/unbound/unbound.conf:/etc/unbound/unbound.conf:ro" \ --entrypoint unbound-checkconf klutchell/unbound /etc/unbound/unbound.confdocker compose up -ddig @192.168.10.2 jellyfin.home.example.com +shortThe first command prints no errors or a line number. Run it before every restart. That check is the main reason to pick Unbound.
If port 53 is already taken, systemd-resolved is usually the holder. Free it or bind Unbound to a specific interface address.
AdGuard Home: Rewrites
In the UI, go to Filters, then DNS rewrites. In the file, rewrites live under the filtering key. A rewrite item has two fields, domain and answer. The answer can be an IP, a CNAME, or the special values A and AAAA, which keep the upstream records of that type. Wildcards work.
filtering: rewrites_enabled: true rewrites: - domain: jellyfin.home.example.com answer: 192.168.10.20 - domain: nextcloud.home.example.com answer: 192.168.10.20 - domain: '*.apps.home.example.com' answer: 192.168.10.20Two details I checked in the v0.107.79 source. There is a global rewrites_enabled switch, but the YAML item has no per-rewrite enabled field (a code comment lists adding one as a TODO). The REST API has an enabled field, but the YAML item does not store one. Edit the file with AdGuard Home stopped, or it overwrites your changes on shutdown.
If AdGuard Home is already your LAN resolver, stop here for the DNS half. You are done with move 1.
dnsmasq in One Paragraph
dnsmasq has two ways to do this. address=/home.example.com/192.168.10.20 answers that name and every subdomain with the IP. host-record=jellyfin.home.example.com,192.168.10.20 answers that exact name and does not expand it. Use host-record for named services and address= for a wildcard. The dnsmasq manual does not say what an AAAA query gets back when you give only an IPv4 address, so check with dig AAAA before you trust it.
Move 2: Point Home Clients at the Resolver
Set your router’s DHCP to hand out the resolver’s LAN IP as the only DNS server. A second public DNS server in the list means some clients will use it at random, and then the same name works on Tuesday and fails on Wednesday.
If the router will not let you change DHCP DNS, run DHCP on the resolver host or set DNS per device.
Move 3a: WireGuard
In a wg-quick client config, DNS = in the [Interface] section sets the resolver for the interface. wg-quick does this by calling resolvconf on bring-up. Other WireGuard implementations (the kernel module alone, or a hand-built ip link setup) ignore the key.
[Interface]PrivateKey = <client-private-key>Address = 10.8.0.2/32DNS = 192.168.10.2
[Peer]PublicKey = <server-public-key>Endpoint = vpn.example.com:51820AllowedIPs = 192.168.10.0/24, 10.8.0.0/24PersistentKeepalive = 25AllowedIPs is where the first big gotcha hides. It is the routing table for the tunnel. If the resolver answers 192.168.10.20 but AllowedIPs does not include 192.168.10.0/24, the name resolves and the connection times out. The DNS server itself sits at 192.168.10.2, so it needs that route too. Symptom: dig works, curl hangs.
Full tunnel versus split tunnel changes little for DNS. With AllowedIPs = 0.0.0.0/0, ::/0, wg-quick routes all traffic through the tunnel (it uses ip rule to override the default gateway), and the LAN subnet comes along for free. With the split list above, only home traffic goes through the tunnel and the coffee-shop traffic does not. For a home lab, I prefer split. You get no battery drain and no slow video call over your upload link.
Move 3b: Tailscale
Tailscale has its own DNS layer. MagicDNS names every device in your tailnet, and Tailscale’s docs say you cannot add arbitrary records to it. Tailscale has an “extra DNS records” feature, but its docs call it alpha and you must ask support to enable it, so this post uses your own resolver instead. Two settings do that.
- In the admin console, open the DNS page and add a nameserver. Restrict it to the search domain
home.example.comand set the address to192.168.10.2. A restricted nameserver only applies to queries that match that domain. This is what Tailscale calls split DNS. - Make the resolver reachable. Tailscale’s docs say you probably need subnet routing unless the nameserver is public or on a Tailscale IP. Run a subnet router on a LAN host and approve the route in the admin console.
# On a LAN host (the subnet router)sudo tailscale up --advertise-routes=192.168.10.0/24
# On a Linux client that should use those routessudo tailscale up --accept-routesTwo flags to know. --accept-dns defaults to true and means “use the DNS configuration from the admin console”. If you ran --accept-dns=false once on a server, that client ignores your split DNS rule and fails in confusing ways. --accept-routes defaults to off on Linux, and on for Windows, iOS, Android, and macOS. A Linux laptop that resolves the name but cannot reach it often just needs this flag.
The same ACL check applies as in the WireGuard case. If your ACL blocks the device from the nameserver, Tailscale’s docs say it cannot resolve DNS queries.
Leave “Override DNS servers” off unless you want every query to go through your tailnet nameservers. With it off, devices keep their local DNS and only use the restricted nameserver for your domain.
The Gotchas That Eat Your Weekend
Overlapping subnets
Your home is 192.168.1.0/24. So is the coffee shop. The client now has two networks with the same range, and your route to home loses. Pick an unusual home subnet such as 192.168.10.0/24 (still not safe, but rarer) or renumber into a random slice of 10.0.0.0/8. Tailscale also offers 4via6 subnet routers for this case. I did not test that here.
Browsers with DoH
Firefox with DoH on by default does not ask your resolver. Mozilla’s documented off-switch is the canary domain: if use-application-dns.net returns NXDOMAIN (or SERVFAIL, or NOERROR with no A and AAAA records), Firefox turns the default DoH off and uses the system resolver. Mozilla’s page also says the canary does not apply when the user turned DoH on deliberately. Your own Unbound can return NXDOMAIN for it:
local-zone: "use-application-dns.net." staticA static zone with no data answers NXDOMAIN. If you picked DoH in the browser settings, switch it off or add an exception. The browser’s DoH provider will happily answer with your public IP.
Android Private DNS
Private DNS set to a hostname sends queries over TLS to that provider and skips the resolver your DHCP or VPN supplied. Your split-horizon names vanish. Set it to Off or Automatic on devices that need internal names, or run a DoT endpoint yourself. Automatic mode only tries DoT against the resolver the network gave it, so it keeps your internal names.
IPv6 leaks the public answer
Your resolver returns an A record with the LAN IP, but the client also asks for AAAA. If your public zone has an AAAA, the client prefers it and goes out the front door again. With Unbound transparent and a name in local-data with no AAAA, I got NOERROR with an empty answer section in testing, which is what you want. With any other resolver, run dig AAAA and look. If you serve IPv6 on the LAN, add an internal AAAA to match.
TLS still works
The name is identical, so the same certificate matches. For a service reachable from the internet, your usual HTTP-01 setup keeps working. For an internal-only name, the CA cannot reach your server, so use the DNS-01 challenge: the ACME client proves control by writing a TXT record at your DNS provider. Caddy, Traefik, and certbot all support it with provider plugins. The public zone sees only the TXT record, not your LAN IP.
Testing It
Test the resolver directly, then the system path, because they fail separately.
# Ask the resolver itselfdig @192.168.10.2 jellyfin.home.example.com +shortdig @192.168.10.2 jellyfin.home.example.com AAAA
# Ask what the machine actually uses (systemd-resolved)resolvectl query jellyfin.home.example.comresolvectl status
# Then the connection, not just the namecurl -I https://jellyfin.home.example.comIf dig @192.168.10.2 is right and resolvectl query is wrong, a client DNS setting overrides your resolver (DoH, Private DNS, or a second DNS server in DHCP). If both are right and curl hangs, look at routes: AllowedIPs, --accept-routes, or an overlapping subnet.
The SumGuy Take
Run one resolver, give it a static IP, and make it the only DNS server your DHCP hands out. Use AdGuard Home rewrites if you already have it. Use Unbound if you want a file you can diff and syntax-check. Route the LAN subnet through your VPN, set the resolver in the VPN config, and test with dig and curl as two separate steps. Your 2 AM self will appreciate the split.
Common Questions
Do I need split-horizon DNS if my router supports NAT loopback?
Not strictly. NAT loopback fixes devices on the LAN, so the name works from your couch. Split-horizon DNS still earns its place: it keeps LAN traffic off the router’s WAN path, removes the dependency on a router feature, and gives VPN clients the LAN answer. NAT loopback alone does nothing for VPN clients.
Will split-horizon DNS break my public website?
No, as long as the zone type lets other names through. Unbound’s transparent type answers names in local-data and resolves every other name normally. Only the names you list get LAN answers. Outside clients keep asking public DNS and never see your internal IPs.
Does WireGuard’s DNS setting work in the Android and iOS apps?
Yes, the official WireGuard apps accept a DNS line in the tunnel config and apply it to the VPN interface (the Android and iOS app sources both parse it). The resolvconf mechanism described here belongs to wg-quick on Linux. Check Android Private DNS, which can still override the tunnel resolver.
Can I use a different domain for internal names instead of the public one?
Yes, and it is simpler for names that never need to be public. Use a subdomain you control, such as home.example.com, or the reserved home.arpa suffix. The trade-off is certificates: a public CA will not issue for home.arpa, so use a real domain with DNS-01 if you want trusted HTTPS.