BIND9 is the reference DNS server from the Internet Systems Consortium (ISC) and can act as an authoritative name server, a recursive resolver, or both. In this tutorial you will run your own authoritative nameservers for a domain on Ubuntu 24.04: a primary server (ns1) that holds the zone file and a secondary server (ns2) that receives copies through zone transfers. You will also sign the zone with DNSSEC and verify every step with dig.

Recursion stays disabled on both servers. An authoritative server that also answers recursive queries for the whole Internet is an open resolver and gets abused for DNS amplification attacks.

Prerequisites

To follow this tutorial you need:

  • Two servers running Ubuntu 24.04 LTS, each with a public IPv4 address, for example two CubePath VPS in different locations. This guide uses 203.0.113.10 for ns1 and 203.0.113.20 for ns2.
  • A non-root user with sudo privileges on both servers.
  • A registered domain. This guide uses example.com; replace it with your_domain everywhere.
  • Access to your registrar's control panel, where you will register the nameserver hostnames (glue records) and, optionally, the DNSSEC DS record.

Most registrars require at least two nameservers on different IP addresses, which is why this guide uses two servers. Commands are labelled with the server they run on.

Step 1 - Installing BIND9 on both servers

Install BIND9 and its command-line tools on ns1 and ns2. The bind9-dnsutils package provides dig, which you will use for testing.

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

Ubuntu starts the service right away. It is called named (the bind9 name is an alias):

sudo systemctl status named --no-pager
● named.service - BIND Domain Name Server
     Loaded: loaded (/usr/lib/systemd/system/named.service; enabled; preset: enabled)
     Active: active (running)

Check the installed version:

named -v
BIND 9.18.x-0ubuntu0.24.04.x-Ubuntu (Extended Support Version) <id:...>

Ubuntu 24.04 ships BIND 9.18, which supports the dnssec-policy and primaries syntax used below.

Step 2 - Configuring global options

The main configuration is split across several files in /etc/bind/. Global settings live in named.conf.options. On both servers, open it:

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

Replace its contents with the following block:

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

    // Authoritative only: never resolve names for other clients
    recursion no;
    allow-query { any; };

    // Deny zone transfers by default, allow them per zone
    allow-transfer { none; };

    listen-on { any; };
    listen-on-v6 { any; };

    dnssec-validation auto;

    // Do not reveal the exact BIND version
    version "not disclosed";
};

The important settings are:

  • recursion no; turns the server into a pure authoritative server, so it only answers for zones it hosts.
  • allow-transfer { none; }; blocks AXFR zone transfers globally. You will open them only from ns1 to ns2.
  • version hides the version string that dig version.bind CH TXT would otherwise return.

Check the syntax. No output means the configuration is valid:

sudo named-checkconf

Step 3 - Creating the zone file on the primary

The zone file contains the DNS records for your domain. Store it in /var/lib/bind/: BIND runs as the bind user, and that directory is writable by it and allowed by Ubuntu's AppArmor profile, which DNSSEC inline signing needs later because BIND writes signed copies next to the zone file.

On ns1, create the file:

sudo nano /var/lib/bind/db.example.com

Add the following zone, adjusting names and addresses:

$TTL 3600
@       IN  SOA ns1.example.com. hostmaster.example.com. (
                2026092501 ; serial (YYYYMMDDNN)
                3600       ; refresh
                900        ; retry
                1209600    ; expire
                300 )      ; negative caching TTL

; Nameservers
@       IN  NS  ns1.example.com.
@       IN  NS  ns2.example.com.

; Glue for the nameservers themselves
ns1     IN  A   203.0.113.10
ns2     IN  A   203.0.113.20

; Website and mail
@       IN  A   203.0.113.50
www     IN  CNAME example.com.
@       IN  MX  10 mail.example.com.
mail    IN  A   203.0.113.60
@       IN  TXT "v=spf1 mx -all"

A few rules to remember when editing zone files:

  • Every fully qualified name ends with a dot (ns1.example.com.). Without the dot, BIND appends the zone name.
  • The SOA email address uses a dot instead of @: hostmaster.example.com. means [email protected].
  • The serial must increase every time you change the file, or the secondary will not pick up the change. The YYYYMMDDNN format makes this easy.

Give the file to the bind user and validate it:

sudo chown bind:bind /var/lib/bind/db.example.com
sudo named-checkzone example.com /var/lib/bind/db.example.com
zone example.com/IN: loaded serial 2026092501
OK

Step 4 - Declaring the zone on the primary

Zones are declared in /etc/bind/named.conf.local. On ns1, open it:

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

Add the zone definition:

zone "example.com" {
    type primary;
    file "/var/lib/bind/db.example.com";

    // Only ns2 may transfer the zone, and it is notified on every change
    allow-transfer { 203.0.113.20; };
    also-notify { 203.0.113.20; };
    notify yes;
};

Validate the configuration and the zone together, then reload BIND:

sudo named-checkconf -z
sudo systemctl reload named
zone example.com/IN: loaded serial 2026092501

Step 5 - Opening the firewall

DNS uses UDP port 53 for most queries and TCP port 53 for large responses and zone transfers, so both protocols must be open. On both servers:

sudo ufw allow 53
sudo ufw status
To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
53                         ALLOW       Anywhere

If UFW is not enabled yet, run sudo ufw allow OpenSSH before sudo ufw enable so you do not lock yourself out.

Now query the primary from any other machine:

dig @203.0.113.10 example.com A +norecurse
;; flags: qr aa; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; ANSWER SECTION:
example.com.		3600	IN	A	203.0.113.50

