Split-Horizon DNS: One Name, Two Answers, and the Server Decides by Who's Asking

This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.

In Lab #41, round-robin DNS rotated the order of one name's records. Split-horizon DNS pulls a more radical trick: it changes the answer itself based on who is asking. An authoritative server keeps several views of the same zone and picks one by the client's source address.

Reading guide: rfc-notes/split-horizon-dns.md

Prerequisite: DNS Lab 05: Resolving a Name Through the Hierarchy

Expected time: 35–50 minutes.

The Goal

One name (app.lab.) is served through two views:

  • an internal view (match-clients { 10.0.1.0/24; }) answers with the private address 10.0.0.5,
  • an external view (match-clients { any; }) answers with the public address 203.0.113.5,
  • two clients on different networks resolve the same app.lab. and get different addresses — the basis of split-brain DNS, used to give insiders a private path and outsiders a public one.

By the end, you should be able to explain this table:

client (source) app.lab. resolves to
internal (10.0.1.0/24) 10.0.0.5 (private)
external (any other) 203.0.113.5 (public)

What You Will Learn

  • What a BIND view is and how match-clients selects one by source address.
  • Why view order matters (specific first, any last).
  • How the same name resolves to different records per view.
  • Where split-horizon is used (internal vs. public paths, hiding topology).
  • The caveats: caching across boundaries and keeping two zone copies consistent.

This lab does not cover:

  • Recursive resolvers and cache separation in depth.
  • TSIG-key-based view selection or EDNS Client Subnet.
  • Automated zone-data generation for multiple views.

Where to Read in the RFCs

Reference What to focus on
RFC 8499 The terminology for "view" / split DNS
RFC 1034 The basics of authoritative servers and zones
RFC 6950 Caveats of scoped (audience-dependent) responses
RFC 5737 / RFC 1918 Confirming the addresses used here are local/documentation-only

The Big Picture

The DNS server sits between two faces, internal and external. A client on each side asks for the same name.

 internal-client (10.0.1.10) --- eth1 [ dns (views) ] eth2 --- external-client (203.0.113.10)
                             10.0.1.2                203.0.113.2
   internal: app.lab -> 10.0.0.5     external: app.lab -> 203.0.113.5
flowchart TD
  I["internal-client<br/>src 10.0.1.10"] -->|dig app.lab| D["dns<br/>view match by source"]
  E["external-client<br/>src 203.0.113.10"] -->|dig app.lab| D
  D -->|"internal view<br/>(10.0.1.0/24)"| RI["A 10.0.0.5 (private)"]
  D -->|"external view<br/>(any)"| RE["A 203.0.113.5 (public)"]

10.0.1.0/24 is the internal link; 203.0.113.0/24 (RFC 5737) is the external link.

Note: Everything here uses local/documentation address space (RFC 1918 / RFC 5737), so nothing in this lab touches the real internet.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM
  • Docker
  • containerlab

Images used:

  • protocol-lab/bind9:9.20 — built from the Dockerfile by run.sh.
  • nicolaka/netshoot:latest — the clients, providing dig.

Running the Lab

The quick path, which deploys, verifies, and tears down for you:

./scripts/labctl.sh run dns-views-42

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/dns-views-42

2. Build the image and deploy

docker build -t protocol-lab/bind9:9.20 .
sudo containerlab deploy -t dns-views-42.clab.yml

3. Resolve the same name from inside and outside

docker exec clab-dns-views-42-internal-client dig +short app.lab @10.0.1.2      # 10.0.0.5
docker exec clab-dns-views-42-external-client dig +short app.lab @203.0.113.2   # 203.0.113.5

The same app.lab resolves to a different address depending on the source.

Expected Output

  • internal-client: app.lab10.0.0.5 (private).
  • external-client: app.lab203.0.113.5 (public).
  • The two answers differ — split-horizon is working.

Why It Works

Split-horizon DNS is "one authoritative name, multiple sets of answers, chosen by who's asking."

  • Views. A view is a named container with its own zone definitions (files). The server can hold the same zone separately in multiple views.
  • Selection by match-clients. The server compares the query's source address against each view's match-clients list, top to bottom, and answers from the zone of the first view that matches. In this lab, the order is internal (10.0.1.0/24) → external (any). The internal client (10.0.1.10) matches the internal view → 10.0.0.5. The external client (203.0.113.10) doesn't match internal, falls through to external (any) → 203.0.113.5.
  • Order is everything. Put specific views first and any last. Reverse them and everyone lands in the first view.
  • Why it's useful. Internal users get the private IP (a direct in-house path); external users get the public IP (the published route). Private addresses and internal-only hosts stay invisible to the outside (topology hiding), and users keep using the same name (URL) everywhere.
  • The caveats. Responses get cached by resolvers — if you don't separate internal and external resolvers, answers from different views can get mixed. Each view keeps its own copy of the zone, so a missed update leaves inside and outside out of sync. And selection is based on source address, so NAT or VPN can shift a client into an unintended view.

The key insight: select the view by source address, and the same name resolves to the right face for wherever the client sits. Unlike round-robin (which shuffles order), split-horizon changes the answer itself based on who asks.

Common Pitfalls

  • Confusing it with round-robin. RR (Lab 41) changes the order of records; views change the answer itself.
  • View ordering. Matching goes top-down and the first hit wins. Put any first and everyone falls into it.
  • Assuming one zone file is enough. Each view holds its own copy of the zone; you need an operational story for keeping them in sync.
  • Caching. Without separate internal and external resolvers, answers get mixed across the boundary.
  • Trusting the source address too much. NAT, VPNs, and spoofing can all change the apparent source. Design your ACLs carefully.
  • Recursion. This lab queries the authoritative server directly. Put a recursive resolver in between, and view selection is decided by the resolver's source address, not the end client's.

Cleanup

sudo containerlab destroy -t dns-views-42.clab.yml --cleanup

If you used labctl.sh run dns-views-42, the script runs destroy for you at the end.

Check Your Understanding

  1. What is a BIND view? What does match-clients use to select one?
  2. How does the view matching order matter? What happens if any comes first?
  3. In this lab, why do the internal and external clients get different addresses for the same name?
  4. How does split-horizon differ from round-robin (Lab 41)?
  5. Name two operational caveats of split-horizon (think caching and consistency).
  6. How do NAT or VPN affect view selection?

References

Verified Run Log (2026-07-08)

This lab has been confirmed reproducible on real hardware.

Environment:

  • Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
  • Docker 29.1.3
  • containerlab 0.77.0
  • dns: protocol-lab/bind9:9.20 (built from the Dockerfile by run.sh)
  • internal-client / external-client: nicolaka/netshoot:latest (dig)

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run dns-views-42 performed build → deploy → verify → destroy, and verification.json returned "status": "verified".

The same name, a different answer per source

internal: 10.0.0.5
external: 203.0.113.5
  • internal-client (source 10.0.1.10, matching 10.0.1.0/24) landed in the internal view: app.lab10.0.0.5 (the private address).
  • external-client (source 203.0.113.10) didn't match internal and fell through to the external (any) view: the same app.lab203.0.113.5 (the public address).
  • One authoritative name resolved to different records per source, via match-clients view selection — private to the inside, public to the outside. Split-horizon confirmed.

Cleanup

containerlab destroy -t dns-views-42.clab.yml --cleanup

That's split-horizon DNS: keep multiple views of one zone, match on the source address, and the same name quietly resolves to the right answer for each audience.

Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.

Next up, we'll keep pushing on DNS behavior — how resolvers, caches, and trust boundaries shape the answers you actually see.

Read more