Split-Horizon DNS Configuration on Linux

Split-horizon DNS serves different DNS responses to internal and external clients for the same domain, allowing private IP addresses to be used internally while external clients see public IPs. This guide covers implementing split-horizon DNS using BIND views, configuring ACLs, and testing internal vs. external resolution.

Prerequisites

  • Ubuntu 22.04/Debian 12 or CentOS/Rocky 9
  • Root or sudo access
  • BIND 9 (named)
  • Understanding of your internal network ranges
  • Port 53 (TCP/UDP) open in firewall
  • Two sets of DNS records for the same domain (internal IPs and public IPs)

Install BIND

# Ubuntu/Debian
sudo apt update && sudo apt install -y bind9 bind9utils bind9-doc

# CentOS/Rocky
sudo dnf install -y bind bind-utils

# Check version
named -v

# Enable the service (don't start yet)
sudo systemctl enable named

BIND configuration directories:

  • Ubuntu/Debian: /etc/bind/
  • CentOS/Rocky: /etc/named/ and /var/named/
# Set variable for config directory
BIND_DIR=/etc/bind          # Debian/Ubuntu
# BIND_DIR=/etc/named       # CentOS/Rocky

Understanding Views and ACLs

BIND's view statement selects which zone data to serve based on the requesting client's IP address. ACLs (Access Control Lists) define client groups:

                    DNS Server
                    /       \
         Internal?            External?
         /                          \
  Serve internal zone            Serve external zone
  (10.0.0.10 for www)           (203.0.113.10 for www)

The DNS server uses the same domain name (example.com) but returns different records depending on whether the query comes from an internal network or the internet.

Configure Split-Horizon with BIND Views

Create the main named configuration:

sudo tee /etc/bind/named.conf << 'EOF'
// Include standard options
include "/etc/bind/named.conf.options";

// ACL definitions
acl "internal" {
    127.0.0.0/8;
    10.0.0.0/8;
    172.16.0.0/12;
    192.168.0.0/16;
};

acl "external" {
    any;
};

// Include view definitions
include "/etc/bind/named.conf.views";
EOF

Create the options file:

sudo tee /etc/bind/named.conf.options << 'EOF'
options {
    directory "/var/cache/bind";

    // Forward external queries to upstream resolvers
    forwarders {
        1.1.1.1;
        8.8.8.8;
    };
    forward only;

    // Listen on all interfaces
    listen-on { any; };
    listen-on-v6 { any; };

    // Security hardening
    recursion yes;
    allow-recursion { internal; };  // Only internal clients get recursion
    allow-query-cache { internal; };

    version "not disclosed";
    dnssec-validation auto;
};
EOF

Create the views configuration:

sudo tee /etc/bind/named.conf.views << 'EOF'
// Internal view - for clients on internal networks
view "internal" {
    match-clients { internal; };
    match-destinations { any; };
    recursion yes;

    zone "example.com" {
        type master;
        file "/etc/bind/zones/example.com.internal";
        allow-query { internal; };
    };

    zone "0.0.10.in-addr.arpa" {
        type master;
        file "/etc/bind/zones/10.0.0.rev";
        allow-query { internal; };
    };

    // Allow internal clients to resolve everything else
    include "/etc/bind/named.conf.default-zones";
};

// External view - for clients on the internet
view "external" {
    match-clients { external; };
    match-destinations { any; };
    recursion no;   // Do not allow external clients to use this server as a recursive resolver

    zone "example.com" {
        type master;
        file "/etc/bind/zones/example.com.external";
        allow-query { any; };
    };

    // No default zones for external view (not a public recursive resolver)
};
EOF

Create zone directories:

sudo mkdir -p /etc/bind/zones

Create Internal and External Zone Files

Internal zone - returns private IP addresses:

sudo tee /etc/bind/zones/example.com.internal << 'EOF'
$ORIGIN example.com.
$TTL 300

@   IN  SOA ns1.example.com. admin.example.com. (
            2026040401  ; Serial
            3600        ; Refresh
            900         ; Retry
            604800      ; Expire
            300 )       ; Minimum TTL

; Name servers (internal)
@       IN  NS  ns1.example.com.

; Internal server IPs
ns1     IN  A   10.0.0.1
@       IN  A   10.0.0.10       ; Internal web server
www     IN  A   10.0.0.10
api     IN  A   10.0.0.20       ; Internal API server
db      IN  A   10.0.0.30       ; Database (only accessible internally)
mail    IN  A   10.0.0.40
admin   IN  A   10.0.0.50       ; Admin panel (internal only)

; MX
@       IN  MX  10 mail.example.com.

; Internal-only service
gitlab  IN  A   10.0.0.60
jenkins IN  A   10.0.0.70
EOF

External zone - returns public IP addresses (no private IPs exposed):