The aa flag (authoritative answer) confirms that ns1 is serving the zone itself rather than from a cache.

Step 6 - Configuring the secondary server

The secondary does not need a zone file of its own: it downloads the zone from the primary and keeps it up to date. On ns2, open named.conf.local:

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

Add the secondary zone:

zone "example.com" {
    type secondary;
    primaries { 203.0.113.10; };
    file "/var/lib/bind/db.example.com";
};

Check the configuration and reload:

sudo named-checkconf
sudo systemctl reload named

Look at the log to confirm that the transfer succeeded:

sudo journalctl -u named --since "5 minutes ago" | grep -i transfer
named[1234]: transfer of 'example.com/IN' from 203.0.113.10#53: Transfer status: success
named[1234]: transfer of 'example.com/IN' from 203.0.113.10#53: Transfer completed: 1 messages, 11 records, ...

Query ns2 directly and compare the SOA serial with the primary:

dig @203.0.113.20 example.com SOA +short
ns1.example.com. hostmaster.example.com. 2026092501 3600 900 1209600 300

Finally, confirm that zone transfers are refused to everyone else. From a machine that is not ns2:

dig @203.0.113.10 example.com AXFR
; Transfer failed.

Step 7 - Pointing the domain at your nameservers

Your servers are ready, but resolvers on the Internet will not use them until the registrar delegates the domain to them. In your registrar's control panel:

  1. Register the host records (also called glue records or child nameservers) ns1.example.com → 203.0.113.10 and ns2.example.com → 203.0.113.20. They are required because the nameservers live inside the domain they serve.
  2. Change the domain's nameservers to ns1.example.com and ns2.example.com.

Delegation changes can take from a few minutes to 48 hours to propagate, depending on the TTL of the previous NS records at the TLD. Check the delegation from the top with +trace:

dig example.com A +trace

The last section of the output should show the answer coming from ns1.example.com or ns2.example.com.

Step 8 - Signing the zone with DNSSEC

DNSSEC adds cryptographic signatures to your records so resolvers can detect forged answers. BIND 9.18 can generate keys, sign the zone and roll keys automatically with dnssec-policy.

On ns1, edit the zone in /etc/bind/named.conf.local and add two lines:

sudo nano /etc/bind/named.conf.local
zone "example.com" {
    type primary;
    file "/var/lib/bind/db.example.com";
    allow-transfer { 203.0.113.20; };
    also-notify { 203.0.113.20; };
    notify yes;

    dnssec-policy default;
    inline-signing yes;
};

The default policy uses a single ECDSA P-256 key (algorithm 13). With inline-signing yes, you keep editing the unsigned db.example.com file as usual, and BIND maintains a signed copy (db.example.com.signed) that it serves and transfers to ns2. The secondary needs no changes.

Reload and check the signing status:

sudo named-checkconf
sudo systemctl reload named
sudo rndc dnssec -status example.com
dnssec-policy: default
current time:  Fri Sep 25 10:00:00 2026

key: 12345 (ECDSAP256SHA256), CSK
  published:      yes - since Fri Sep 25 09:59:00 2026
  key signing:    yes - since Fri Sep 25 09:59:00 2026
  zone signing:   yes - since Fri Sep 25 09:59:00 2026
...

Confirm that answers now carry signatures:

dig @203.0.113.10 example.com A +dnssec +norecurse

The answer section should contain an RRSIG record next to the A record.

To complete the chain of trust, publish a DS record at your registrar. Generate it from the zone's public key:

dig @127.0.0.1 example.com DNSKEY +norecurse | dnssec-dsfromkey -f - example.com
example.com. IN DS 12345 13 2 3F5A...C1E9

Enter the key tag (12345), algorithm (13), digest type (2) and digest into the registrar's DNSSEC form. Once the DS record is live, validating resolvers such as 1.1.1.1 will return the ad (authenticated data) flag:

dig @1.1.1.1 example.com A +dnssec
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

Updating records

Every change to the zone follows the same routine on ns1:

  1. Edit /var/lib/bind/db.example.com.
  2. Increase the serial number.
  3. Validate and reload only that zone:
sudo named-checkzone example.com /var/lib/bind/db.example.com
sudo rndc reload example.com

The primary sends a NOTIFY to ns2, which transfers the new version within seconds. Compare the serials to confirm:

dig @203.0.113.10 example.com SOA +short
dig @203.0.113.20 example.com SOA +short

Troubleshooting

The zone does not load after a reload. Run sudo named-checkconf -z and read the error, then check sudo journalctl -u named -n 50. A common cause is permission denied when the zone file is not owned by bind or lives outside /var/lib/bind, which AppArmor blocks.

The secondary keeps the old serial. You probably forgot to increase the serial on the primary. Also check that TCP port 53 is open on ns1 and that allow-transfer lists the exact IP address ns2 connects from. On ns2, sudo rndc retransfer example.com forces a new transfer.

dig returns REFUSED. The server is not authoritative for the name you asked about, or the zone failed to load. Since recursion is disabled, queries for other domains are refused by design.

Resolvers return SERVFAIL after enabling DNSSEC. The DS record at the registrar does not match the key BIND is using. Compare the output of dnssec-dsfromkey with the DS record shown by dig example.com DS +short and correct it at the registrar.

Conclusion

You now run two authoritative BIND9 nameservers for your domain, with restricted zone transfers, automatic NOTIFY to the secondary and a DNSSEC-signed zone. From here you can add a reverse zone if your provider delegates PTR records for your IP range to you, set up TSIG keys to authenticate zone transfers instead of relying on IP addresses, or add a third nameserver in another region for more resilience.