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:
- Inside: the live app, full read/write, no authentication in front of it. This is only safe because it is unreachable from the internet.
- Outside: a static, scrubbed copy. No API, no write path, no credentials — nothing that can be edited, and nothing you’d mind a stranger reading.
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:
- The array replaces; it does not append. Omitting an existing entry silently deletes it. Always write the complete list.
- Apply it to every resolver your clients can reach. If you run a redundant pair, a record on only one box means half your lookups get the public answer, seemingly at random. Keeping that pair in sync is its own problem — Build Resilient Home DNS with Pi-hole covers the failover VIP and why config-as-code beats a sync job.
- Hand out one resolver address over DHCP. A “backup” DNS server pointing anywhere else is a device-by-device coin flip on which horizon it lands in.
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:
- Split-horizon doesn’t break renewal. The proxy renews via the zone’s API, not via the public IP. The public side, meanwhile, carries its host’s own edge certificate. Both sides serve valid TLS; neither knows about the other.
- The certificate is only as scoped as the token. A provider token minted with account-wide access can edit every zone you own, and it lives in your proxy’s database on a LAN box. Scope it to the single zone, and record where it lives in whatever you use for secrets — the credential is easy to create and easy to forget.
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:
- Test the config before restarting. An invalid
misc.dnsmasq_linesentry stops the resolver from starting at all, which on your primary DNS box is a whole-house outage.pihole-FTL --testmust pass first. local=and theArecord are now coupled. With the name marked local, deleting the override doesn’t fall through to the public answer — it returnsNXDOMAIN. Remove both together or neither.
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:
- Write single elements, not the whole array — if your API lets you. The CLI form
(
pihole-FTL --config misc.dnsmasq_lines '[…]') replaces the list, so anything you forgot to include is deleted. Per-element writes can’t clobber their neighbors. Do check that path actually works before relying on it: Pi-hole’s element endpoint404s for these particular values, because alocal=/name/directive contains slashes and the encoded path doesn’t resolve. Where you’re forced back to a whole-array write, always build the array from a complete view of current state — never a partial one. - Validate the shape before writing. An allowlist of the directive forms you actually use turns a typo into a loud refusal instead of a resolver that won’t come back up.
- Stage across replicas, with a real query in between. Write to one resolver, then query it — not “is the API up,” but does it still answer DNS. Only proceed to the second box if it does. A bad directive then costs you one resolver instead of all of them, and the VIP fails over to the survivor.
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
- A type-65 (HTTPS/SVCB) query forwarded upstream hands Chrome your LAN address
with the public provider’s ECH key →
ERR_SSL_UNRECOGNIZED_NAME_ALERT, Chrome only. Make the resolver authoritative withlocal=/name/. dns.hostsoverridesA, notAAAA. Dormant while IPv6 is off; silently routes v6 clients to the public site the day it’s on.local=closes it too.- DoH bypasses your resolver entirely. Split-horizon is a convenience, not a security boundary.
pihole-FTL --configarrays replace, they don’t append — write the full list or you’ll delete records you forgot to include.- Test the resolver config before restarting it. A bad
misc.dnsmasq_linesentry means the daemon won’t start — a whole-house DNS outage. local=couples to the override: delete theArecord alone and the name goesNXDOMAINinstead of falling through to public.- Version the
local=line, not just theArecord. Restoring one without the other is the broken state — and your config tooling will report in-sync while it happens. - The override must exist on every resolver clients can reach, or the horizon a device lands on depends on which one answered.
- Verify in incognito, and read the address bar. Cached state and a successfully-loading wrong page are the two ways this fools you.
- A misspelled hostname produces the same TLS error as a genuinely broken one — the grey wildcard resolves it, the proxy has no matching host, handshake fails. Spell-check before theorizing.
- Keep the wildcard grey. One specific orange record is the whole public surface; add another only on purpose.
Where to take it next
- Real remote access to the live app — not the snapshot. That’s an authenticated tunnel plus Cloudflare Access in front of the hostname, never naked exposure.
- Put the local records in version control. Rendering both resolvers from one
declared state means drift isn’t a condition the system can reach — provided the
local=directive is in that declared state too, not just theArecord (§5). - Alarm on the snapshot going stale. The
snapshot_attimestamp is only useful if something reads it; a publisher that quietly stops leaves a plausible-looking public site frozen in the past.