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.10forns1and203.0.113.20forns2. - A non-root user with
sudoprivileges on both servers. - A registered domain. This guide uses
example.com; replace it withyour_domaineverywhere. - 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 fromns1tons2.versionhides the version string thatdig version.bind CH TXTwould 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
YYYYMMDDNNformat 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:
- Register the host records (also called glue records or child nameservers)
ns1.example.com→203.0.113.10andns2.example.com→203.0.113.20. They are required because the nameservers live inside the domain they serve. - Change the domain's nameservers to
ns1.example.comandns2.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
WarningDo not remove
dnssec-policyor delete the keys in/var/cache/bindwhile a DS record is published. Resolvers would treat your zone as forged and return SERVFAIL. Remove the DS record at the registrar first and wait for its TTL to expire.
Updating records
Every change to the zone follows the same routine on ns1:
- Edit
/var/lib/bind/db.example.com. - Increase the serial number.
- 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.
