Set Up Split-Horizon DNS at Home

Last verified July 19, 2026View as Markdown

Set Up Split-Horizon DNS at Home

Split-horizon DNS gives one hostname two different answers depending on which network is asking. Inside your house, app.example.com resolves to a box on the LAN and serves the real, live application. From anywhere else, the same URL resolves to Cloudflare and serves a public, read-only snapshot. One address to remember, one address to print on a label — two very different capabilities behind it.

The payoff is that you stop choosing between “useful at home” and “shareable.” The cost is that the seams are invisible when they break: three separate mechanisms can quietly route a device on your own LAN to the public version, and none of them show up in a dig you’d think to run. Those are in §5, and they’re the reason this guide exists.


The shape

LAN client ──▶ local resolver (Pi-hole, 192.168.1.53)
                 override:  app.example.com  A  192.168.1.100
                 authority: local=/app.example.com/     ← closes the leaks (§5)
             ──▶ 192.168.1.100  reverse proxy, wildcard *.example.com cert
             ──▶ 192.168.1.103:8080  the live app

Internet   ──▶ public DNS (Cloudflare, authoritative for example.com)
                 app.example.com  →  proxied / "orange"  →  Cloudflare edge
             ──▶ static host serving the scrubbed snapshot

Same URL. Same certificate authority. Two answers.


What each side gets — decide this first

Write down the split before you touch a DNS record, because it determines everything downstream:

The rule worth internalizing: the name is the irreversible part; the exposure is reversible forever. Once the hostname is printed on stickers or shared with people, you’re stuck with it. What you serve at that hostname you can change any afternoon. So pick the name for the audience, and let capability follow.

If what you actually want is the live app available remotely, split-horizon is the wrong tool — put an authentication wall in front of it instead. See Restrict a Website to Specific People with Cloudflare Access. Publishing an unauthenticated self-hosted app to the internet because it was convenient is how inventories become world-editable.


1. The public record — and the wildcard underneath it

Most homelabs already point a wildcard at their reverse proxy so every internal name resolves without a per-name record:

Record Value Proxy
*.example.com 192.168.1.100 off (“grey”)
app.example.com Cloudflare Pages / your static host on (“orange”)

Two things are doing work here.

The wildcard is grey, never orange. It publishes a private 192.168.1.x address to the world, which looks alarming and is harmless — the internet cannot route to RFC 1918 space. What it buys you is that every other internal name (print, nas, grafana) resolves to your proxy from anywhere, so a laptop that’s home gets straight there with no VPN and no local record at all. The rule: mirror the name, invert the cloud setting.

A specific record beats the wildcard. That’s the entire public half of the split — app.example.com escapes the wildcard because it has its own record, so it’s the one name that points somewhere public while everything else stays grey and LAN-only.

The corollary bites later: “it’s on my domain” does not mean “it’s LAN-only.” Only grey-wildcard names are. The day you add a specific orange record for some other service, that name becomes internet-reachable — deliberately or not.


2. The local override

Now shadow the public record on your own network. In Pi-hole that’s a local DNS record:

pihole-FTL --config dns.hosts '[ "192.168.1.100 app.example.com" ]'

Three things to know before you run it:

Verify from a client, not from the resolver itself:

dig +short app.example.com @192.168.1.53
# 192.168.1.100

That’s the naive version, and it’s where most write-ups stop. It is not enough — see §5.


3. TLS — a valid public certificate on a box the internet can’t reach

The obvious objection: Let’s Encrypt has to verify you control the name, and your internal proxy isn’t reachable from the internet, so how does it get a certificate that browsers accept?

Use a DNS-01 challenge. Instead of proving control by answering an HTTP request, your proxy proves it by writing a TXT record into the domain’s zone through your DNS provider’s API. That never depends on where the A record points or whether anything is publicly reachable. Issue a wildcard*.example.com — and every internal hostname is covered by one certificate that renews itself.

Most reverse proxies support this directly. In Nginx Proxy Manager it’s SSL Certificates → Add → Use a DNS Challenge, pick your provider, paste an API token.

Two consequences worth stating plainly:

