Split-Horizon DNS: One Domain, Two Answers

Why the same hostname should resolve differently depending on where you're asking from

Contents

I run a handful of self-hosted services behind a real domain rather than raw IP addresses, because certificates, bookmarks and muscle memory all want a hostname. The awkward part is that nas.example.com needs to mean something different depending on who’s asking. From outside the house, it should resolve to my public IP so the reverse proxy at the edge can route it. From inside the house, it should resolve straight to the NAS’s private address — routing internal traffic out to the internet and back in again just to reach a machine on the same LAN is wasteful at best and broken at worst. Split-horizon DNS is the fix: the same domain name, two different answers, depending on where the query came from.

The problem it solves

Advertisement

Without split-horizon DNS, a home network with self-hosted services behind a public domain runs into one of two bad options.

The first is hairpin NAT (also called NAT loopback): a device on the LAN resolves nas.example.com to the router’s public IP, sends the request out, and the router has to recognise that the destination is actually itself and loop the traffic back inward. Plenty of consumer routers don’t implement this correctly, and even when they do, it’s an odd amount of extra work — and extra latency — for two devices sitting on the same switch to talk to each other.

The second is giving up and using two different names — an internal nas.mylab.local and an external nas.example.com — which works but means every device, bookmark, script and certificate has to know which network it’s on and pick the right one. It’s the kind of friction that seems minor until you’re debugging why a script that works from your laptop fails identically from a container on the same LAN, purely because of which hostname got hardcoded where.

I ran the two-names approach for about a year before switching, and the failure mode that finally pushed me off it was mobile devices. A phone that’s on Wi-Fi at home and then walks out the door onto mobile data doesn’t know it’s supposed to swap nas.mylab.local for nas.example.com — it just keeps trying to resolve whichever name got bookmarked, and depending on the app, that either fails outright or silently falls back to a cached IP that no longer applies. Bookmarks, home-screen shortcuts, and any app with a hardcoded server address all inherit the same problem. None of it is fatal, but it’s the sort of low-grade friction that quietly discourages self-hosting things you’d otherwise use daily, and it disappears completely once there’s exactly one name that always does the sensible thing.

Split-horizon DNS collapses both problems into one: nas.example.com is the only name that exists, and the answer depends on which network asked. Internal resolvers hand back the private LAN IP; anything asking from outside gets the public-facing answer. One name, one certificate (a wildcard or SAN cert covering example.com still works from both directions), and every device does the sensible thing automatically depending on where it happens to be.

Views in Unbound

Unbound — the resolver bundled with OPNsense and pfSense, and a solid standalone choice too — implements this with views, which bind a set of local zone data to specific client access-control entries. The internal view only answers with the private IP for clients matching an internal ACL; anyone else falls through to a normal recursive lookup, which reaches the same public DNS record everyone else on the internet sees.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# /etc/unbound/unbound.conf

server:
    interface: 0.0.0.0
    access-control: 192.168.1.0/24 allow
    access-control: 0.0.0.0/0 refuse

view:
    name: "internal"

view:
    name: "internal"
    view-first: yes

access-control-view: 192.168.1.0/24 internal

local-zone: "example.com." transparent
local-zone-tag: "internal" "example.com."

local-data: 'nas.example.com. IN A 192.168.1.20' view:"internal"
local-data: 'proxy.example.com. IN A 192.168.1.5' view:"internal"

Anything not matching the 192.168.1.0/24 access-control entry never sees the internal view’s local data at all — the query falls through to Unbound’s normal recursive resolution and gets whatever public record actually exists for example.com. That fallback is the part that makes this maintainable: you define local overrides only for the handful of names that actually need an internal answer, and everything else resolves normally without any duplicated zone data to maintain.

Views in BIND

Advertisement

BIND has had view as a first-class top-level construct for longer than Unbound, and it reads a little more explicitly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// /etc/bind/named.conf

acl internal_net { 192.168.1.0/24; };

view "internal" {
    match-clients { internal_net; };
    recursion yes;

    zone "example.com" {
        type master;
        file "/etc/bind/zones/db.example.com.internal";
    };
};

view "external" {
    match-clients { any; };
    recursion no;

    zone "example.com" {
        type master;
        file "/etc/bind/zones/db.example.com.external";
    };
};

BIND’s approach maintains two entirely separate zone files rather than a handful of overrides layered on recursive resolution, which is more explicit but also more upkeep — every record that should be identical on both sides (an MX entry, say) has to be kept in sync across two files by hand, or templated with something like m4 if the zone is large enough to make manual sync error-prone.

Views in CoreDNS

CoreDNS takes the plugin-chain approach rather than a native views concept, composing behaviour from small, single-purpose plugins per server block. The equivalent split is usually built with the view plugin (available from CoreDNS 1.9+) alongside separate zone files or the hosts plugin for the internal overrides:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Corefile

example.com:53 {
    view internal {
        expr client_ip() in ['192.168.1.0/24']
    }
    hosts {
        192.168.1.20 nas.example.com
        192.168.1.5  proxy.example.com
        fallthrough
    }
}

example.com:53 {
    forward . 1.1.1.1 9.9.9.9
}

fallthrough in the hosts block is doing the same job as Unbound’s view-not-matched fallback: names not explicitly listed pass through rather than returning NXDOMAIN. CoreDNS is the natural fit if you’re already running it as your Kubernetes cluster’s internal DNS and want the same split-horizon behaviour to extend to the rest of the homelab without introducing a second DNS server technology.

Certificates still work the same way

