Split-horizon DNS (also called split-brain DNS) answers the same name differently depending on who asks. Servers on your private network resolve www.example.com to its private address and talk to it directly, while visitors on the Internet get the public address, and internal-only names such as db.example.com are not visible from outside at all. In this tutorial you will implement split-horizon DNS with BIND 9 views on Ubuntu 24.04, using one internal and one external version of the same zone, and test both answers from the server itself.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS with a public IP address and an interface on a private network, for example a CubePath VPS attached to a private network.
  • A non-root user with sudo privileges.
  • A domain whose records you control. This guide uses example.com, the private range 10.10.0.0/24 with the DNS server at 10.10.0.2, and the public addresses 203.0.113.2 (this DNS server) and 203.0.113.10 (the web server). Replace them with your own values.

Step 1 - Installing BIND

Install BIND and its tools:

sudo apt update
sudo apt install bind9 bind9-utils bind9-dnsutils

Check the version and the service. On Ubuntu 24.04 the service is called named:

named -v
systemctl status named --no-pager
BIND 9.18.x-0ubuntu0.24.04.x-Ubuntu (Extended Support Version) <id:...>
● named.service - BIND Domain Name Server
     Active: active (running) since ...

Ubuntu keeps BIND's configuration in /etc/bind. The main file, named.conf, includes three others: named.conf.options (global options), named.conf.local (your zones) and named.conf.default-zones (the root hints and localhost zones).

Step 2 - Understanding views and ACLs

A view is a separate copy of the DNS namespace. For each query, BIND checks the views in the order they are written and uses the first one whose match-clients list contains the client's source IP address. Two rules follow from this:

  • Once you define one view, every zone must be inside a view, including the default zones. That is why you will move the named.conf.default-zones include into the internal view.
  • The internal view must come first, because the external view matches everyone.

BIND selects the view by the source address of the query. Internal clients must reach the server with their private address, not through NAT to a public IP, or they will land in the external view.

Step 3 - Configuring the global options

Open the options file:

sudo nano /etc/bind/named.conf.options

Replace its content with the following:

acl "internal" {
    127.0.0.1;
    ::1;
    10.10.0.0/24;
};

