Split-horizon DNS for private services with CoreDNS, the secure way
Split-horizon DNS is two phone books with one front desk. Hand the internal book to a visitor and they learn where the database lives; hand the public book to staff and nothing works. CoreDNS decides by block order, and it will not warn you.
The short answer
Keep two zone files: internal names only in the internal one. In CoreDNS, declare the block with a view matching your internal client ranges first, and the public catch-all block last. Test the same names from an internal and an external address. Bind listeners and firewall port 53 so outsiders never reach the internal side.
On this page
What goes wrong
Split-horizon DNS answers the same name differently depending on who asks.
Staff on the internal network get www.example.com at a private address
and can resolve db.example.com. Everyone else gets the public edge address
and learns nothing about db.
Two mistakes are common. The first is serving one zone to everyone, often because the DNS server started internal-only and later got a public address. Every private name and address is then public. Attackers use those names to plan: which hosts exist, what they are called, which ranges are inside.
The second is specific to CoreDNS. The view plugin picks a server block
by an expression such as the client address. Blocks for the same zone are
tried from top to bottom. A block with no view matches everything. Put it
first, and the internal view is never used: internal clients get public
answers and private names stop resolving.
What the docs say
view defines an expression that must evaluate to true for a DNS request to be routed to the server block.
Source: CoreDNS docs, view plugin
An unfiltered catch-all block declared before a filtered block will shadow it, because the catch-all matches every query.
Source: CoreDNS docs, view plugin, Server Block Ordering
To get the expected split-DNS behavior, declare all filtered (view) blocks first and the unfiltered catch-all block last.
Source: CoreDNS docs, view plugin, Server Block Ordering
The CoreDNS docs describe the routing well. They leave out two things.
First, client_ip() is the address the query came from. If internal
clients use a central resolver, the view sees the resolver, not the laptop.
Second, a view decides which answer to give, not who may connect. The
server still answers the public view to anyone who can reach it, so network
reachability needs its own control.
The secure configuration
Two zone files. The internal file has every name; the public file has only what the internet should see:
; db.example.com.internal
$ORIGIN example.com.
$TTL 300
@ SOA ns1 hostmaster 2026092401 3600 600 302400 300
@ NS ns1
ns1 A 10.10.0.53
www A 10.10.0.80 ; internal clients reach the app directly
db A 10.10.0.5 ; private service: never in the public file
vault A 10.10.0.6; db.example.com.public
$ORIGIN example.com.
$TTL 300
@ SOA ns1 hostmaster 2026092401 3600 600 302400 300
@ NS ns1
ns1 A 198.51.100.53
www A 203.0.113.80 ; public clients go through the edgeCorefile. The view block comes first; the catch-all comes last:
example.com {
bind 10.10.0.53 198.51.100.53 # listen only on the addresses you mean
view internal {
# the internal client ranges, or your internal resolvers' addresses
expr incidr(client_ip(), '10.10.0.0/24')
}
file /etc/coredns/db.example.com.internal
log
}
example.com {
bind 10.10.0.53 198.51.100.53
file /etc/coredns/db.example.com.public # no private names in this file
log
}Notes on the configuration:
- Use single quotes inside expr. CoreDNS strips double quotes, and the
expression then fails to parse.
- Do not add a forward or recursion to the public block for example.com.
Unknown names there must be NXDOMAIN, not a lookup elsewhere.
- If internal clients reach CoreDNS through a resolver, match the resolver's
addresses in the view, and make sure that resolver only serves internal
clients.
- Keep the public file generated from, or checked against, the internal one,
so a public name never exists only by accident.Reachability, on the host running CoreDNS:
# Internal listener: only internal ranges may reach it.
nft add rule inet filter input ip daddr 10.10.0.53 ip saddr != 10.10.0.0/24 udp dport 53 drop
nft add rule inet filter input ip daddr 10.10.0.53 ip saddr != 10.10.0.0/24 tcp dport 53 dropProve it
The test (secure-tests/split-horizon-dns-private-services-coredns/run.sh)
attaches CoreDNS 1.14.7 to an internal network (10.10.0.53) and a public
one (198.51.100.53). A client at 10.10.0.10 and a client at 198.51.100.99
ask for www and db.
Before 1: one zone for everyone
example.com {
file /etc/coredns/db.example.com.internal
}$ kdig @10.10.0.53 www.example.com A (internal client 10.10.0.10)
status: NOERROR
www.example.com. 300 IN A 10.10.0.80
$ kdig @198.51.100.53 www.example.com A (public client 198.51.100.99)
status: NOERROR
www.example.com. 300 IN A 10.10.0.80
$ kdig @10.10.0.53 db.example.com A (internal client 10.10.0.10)
status: NOERROR
db.example.com. 300 IN A 10.10.0.5
$ kdig @198.51.100.53 db.example.com A (public client 198.51.100.99)
status: NOERROR
db.example.com. 300 IN A 10.10.0.5The public client learns the private database name and address.
Before 2: views in the wrong order
The public catch-all block is declared first, the view block second:
$ kdig @10.10.0.53 www.example.com A (internal client 10.10.0.10)
status: NOERROR
www.example.com. 300 IN A 203.0.113.80
$ kdig @198.51.100.53 www.example.com A (public client 198.51.100.99)
status: NOERROR
www.example.com. 300 IN A 203.0.113.80
$ kdig @10.10.0.53 db.example.com A (internal client 10.10.0.10)
status: NXDOMAIN
$ kdig @198.51.100.53 db.example.com A (public client 198.51.100.99)
status: NXDOMAINNothing leaks, but the internal view is dead: staff get public answers and
cannot resolve db. The usual "fix" under pressure is to put the private
records in the public file.
After: view first, catch-all last, bind on both blocks
$ kdig @10.10.0.53 www.example.com A (internal client 10.10.0.10)
status: NOERROR
www.example.com. 300 IN A 10.10.0.80
$ kdig @198.51.100.53 www.example.com A (public client 198.51.100.99)
status: NOERROR
www.example.com. 300 IN A 203.0.113.80
$ kdig @10.10.0.53 db.example.com A (internal client 10.10.0.10)
status: NOERROR
db.example.com. 300 IN A 10.10.0.5
$ kdig @198.51.100.53 db.example.com A (public client 198.51.100.99)
status: NXDOMAINThe query log shows which client got which answer:
10.10.0.10:48039 - 49644 "A IN db.example.com. udp 43 false 1232" NOERROR qr,aa 102 0.000116642s
198.51.100.99:45427 - 9353 "A IN db.example.com. udp 43 false 1232" NXDOMAIN qr,aa 116 0.000194521sMistakes people make
The catch-all block first
CoreDNS routes to the first matching block. A block without view matches
everything, so it must be last. The test shows the result of the wrong
order: internal names vanish for internal users.
Private names in the public file "just in case"
Once a private name is in the public zone, it is in every resolver cache and in passive DNS databases that record answers over time. Keep private names only in the internal file, and diff the two files in CI.
Matching laptops when queries come from a resolver
If staff use a central resolver, CoreDNS sees the resolver's address. A view on the office range then never matches. Match the resolver, and lock that resolver to internal clients.
Treating the view as access control
A view chooses an answer. It does not refuse connections. Bind the internal listener to an internal address and firewall it, so outsiders cannot even send it a query.
Double quotes in the expression
The Corefile parser removes double quotes, and the expression fails to parse at startup. Use single quotes, as in the CoreDNS examples.
Checklist
- Keep an internal zone file and a public zone file; private names only in the internal one.
- Declare every
viewblock before the catch-all block for the same zone. - Match the addresses that actually send queries: clients or internal resolvers.
- Use single quotes inside
expr. - Use
bindso CoreDNS listens only on the intended addresses. - Firewall the internal listener to internal ranges.
- Do not forward unknown names from the public block.
- Test the same names from one internal and one external address after every change.
- Diff the two zone files in CI and review every public addition.
Split-horizon DNS works when each side sees exactly its own book. Test from both sides, every time, because the server will happily answer either way.
H2-CSPE
Learn it on a live range
Cluster networking and policy, in Secure Platform Engineering: a real host in your browser, and every objective checked on the machine.
Start freeThe Dome
Want it run for you?
The Dome puts post-quantum TLS, a WAF that blocks, signed DNS and a zero-trust mesh in front of your application. Tell us what you run.
See the Dome