The one worry people raise before setting this up is TLS: if nas.example.com resolves to a private address internally, will a browser still trust the certificate it gets served? Yes, and the reason is worth spelling out plainly, because it trips up people who assume DNS and certificate validation are more entangled than they actually are. A certificate is issued for a name. A Let’s Encrypt certificate obtained through the ACME DNS-01 challenge proves control of the example.com DNS zone itself, entirely separate from whatever address that name happens to resolve to at any given moment. Once issued, that certificate is valid for nas.example.com regardless of which IP a given client’s resolver handed back, internal or external.

The practical upshot is that split-horizon DNS and automatic certificate renewal via Caddy or another ACME client coexist without any special handling — the certificate renewal process typically runs from wherever the reverse proxy lives and uses the DNS-01 or HTTP-01 challenge against the public DNS zone, entirely independent of what the internal view happens to be serving to LAN clients at that moment. The only thing to double check is that the internal view’s IP actually points at a host that’s also presenting that same certificate — a common early mistake is pointing the internal override straight at a backend service that’s never had TLS terminated on it directly, expecting the browser to somehow inherit the reverse proxy’s certificate by magic.

Why this beats hairpin NAT outright

Advertisement

Beyond the reliability question — plenty of consumer and even some prosumer routers implement NAT loopback poorly or not at all — split-horizon DNS has a latency argument that’s easy to underrate until you measure it. Hairpin NAT means every internal request to a self-hosted service takes a round trip through the router’s NAT table, sometimes through the ISP’s edge equipment if the loopback implementation is lazy about it, and back. Split-horizon DNS means the client gets the LAN IP directly and talks to the NAS at switch speed. For anything latency-sensitive — a self-hosted game server, a real-time sync tool like the one covered in the Syncthing piece, or just a NAS mount that feels sluggish over hairpinned traffic — the difference is very noticeable.

It also plays cleanly with ad-blocking DNS setups. If you’re running Pi-hole in front of Unbound, the split-horizon local zone data belongs on the upstream Unbound resolver, with Pi-hole simply forwarding to it — Pi-hole itself doesn’t need to know anything about the internal/external distinction, it just needs to ask the resolver that does.

The DNS-over-HTTPS wrinkle

Split-horizon DNS assumes every client on the network actually asks your resolver. That assumption used to be safe and increasingly isn’t. Firefox ships with DNS-over-HTTPS enabled by default in several regions, routing queries straight to Cloudflare or NextDNS over an encrypted channel that completely bypasses whatever DHCP handed out as the local DNS server — and, by extension, bypasses your split-horizon view entirely. A browser configured this way asks Cloudflare for nas.example.com, gets the public answer even from inside the house, and the internal view you built never even sees the query. Chrome does something similar depending on the platform and whether the configured resolver supports DoH itself, in which case it upgrades to encrypted queries against the same server rather than switching providers outright.

The fix is to make the internal resolver the one doing DoH upstream, rather than leaving individual applications to pick their own path. Unbound supports upstream DoH natively as of recent versions, and pairing that with a firewall rule blocking outbound port 853 (DNS-over-TLS) and the well-known public DoH endpoints forces every device back onto the resolver that actually knows about the internal view. It’s an extra piece of firewall bookkeeping, but skipping it means split-horizon DNS quietly stops working for exactly the subset of devices — modern browsers — that people use most.

Verifying it actually works

Before trusting the setup, check both sides deliberately rather than assuming the config did what it was supposed to. From a device on the internal VLAN, query the resolver directly and confirm it hands back the private address:

1
2
dig +short nas.example.com @192.168.1.1
# expect: 192.168.1.20

Then repeat the same lookup against a public resolver, or from a phone on mobile data with Wi-Fi off, and confirm it returns the public-facing address instead:

1
2
dig +short nas.example.com @1.1.1.1
# expect: your public IP or reverse-proxy address

Seeing the same answer from both is the most common sign something’s misconfigured — usually the internal view’s ACL not matching the client, or the local zone accidentally applying more broadly than intended.

Troubleshooting

Internal clients get the public IP instead of the private one. The access-control-view (Unbound) or match-clients (BIND) list almost certainly doesn’t match the client’s actual source address as the resolver sees it — a common cause is a client behind a second layer of NAT or a VPN whose apparent source address falls outside the configured internal range. Confirm with dig nas.example.com @<resolver-ip> run directly against the resolver from the client in question and check what ACL entry actually matched.

External resolution suddenly breaks after adding split-horizon config. Double-check that the internal view’s local-zone is genuinely scoped only to internal clients and that the external-facing answer for example.com — served by your actual public authoritative DNS provider, not by the homelab resolver at all — hasn’t been accidentally shadowed. Public queries for example.com should never reach your home resolver in the first place; if they can, something upstream is misconfigured.

A device gets inconsistent answers depending on which resolver it happens to query. This shows up on networks where DHCP hands out multiple DNS servers and not all of them implement the split — a phone might cache the router’s DNS but a container on a VLAN with a different upstream doesn’t see the same view. Standardise on a single internal resolver for every VLAN that needs the split behaviour rather than mixing resolvers with different local data.

TLS certificate errors only from inside the LAN. If the internal view answers with the LAN IP but the service listening there presents a certificate that doesn’t cover that use case — say, it’s only valid because Let’s Encrypt validated the public-facing hostname — the fix is making sure the same certificate (a wildcard, or a SAN cert with the exact name) is what’s actually served on the internal IP too; the DNS answer changing doesn’t change what certificate the backend presents.

Is it worth it

For a single self-hosted service reachable by exactly one device, this is more infrastructure than the problem deserves. The moment there’s more than one internal client and more than one self-hosted service behind a real domain, split-horizon DNS stops being a nice-to-have and starts being the thing that makes internal traffic behave the way you’d expect — fast, direct, and not dependent on your router correctly hairpinning NAT. It’s a modest amount of resolver configuration for a problem that otherwise nags at you indefinitely.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.