dnsprobe v1

Setting up split-horizon DNS for internal services

· ~3 min read · dnsprobe.net/blog

You have api.example.com in production on 203.0.113.10. Your office network has the same service running on 10.10.0.10 for low-latency development. You want developers in the office to hit the internal IP, and everyone else (CI, customers, remote workers) to hit the external IP, without changing the URL. That is split-horizon DNS in one paragraph.

The mechanism

The recursive (or authoritative) DNS server returns a different answer based on the source IP of the query. From the resolver's perspective, there is no magic — it is just a configuration that branches on client address. From the client's perspective, the same hostname resolves to different IPs depending on which DNS server it asks.

The two common topologies

Internal recursive resolver overrides. The office runs its own recursive resolver (Pi-hole, unbound, BIND, dnsmasq, AD DNS). For api.example.com, the resolver is configured with a local override that returns 10.10.0.10 rather than recursing to the public DNS. Office clients use the office resolver; remote clients use whatever resolver they are configured to use, which has no override and returns the public answer.

Cleanest implementation. The authoritative DNS for the zone has zero awareness of the internal view. The override exists only at the office.

Example unbound config:

server:
    local-zone: "api.example.com." redirect
    local-data: "api.example.com. 60 IN A 10.10.0.10"

Example dnsmasq:

address=/api.example.com/10.10.0.10

Authoritative view-based DNS. The authoritative DNS for the zone runs different views per source network. Clients from 10.0.0.0/8 get the internal view; everyone else gets the external view. BIND9 is the canonical implementation:

view "internal" {
    match-clients { 10.0.0.0/8; 192.168.0.0/16; 172.16.0.0/12; };
    zone "example.com" {
        type master;
        file "/etc/bind/example.com.internal";
    };
};

view "external" {
    match-clients { any; };
    zone "example.com" {
        type master;
        file "/etc/bind/example.com.external";
    };
};

Two zone files, two truths. The match-clients ACL decides which view a query gets.

Where this goes wrong

  1. Caching across views. If a recursive resolver caches the internal answer and then later serves it to an external client, the external client gets the internal IP — which it can't reach. This happens with shared corporate resolvers and remote-access VPN setups. Mitigation: lower the TTL on the override (60 seconds, not 86400) and never share an internal-override resolver with external clients.
  2. Certificate validation. The internal IP 10.10.0.10 still needs a valid TLS cert for api.example.com. You cannot use the public Let's Encrypt cert at the internal endpoint unless the internal service has the same private key. Usually you either share the cert+key across both endpoints (operationally fragile) or use an internal CA for the internal endpoint and distribute its root to office devices.
  3. Audit trail. Internal traffic to api.example.com never hits the production WAF, the production logs, the production rate limits. Bugs and abuse from internal callers can be invisible until they ship to a customer who triggers the public path.
  4. Documentation drift. The external view's records and the internal view's records can diverge silently. A new subdomain added to the public zone may not be added to the internal view. Two weeks later someone in the office discovers v2.api.example.com doesn't resolve internally.

A pragmatic alternative: split names, not views

Many teams that started with view-based DNS migrate over time to using distinct hostnames:

  • api.example.com — public, routable from anywhere.
  • api.internal.example.com — only resolvable inside the office network.

Code that knows it is running inside the office uses the internal hostname. Public services use the public one. There is no source-IP branching, the public DNS has no idea the internal hostname exists, and the certificate strategy is straightforward: separate cert per hostname.

The cost is that URLs are no longer portable. A request that worked in dev sometimes doesn't work in CI because CI used the public URL. Most teams add a thin config layer that picks the hostname per environment.

Verifying which view you got

From an office machine:

dig api.example.com +short                       # uses office resolver
dig @1.1.1.1 api.example.com +short              # bypasses office resolver

If the two answers differ, you have a split-horizon setup somewhere. If they agree but the answer is the internal IP, your office resolver is leaking the override to upstream queries (or you're behind a transparent DNS interceptor — captive portals love these).

To compare an authoritative view's external answer against what twelve public resolvers actually return, run the hostname through dnscheck — the per-resolver matrix tells you the public truth, which is what your external customers see. Try google.com to see a host whose authoritative response is consistent across all resolvers.

References: BIND9 views, unbound config reference.