Point the proxy host at the app (http://192.168.1.103:8080), assign the wildcard certificate, and the inside horizon is done.


4. What to serve outside — the scrub-and-publish pattern

The public side should be a static snapshot, generated from the live app and deployed to a static host. Nothing dynamic means nothing to exploit and nothing to keep patched. Host a Free Website with Cloudflare Pages and GitHub covers the hosting half; this is the generation half.

Whitelist the fields you copy — never blacklist. Walk the live API’s response and build the public object out of explicitly-named fields:

def scrub(item):
    return {k: item[k] for k in ("id", "name", "category", "quantity") if k in item}

Blacklisting (“copy everything except cost”) leaks the next field you add. A whitelist fails closed: a new field is absent from the public copy until you deliberately add it.

Write the snapshot to the exact path the page already fetches. If the app’s front end does a relative fetch('/api/v1/items'), save the scrubbed JSON at /api/v1/items in the static build. Now the same unmodified HTML runs on both horizons — live API inside, flat file outside — and you maintain one page instead of two. If your static host does SPA-style catch-all rewrites, make sure the data file wins over the catch-all:

/api/v1/items  /api/v1/items  200
/*             /index.html    200

Rebuild on a schedule. An hourly cron on the box that can reach the live app is plenty; have it read the live API, scrub, and deploy. Stamp the build with its own timestamp ({"snapshot_at": "...", "count": 309}) so a stale public site is diagnosable instead of merely suspicious. Give the deploy token permission to publish to that one project and nothing else.

Expect the public copy to be genuinely narrower, and let that be by construction rather than by editing. Deep-link routes the live app serves (/items/1042) don’t exist in a flat snapshot; a price or a private note simply isn’t in the whitelist, so a “total value” line renders inside and renders empty outside — from the same template, with no conditional.


5. Three ways it breaks silently

Every one of these routes a device on your own LAN to the public site while dig says everything is fine. This is the section to actually read.

The ECH splice — “LAN address, cloud crypto”

The symptom is bizarre and specific: Chrome fails with ERR_SSL_UNRECOGNIZED_NAME_ALERT. Safari works. curl works. Another machine on the same LAN works. It reads exactly like a Chrome bug, and it is not.

Modern browsers don’t just ask for A. They also ask for HTTPS/SVCB (DNS record type 65), which is where a proxied provider publishes its Encrypted Client Hello key. Your resolver overrode the A record — but happily forwarded the type-65 query upstream. So Chrome ends up holding your LAN address paired with the public provider’s ECH configuration. It encrypts the server name with a key only Cloudflare holds and sends it to your proxy, which cannot decrypt it, cannot tell which virtual host is wanted, and rejects the handshake.

This only affects a name that is both publicly proxied and locally overridden — which is precisely the one name split-horizon creates. It’s a latent trap, not a one-off.

The fix is to make your resolver authoritative for the name, so it answers every record type itself instead of forwarding the ones it has no opinion about:

pihole-FTL --test   # ALWAYS gate on this first — see below
pihole-FTL --config misc.dnsmasq_lines '[ "local=/app.example.com/" ]'

local= is name-scoped: other names still ride the wildcard normally. Two hard warnings:

Keep the directive under version control — carefully

This line is now load-bearing for both leaks above, and it’s the one most likely to go missing: raw dnsmasq directives usually sit outside whatever config-as-code covers your DNS records. That asymmetry is the trap. Rebuild a resolver from declared state and you get the A override back without the authority line — which is exactly the broken configuration, silently, while your tooling reports everything in sync.

Bringing it under management is worth doing, and worth doing carefully, because the failure mode is not “wrong answer” — it’s the resolver refusing to start. On the box holding your VIP that’s a whole-house outage. Three rules earn their keep:

Two things to know about testing this one: verify in an incognito window, which shares the resolver but not the profile cache — otherwise you’ll be judging a cached failure. And check the address bar, not just that a page loaded; the whole failure mode here is a page loading successfully from the wrong horizon.

The IPv6 leak

dns.hosts overrides A. It does not override AAAA. So this passes:

dig +short A app.example.com @192.168.1.53      # 192.168.1.100  ✓

…while an IPv6-capable client on the same LAN resolves AAAA, gets the public provider’s global address, and lands on the snapshot. Silently. The live app is right there and the browser never asks for it.

If IPv6 is off network-wide today, this is dormant — and it activates the day you turn IPv6 on, long after you’ve forgotten this record exists. The good news is that the local= directive above fixes it as a side effect: once the resolver is authoritative for the name, any type it has no record for returns NODATA rather than being forwarded. Confirm both, not just the one you were worried about:

dig AAAA   app.example.com @192.168.1.53 | grep -E 'status:|ANSWER:'
dig TYPE65 app.example.com @192.168.1.53 | grep -E 'status:|ANSWER:'
# both: status: NOERROR ... ANSWER: 0     ← NODATA, no leak

DNS-over-HTTPS

A client using browser-level DoH never asks your resolver anything. Its DNS rides HTTPS on port 443 to a public resolver, gets the public answer, and serves the snapshot — on your LAN, with your override sitting there working perfectly.

A firewall rule blocking outbound port 53 and 853 forces plain DNS back to your resolver but does nothing about DoH. There’s no clean fix short of blocking known DoH endpoints or pinning per-browser settings; the honest framing is that split-horizon DNS is a routing convenience, not a security boundary. Which loops back to §What each side gets: the reason the inside horizon can be unauthenticated is that the network isn’t reachable, not that DNS points elsewhere. Never let split-horizon be the only thing standing between the internet and an unauthenticated app.


Verify both horizons

Check the outside from genuinely outside — a phone on cellular, or a public resolver:

# inside
dig +short app.example.com @192.168.1.53     # 192.168.1.100
curl -sI https://app.example.com | grep -i server   # your proxy, e.g. openresty

# outside
dig +short app.example.com @1.1.1.1          # public edge addresses
curl -sI https://app.example.com | grep -i server   # cloudflare

The server: header is the cheapest unambiguous tell that you’re actually on the horizon you think you’re on — far better than eyeballing the page, since the whole design goal was for the two to look alike.

Then do the one check that’s easy to skip: confirm from off-network that the live app is not reachable. Not that the snapshot loads — that the real app doesn’t. That single test is what the entire “unauthenticated on the LAN” decision rests on.


Gotchas, condensed


Where to take it next