options {
    directory "/var/cache/bind";

    listen-on { 127.0.0.1; 10.10.0.2; 203.0.113.2; };
    listen-on-v6 { none; };

    // Recursion and cache only for internal clients
    allow-recursion { internal; };
    allow-query-cache { internal; };

    // No zone transfers unless configured per zone
    allow-transfer { none; };

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

The ACL uses 127.0.0.1 and ::1 rather than BIND's built-in localhost ACL on purpose: localhost matches every address of the server, including its public IP, which would make the external view impossible to test from the server itself in Step 7.

Step 4 - Defining the views

Open the main configuration file:

sudo nano /etc/bind/named.conf

Comment out the default-zones include, because those zones now have to live inside a view:

include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";
// include "/etc/bind/named.conf.default-zones";

Now define both views in named.conf.local:

sudo nano /etc/bind/named.conf.local
view "internal" {
    match-clients { internal; };
    recursion yes;

    zone "example.com" {
        type primary;
        file "/etc/bind/zones/db.example.com.internal";
    };

    zone "0.10.10.in-addr.arpa" {
        type primary;
        file "/etc/bind/zones/db.10.10.0";
    };

    // Root hints, localhost and broadcast zones for recursion
    include "/etc/bind/named.conf.default-zones";
};

view "external" {
    match-clients { any; };
    recursion no;

    zone "example.com" {
        type primary;
        file "/etc/bind/zones/db.example.com.external";
    };
};

Internal clients get recursion, so they can use this server for every domain on the Internet. External clients only get authoritative answers for example.com, so the server is not an open resolver.

Step 5 - Creating the zone files

Create a directory for the zone files:

sudo mkdir -p /etc/bind/zones

Create the internal zone, which uses private addresses and includes internal-only hosts:

sudo nano /etc/bind/zones/db.example.com.internal
$TTL 300
@       IN  SOA ns1.example.com. hostmaster.example.com. (
                2026092501  ; serial (YYYYMMDDNN)
                3600        ; refresh
                900         ; retry
                604800      ; expire
                300 )       ; negative cache TTL

@       IN  NS  ns1.example.com.
ns1     IN  A   10.10.0.2

@       IN  A   10.10.0.10
www     IN  A   10.10.0.10
api     IN  A   10.10.0.20
db      IN  A   10.10.0.30
gitlab  IN  A   10.10.0.40

Create the external zone, which contains only public addresses and public services:

sudo nano /etc/bind/zones/db.example.com.external
$TTL 3600
@       IN  SOA ns1.example.com. hostmaster.example.com. (
                2026092501  ; serial (YYYYMMDDNN)
                3600        ; refresh
                900         ; retry
                604800      ; expire
                300 )       ; negative cache TTL

@       IN  NS  ns1.example.com.
ns1     IN  A   203.0.113.2

@       IN  A   203.0.113.10
www     IN  A   203.0.113.10
api     IN  A   203.0.113.20
@       IN  TXT "v=spf1 -all"

db and gitlab do not exist in this file, so Internet clients get NXDOMAIN for them. In production, list at least two name servers in both zones and register them at your registrar.

Create the reverse zone for the private range, so internal tools can map IPs back to names:

sudo nano /etc/bind/zones/db.10.10.0
$TTL 300
@       IN  SOA ns1.example.com. hostmaster.example.com. (
                2026092501 3600 900 604800 300 )

@       IN  NS  ns1.example.com.

2       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 gitlab.example.com.

Step 6 - Checking and loading the configuration

Validate each zone file:

sudo named-checkzone example.com /etc/bind/zones/db.example.com.internal
sudo named-checkzone example.com /etc/bind/zones/db.example.com.external
sudo named-checkzone 0.10.10.in-addr.arpa /etc/bind/zones/db.10.10.0
zone example.com/IN: loaded serial 2026092501
OK

Then check the whole configuration. The -z flag also loads every zone in every view:

sudo named-checkconf -z

If it prints no errors, restart BIND and check its log:

sudo systemctl restart named
sudo journalctl -u named -n 20 --no-pager

You should see lines such as zone example.com/IN/internal: loaded serial 2026092501 and zone example.com/IN/external: loaded serial 2026092501.

Allow DNS through UFW. Port 53 must be open to everyone for the external view to work; the ACLs, not the firewall, decide what each client can see:

sudo ufw allow 53/udp
sudo ufw allow 53/tcp

Step 7 - Testing both views

Query the server from itself on the loopback address. The source is 127.0.0.1, which matches the internal view:

dig @127.0.0.1 www.example.com +short
dig @127.0.0.1 db.example.com +short
dig @127.0.0.1 -x 10.10.0.30 +short
10.10.0.10
10.10.0.30
db.example.com.

Now query the server's public address. When a machine talks to one of its own IPs, the source address is that same public IP, which is not in the internal ACL, so BIND uses the external view:

dig @203.0.113.2 www.example.com +short
dig @203.0.113.2 db.example.com
203.0.113.10

The second command returns status: NXDOMAIN. Confirm that the external view refuses recursion for other domains:

dig @203.0.113.2 cubepath.com | grep -E 'status|WARNING'
;; ->>HEADER<<- opcode: QUERY, status: REFUSED, id: 10477
;; WARNING: recursion requested but not available

Finally, test from a machine on the private network. It must get the private address and be able to resolve other domains:

dig @10.10.0.2 www.example.com +short
dig @10.10.0.2 cubepath.com +short

To see which view answered a given query, enable query logging for a moment and watch the log:

sudo rndc querylog on
sudo journalctl -u named -f

Each logged query includes view internal or view external. Turn logging off again with sudo rndc querylog off.

Step 8 - Updating records

Each view has its own copy of the zone, so a change that affects both audiences must be made in both files. After editing a zone file, increase its serial number (for example from 2026092501 to 2026092502), validate it and reload just that zone in the right view:

sudo named-checkzone example.com /etc/bind/zones/db.example.com.internal
sudo rndc reload example.com IN internal

Troubleshooting

named-checkconf reports when using 'view' statements, all zones must be in views. A zone is still defined outside a view, usually because the named.conf.default-zones include in named.conf is still active. Comment it out as in Step 4.

Internal clients get the public addresses. Their queries reach BIND from an address outside the internal ACL, often because of NAT. Enable rndc querylog on, check the client address shown in the log and add the correct range to the ACL.

Internal clients cannot resolve Internet domains. Make sure the internal view has recursion yes and includes named.conf.default-zones, and that the client range is in the ACL used by allow-recursion.

Changes do not show up. You forgot to increase the serial, or you reloaded the zone in the other view. Check the loaded serial with dig @127.0.0.1 example.com SOA +short.

BIND fails to start with permission denied on a zone file. Zone files in /etc/bind/zones must be readable by the bind user. Fix them with sudo chmod 644 /etc/bind/zones/*.

Conclusion

BIND is now serving two versions of example.com on Ubuntu 24.04: private addresses and internal-only hosts for your private network, and public addresses only for the Internet, with recursion limited to internal clients. Next, you can add a secondary server using TSIG-signed zone transfers with one key per view, sign the external zone with DNSSEC, or move frequently changing records to dynamic updates with nsupdate.