
# 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](#5-three-ways-it-breaks-silently), 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](/cloudflare-access-wall/).
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:

```sh
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](/resilient-pihole-dns/) 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:

```sh
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](#5-three-ways-it-breaks-silently).

---

## 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](/cloudflare-pages/)
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:

```python
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:

```sh
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_lines` entry stops
  the resolver from starting at all, which on your primary DNS box is a whole-house
  outage. `pihole-FTL --test` must pass first.
- **`local=` and the `A` record are now coupled.** With the name marked local, deleting
  the override doesn't fall through to the public answer — it returns `NXDOMAIN`. 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 endpoint
  `404`s for these particular values, because a `local=/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:

```sh
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:

```sh
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](#what-each-side-gets--decide-this-first): 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:

```sh
# 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 with `local=/name/`.
- **`dns.hosts` overrides `A`, not `AAAA`.** 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 --config` arrays 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_lines` entry
  means the daemon won't start — a whole-house DNS outage.
- **`local=` couples to the override:** delete the `A` record alone and the name goes
  `NXDOMAIN` instead of falling through to public.
- **Version the `local=` line, not just the `A` record.** 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](/cloudflare-access-wall/) 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 the `A` record
  ([§5](#keep-the-directive-under-version-control--carefully)).
- **Alarm on the snapshot going stale.** The `snapshot_at` timestamp is only useful if
  something reads it; a publisher that quietly stops leaves a plausible-looking public
  site frozen in the past.