sudo tee /etc/bind/zones/example.com.external << 'EOF'
$ORIGIN example.com.
$TTL 3600

@   IN  SOA ns1.example.com. admin.example.com. (
            2026040401  ; Serial
            3600        ; Refresh
            900         ; Retry
            604800      ; Expire
            300 )       ; Minimum TTL

; Public name servers
@       IN  NS  ns1.example.com.
@       IN  NS  ns2.example.com.

; Public IP addresses only
ns1     IN  A   203.0.113.1
ns2     IN  A   203.0.113.2
@       IN  A   203.0.113.10    ; Public web server
www     IN  A   203.0.113.10
api     IN  A   203.0.113.20    ; Public API endpoint
mail    IN  A   203.0.113.40

; MX
@       IN  MX  10 mail.example.com.

; SPF
@       IN  TXT "v=spf1 mx -all"

; Note: No db, admin, gitlab, jenkins records in external zone
EOF

Reverse zone for internal IP addresses:

sudo tee /etc/bind/zones/10.0.0.rev << 'EOF'
$ORIGIN 0.0.10.in-addr.arpa.
$TTL 300

@   IN  SOA ns1.example.com. admin.example.com. (
            2026040401 3600 900 604800 300 )

@       IN  NS  ns1.example.com.

1       IN  PTR ns1.example.com.
10      IN  PTR www.example.com.
20      IN  PTR api.example.com.
30      IN  PTR db.example.com.
40      IN  PTR mail.example.com.
50      IN  PTR admin.example.com.
EOF

Start BIND:

# Verify configuration syntax
sudo named-checkconf /etc/bind/named.conf
sudo named-checkzone example.com /etc/bind/zones/example.com.internal
sudo named-checkzone example.com /etc/bind/zones/example.com.external

sudo systemctl start named

Firewall and Network Considerations

# Allow DNS traffic
# Ubuntu
sudo ufw allow 53/tcp
sudo ufw allow 53/udp

# CentOS/Rocky
sudo firewall-cmd --permanent --add-service=dns
sudo firewall-cmd --reload

# If the DNS server is on the edge of your network,
# ensure NAT/routing properly identifies internal vs. external clients.
# The BIND view match is based on the source IP of the DNS query,
# not the client's internal IP - check that internal clients' queries
# arrive with their internal IP (not NAT'd).

Important: Internal clients must reach the DNS server with their real internal IP. If queries are NAT'd to a public IP, BIND will classify them as external.

Testing Split-Horizon Resolution

Test from an internal host (should return internal IPs):

# On an internal client (10.x.x.x)
dig @10.0.0.1 www.example.com
# Expected: 10.0.0.10

dig @10.0.0.1 admin.example.com
# Expected: 10.0.0.50

dig @10.0.0.1 -x 10.0.0.10
# Expected: www.example.com (PTR record)

Simulate an external query from the DNS server itself:

# Bind 9 treats 127.0.0.1 as "internal" per ACL above
# To test the external view from the server, use a tool with source IP spoofing
# Or test from an actual external host

# From an external host:
dig @203.0.113.1 www.example.com
# Expected: 203.0.113.10 (NOT 10.0.0.10)

dig @203.0.113.1 admin.example.com
# Expected: NXDOMAIN (not in external zone)

Use rndc to verify views are loaded:

sudo rndc status
sudo rndc dumpdb -all
sudo rndc reload

Common Issues

Both views returning the same data:

# Verify ACL matching
sudo named-checkconf -p | grep -A5 "view"

# Check what view a query matched (enable query logging temporarily)
sudo rndc querylog on
dig @localhost www.example.com
sudo tail -f /var/log/syslog | grep named   # Ubuntu
sudo journalctl -u named -f                  # CentOS
sudo rndc querylog off

External view returning NXDOMAIN for all queries (not in zone):

# Check zone file syntax
sudo named-checkzone example.com /etc/bind/zones/example.com.external

# Ensure the serial numbers are the same or external is higher
# (some resolvers cache the SOA and may get confused if serial differs)

Internal clients can't resolve internet hostnames:

# Ensure the internal view includes default zones and forwarders are set
# Check that 'recursion yes' is in the internal view block
# Check forwarders are reachable
dig @1.1.1.1 google.com

Named fails to start after adding views:

# Common error: default zones must be inside a view when using views
sudo named-checkconf
# Look for errors about zones needing to be in a view
# Add: include "/etc/bind/named.conf.default-zones"; inside the internal view

Conclusion

Split-horizon DNS with BIND views provides a clean way to serve different DNS answers to internal and external clients without running separate DNS servers. Internal clients get private IP addresses for direct LAN access to services, while external clients receive only public IP addresses, keeping internal network topology hidden. Always validate zone files with named-checkzone and the full configuration with named-checkconf before restarting BIND in production.