A how-to manual: authoritative and recursive servers, zone files, the serial number, a secondary server, TSIG, dynamic updates, split DNS, logging and the failures you will meet, all tested on a lab
▶Prefer to watch? This video runs the guide's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube. It is part of the Networking and Firewalls playlist.
BIND: running your own DNS server
named-checkconf && rndc reload
BIND (the program is called named) is the DNS server most of the internet still runs on. Run it and you decide what your names mean: www.lab.example.com points here, mail goes there, 172.29.77.80 has a name when you ask backwards. It is also famous for punishing a missing dot, a forgotten serial number and a firewall that blocks TCP, always quietly, and usually at 4 p.m. on a Friday. DNS is the only system where "it has propagated" is both a real explanation and a perfectly good excuse.
This guide builds a working setup from nothing: a primary and a secondary server, a forward and a reverse zone, safe defaults, zone transfers, dynamic updates, split DNS and logging, and ends with the failures you will meet and what they look like.
Every example below ran for real: BIND 9.18 on Ubuntu 24.04, on two throwaway lab servers on a private network (ns1 = 172.29.77.11 is the primary, ns2 = 172.29.77.12 the secondary), serving the zone lab.example.com. The lab used no systemd, so named was started by hand; on a real server you use systemctl, and the commands that need root need sudo (left out of the examples to keep them readable). The lab's network could not do recursion starting from the root servers, so the recursion examples use forwarders; the default root-iteration setup is described but its output is not shown. A few things that could not be reproduced there are marked "not run in the lab".
Two different jobs: authoritative and recursive
A DNS server can do two jobs, and BIND does both. Keep them apart in your head, because most mistakes come from mixing them up:
authoritative "I hold the zone lab.example.com, here is the real answer." Asked about a name in its zones, it answers with the aa (authoritative answer) flag. Asked about anything else, it should say no.
recursive "I will go and find the answer for you, and remember it for a while." This is what your laptop's resolver does. It follows the chain from the root servers down, caches the result and hands it back. The cache is the point: the second question is free.
Serving the world's queries about your own domain is the authoritative job. Answering your own users' questions about everybody else's domains is the recursive job. A server that does the recursive job for ANYONE who asks is an open resolver, which is how people end up as the unwitting engine of other people's denial-of-service attacks. So: recursion only for your own networks, and on a public authoritative server, no recursion at all. If you can afford it, use two separate servers for the two jobs. In the lab, ns1 does both (recursion only for its own network) and ns2 does only the authoritative job.
Install, and where the files live
Debian, Ubuntu: sudo apt install bind9 bind9-utils bind9-dnsutils (service: bind9, also called named)
RHEL family: sudo dnf install bind bind-utils (service: named)
$ named -v
BIND 9.18.39-0ubuntu0.24.04.7-Ubuntu (Extended Support Version) <id:>
The layout differs by family, which is the first thing to check when a tutorial does not match your machine:
Debian, Ubuntu RHEL, Fedora
main config /etc/bind/named.conf /etc/named.conf
zone files /etc/bind/ (read only for named) /var/named/
writable data /var/cache/bind, /var/lib/bind /var/named/data, /var/named/dynamic
the daemon runs as bind named
the rndc key /etc/bind/rndc.key /etc/rndc.key
On Debian and Ubuntu the packager split the configuration into small files and the main one only includes them, so you edit the right piece and leave the rest alone:
$ ls /etc/bind
bind.keys db.0 db.127 db.255 db.empty db.local named.conf named.conf.default-zones named.conf.local named.conf.options rndc.key zones.rfc1918
$ grep include /etc/bind/named.conf
include "/etc/bind/named.conf.options";
include "/etc/bind/named.conf.local";
include "/etc/bind/named.conf.default-zones";
named.conf.options global settings (the options block)
named.conf.local YOUR zones go here
named.conf.default-zones localhost and the empty reverse zones for private ranges; leave it
The default install is a recursive server that answers the local network only (in practice: localhost) and does DNSSEC validation. It works the minute it starts, which is also why people never look at it again.
A first config: options, ACLs and who may ask what
This is the named.conf.options used on ns1, an authoritative server for lab.example.com that also does recursion, but only for its own network:
acl "trusted" { 127.0.0.1; 172.29.77.0/24; };
options {
directory "/var/cache/bind";
listen-on { 127.0.0.1; 172.29.77.11; };
listen-on-v6 { none; };
recursion yes;
allow-recursion { trusted; };
allow-query { trusted; };
allow-transfer { none; };
version "none of your business";
dnssec-validation auto;
};
What each line does:
acl "trusted" a named list of addresses, so you write the list once and use the name everywhere. Put it ABOVE the options block.
listen-on which of the server's own addresses named answers on. Naming them is safer than listening on everything, and avoids a clash with another resolver already holding port 53 on the same machine (on Ubuntu, systemd-resolved keeps 127.0.0.53:53; not run in the lab).
recursion, allow-recursion recursion on, for the trusted list only. The most important two lines in this file.
allow-query who may ask anything at all. Here, only the trusted networks. A public authoritative server has "allow-query { any; };" and recursion no.
allow-transfer { none; } nobody may download a whole zone, unless a zone says otherwise (see the secondary server section). A zone transfer hands over every name you have; it is a reconnaissance gift.
version "..." what the server replies when asked "what version are you?". Hide it; there is no reason to announce which bugs you have:
$ dig @172.29.77.11 version.bind chaos txt +short
"none of your business"
Those rules are enforced, as asked from an address outside the list (the lab used 127.0.0.2) and from the secondary, which has recursion off:
$ dig @127.0.0.1 -b 127.0.0.2 www.lab.example.com
;; ->>HEADER<<- opcode: QUERY, status: REFUSED, id: 11091
;; WARNING: recursion requested but not available
$ dig @172.29.77.12 example.com
;; ->>HEADER<<- opcode: QUERY, status: REFUSED, id: 27365
;; WARNING: recursion requested but not available
REFUSED is the server doing its job. ns2 holds lab.example.com and nothing else, so for the rest of the world it says no.
Forwarders and DNSSEC validation
A recursive server normally starts at the root servers. If you would rather hand the work to a bigger resolver (your provider's, or a public one), use forwarders:
forwarders { 1.1.1.1; 9.9.9.9; };
forward only;
"forward only" means: if the forwarders do not answer, fail rather than go to the root yourself. The default, "forward first", tries them and then falls back to doing it itself. Your own zones are never forwarded, because named answers from its own data first. Reload, and ask:
$ rndc reconfig && rndc flush
$ dig @172.29.77.11 example.com A
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 38611
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
$ dig @172.29.77.11 example.com A +noall +answer # the same question two seconds later: it comes from the cache, the TTL counts down
example.com. 146 IN A 172.66.147.243
example.com. 146 IN A 104.20.23.154
$ dig @172.29.77.11 www.lab.example.com | grep flags # your own zone: authoritative, no forwarding
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
Look at the flags. aa means the server answered from its own zone. ra means recursion is available. ad means "authenticated data": named checked the DNSSEC signatures and they were good. With "dnssec-validation auto;" (the default) it does that for every signed domain, using the root key that ships with the package. A domain with broken signatures is refused rather than served. dnssec-failed.org is run on purpose to be broken, so it makes a nice test:
$ dig @172.29.77.11 dnssec-failed.org A | grep status
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 27401
$ dig @172.29.77.11 dnssec-failed.org A +cd +short # +cd = checking disabled: skip validation, to prove the problem is the signatures and not the network
96.99.227.255
SERVFAIL on a name that works elsewhere, and works with +cd, means a DNSSEC problem at the domain (or a wrong clock on your server). It is not your zone, and it is not named being broken.
Your first zone file
A zone is one domain's worth of records, kept in a text file. Declare the zone in named.conf.local, then write the file:
zone "lab.example.com" {
type primary;
file "/etc/bind/zones/db.lab.example.com";
};
(Older configs say "type master;" and "type slave;". BIND 9.18 still accepts both, so you do not have to rewrite old files.) The zone file, /etc/bind/zones/db.lab.example.com:
$TTL 1h
@ IN SOA ns1.lab.example.com. hostmaster.lab.example.com. (
2026100801 ; serial (YYYYMMDDnn)
1h ; refresh
15m ; retry
1w ; expire
1h ) ; negative-answer TTL
IN NS ns1.lab.example.com.
IN NS ns2.lab.example.com.
IN MX 10 mail.lab.example.com.
IN TXT "v=spf1 mx -all"
ns1 IN A 172.29.77.11
ns2 IN A 172.29.77.12
mail IN A 172.29.77.25
www IN A 172.29.77.80
web IN CNAME www
db1 IN A 172.29.77.50
The rules that matter:
$TTL 1h the default time a resolver may cache each answer. Short (minutes) before a planned change, long (hours) the rest of the time.
@ means "the zone name itself", here lab.example.com.
a blank first column means "same name as the line above": the NS, MX and TXT records belong to @.
names without a trailing dot get the zone name added. www means www.lab.example.com. A name WITH a dot at the end is complete. This is the rule that bites, see the next paragraph.
IN the class. Always IN.
CNAME an alias. It points at ANOTHER NAME, and the name that owns it may have no other record: no MX, no NS, no TXT, and so never at the zone name (@).
MX 10 name the lower number is tried first. An MX must point at a name with an A record, never at a CNAME.
NS at least two, ideally on different networks. Every NS must have an address (the A records for ns1 and ns2).
; a comment to the end of the line.
THE TRAILING DOT. Leave it off a name that is already complete and BIND "helpfully" adds the zone name a second time. It is not an error. It is not even a warning. Here the dot was left off the SOA's name server (ns1.lab.example.com hostmaster), and this is how the zone then reads:
$ named-checkzone -D lab.example.com broken.db | sed -n 1,3p
zone lab.example.com/IN: loaded serial 2026100801
OK
lab.example.com. 3600 IN SOA ns1.lab.example.com.lab.example.com. hostmaster.lab.example.com. 2026100801 3600 900 604800 3600
The check said "OK" and the zone is wrong. Whenever a name is wrong with the zone name repeated, a dot is missing. -D dumps the zone as BIND understood it, which is the way to see this kind of thing.
The same file carries more record types. These were added to the lab and queried:
_sip._tcp IN SRV 10 60 5060 sip.lab.example.com. a service: priority, weight, port, target
sip IN A 172.29.77.90
@ IN CAA 0 issue "letsencrypt.org" which certificate authority may issue certificates for this name
*.apps IN A 172.29.77.80 a wildcard: any name below apps, if nothing more exact exists
$ dig @172.29.77.11 +short _sip._tcp.lab.example.com SRV
10 60 5060 sip.lab.example.com.
$ dig @172.29.77.11 +short lab.example.com CAA
0 issue "letsencrypt.org"
$ dig @172.29.77.11 +short anything.apps.lab.example.com A
172.29.77.80
$ dig @172.29.77.11 +short lab.example.com TXT
"v=spf1 mx -all"
The SOA record and the serial number
The SOA (start of authority) record has seven fields, and the secondary server lives and dies by them:
ns1.lab.example.com. the primary name server for the zone
hostmaster.lab.example.com. the contact e-mail address, with the first dot standing for the @ (hostmaster@lab.example.com)
2026100801 the SERIAL
1h refresh how often a secondary checks the primary for a new serial
15m retry how soon a secondary tries again after a failed check
1w expire how long a secondary keeps answering if it cannot reach the primary at all, then it stops (SERVFAIL)
1h negative TTL how long resolvers remember "this name does not exist"
The serial is a number that must go UP every time you change the zone. A secondary compares serials, and if the primary's is not higher it assumes nothing changed and does not fetch anything. The convention is date plus a two digit counter: 2026100801 is the first change on 8 October 2026, 2026100802 the second. Here is what happens if you forget. A record was added, the primary reloaded, the serial left alone:
$ rndc reload lab.example.com
zone reload queued
$ dig @172.29.77.11 +short app.lab.example.com # the primary has it
172.29.77.60
$ dig @172.29.77.12 app.lab.example.com | grep status # the secondary has never heard of it
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 42968
$ for ip in 11 12; do dig @172.29.77.$ip lab.example.com SOA +short; done
ns1.lab.example.com. hostmaster.lab.example.com. 2026100801 3600 900 604800 3600
ns1.lab.example.com. hostmaster.lab.example.com. 2026100801 3600 900 604800 3600
Same serial on both, so everything looks healthy, while half your users get NXDOMAIN. This is the number one BIND mistake. Comparing the serials with dig on each server is the first thing to do when two servers disagree.
The reverse zone (PTR records)
Names to addresses is the forward zone. Addresses to names is the reverse zone, and it lives under in-addr.arpa with the address written backwards: 172.29.77.0/24 becomes 77.29.172.in-addr.arpa. Mail servers, logs and ssh all look at reverse names, and several mail systems reject mail from a server with no matching PTR.
zone "77.29.172.in-addr.arpa" {
type primary;
file "/etc/bind/zones/db.172.29.77";
};
and /etc/bind/zones/db.172.29.77:
$TTL 1h
@ IN SOA ns1.lab.example.com. hostmaster.lab.example.com. ( 2026100801 1h 15m 1w 1h )
IN NS ns1.lab.example.com.
IN NS ns2.lab.example.com.
11 IN PTR ns1.lab.example.com.
12 IN PTR ns2.lab.example.com.
25 IN PTR mail.lab.example.com.
80 IN PTR www.lab.example.com.
50 IN PTR db1.lab.example.com.
The left column is just the last number of the address. The target of a PTR is a complete name, so end it with a dot. The reverse zone has its own SOA and its own serial: forgetting to bump it is the same trap as above, and it is forgotten more often because nobody remembers they have one.
$ dig @172.29.77.11 +short -x 172.29.77.80
www.lab.example.com.
For public addresses the reverse zone belongs to whoever owns the address block (your provider), and you ask them to set the PTR or to delegate the zone to you.
Check before you load
Two checkers come with BIND, and they are the most useful tools in this guide. Run them every time, before the reload. A broken zone that fails to load on a running server is simply not served, and nobody notices until the phone rings.
$ named-checkconf && echo "(no output = OK)"
(no output = OK)
$ named-checkzone lab.example.com /etc/bind/zones/db.lab.example.com
zone lab.example.com/IN: loaded serial 2026100801
OK
$ named-checkzone 77.29.172.in-addr.arpa /etc/bind/zones/db.172.29.77
zone 77.29.172.in-addr.arpa/IN: loaded serial 2026100801
OK
named-checkzone takes the zone name and the file, and loads the zone the way named would. What it says when something is wrong (each of these was produced on purpose):
an address with a typo
dns_rdata_fromtext: t5:17: near '172.29.77.800': bad dotted quad
zone lab.example.com/IN: not loaded due to errors.
a CNAME next to other records (www already had an A record)
dns_master_load: t2:20: www.lab.example.com: CNAME and other data
an NS that points at a CNAME
zone lab.example.com/IN: NS 'web.lab.example.com' is a CNAME (illegal)
a name server with no address
zone lab.example.com/IN: NS 'ns2.lab.example.com' has no address records (A or AAAA)
an MX that points at a CNAME. This one is only a warning: the zone loads, mail may or may not get through, so treat it as an error
zone lab.example.com/IN: lab.example.com/MX 'web.lab.example.com' is a CNAME (illegal)
zone lab.example.com/IN: loaded serial 2026100801
named-checkconf finds problems in the configuration:
$ named-checkconf # a semicolon missing after allow-transfer { ... }
/etc/bind/named.conf.local:4: missing ';' before '}'
$ named-checkconf -z # -z also loads every zone, so a missing or broken zone file shows up too
zone 77.29.172.in-addr.arpa/IN: loading from master file /etc/bind/zones/db.172.29.77x failed: file not found
zone 77.29.172.in-addr.arpa/IN: not loaded due to errors.
Remember that named-checkzone cannot know what you meant. It reports a missing dot as "OK" (see above). It does not know the serial should have gone up. A good habit is a tiny script: check config, check both zones, reload only when all are clean.
Start, reload and rndc
sudo systemctl enable --now bind9 Debian, Ubuntu (the name named works too)
sudo systemctl enable --now named RHEL family
sudo systemctl status bind9 is it up, and the last log lines
sudo journalctl -u bind9 -e the messages when something refused to start
(not run in the lab: it had no systemd. It started named with "named -u bind", which runs it as the unprivileged bind user, exactly like the service does.) After the first start you rarely restart; you tell the running server what to do with rndc, a control tool that talks to named on 127.0.0.1 port 953 with a shared key (/etc/bind/rndc.key, created by the package, readable by root and the bind group only):
$ rndc status
version: BIND 9.18.39-0ubuntu0.24.04.7-Ubuntu (Extended Support Version) <id:> (none of your business)
boot time: Thu, 08 Oct 2026 17:54:47 GMT
last configured: Thu, 08 Oct 2026 17:54:47 GMT
configuration file: /etc/bind/named.conf
CPUs found: 8
worker threads: 8
number of zones: 105 (98 automatic)
debug level: 0
xfers running: 0
xfers deferred: 0
(the zone count includes the 98 built-in empty zones for private ranges.) The commands you will use:
rndc reload re-read the config and all zones whose files changed
rndc reload ZONE re-read one zone only (after editing its file)
rndc reconfig re-read the config and load NEW zones, without touching zones that are already loaded (after adding a zone block)
rndc retransfer ZONE on a secondary: throw away what you have and fetch the zone again, right now
rndc zonestatus ZONE type, file, serial, when it was loaded, when it will be refreshed
rndc flush empty the whole cache; rndc flushname example.com empties one name
rndc freeze ZONE / thaw ZONE for dynamic zones: write the journal into the file so you can edit it, then resume
rndc querylog on|off switch query logging on and off while the server runs
rndc dumpdb -cache write the cache to named_dump.db in the working directory (to see what is in there)
rndc stats write counters to named.stats in the working directory
rndc stop stop, saving state (the service manager does this for you)
$ rndc zonestatus lab.example.com
name: lab.example.com
type: primary
files: /etc/bind/zones/db.lab.example.com
serial: 2026100803
nodes: 12
last loaded: Thu, 08 Oct 2026 17:57:29 GMT
secure: no
dynamic: no
reconfigurable via modzone: no
After editing a zone file the routine is always the same: bump the serial, named-checkzone, rndc reload ZONE, then dig it. Four steps, every time, even for one record.
Test it with dig
Always test with dig (see the dig page) and always aim it at the server with @, so you ask your server and not whatever /etc/resolv.conf says. The records from the zone above:
$ dig @172.29.77.11 +short www.lab.example.com
172.29.77.80
$ dig @172.29.77.11 +short web.lab.example.com # a CNAME shows the alias first, then what it leads to
www.lab.example.com.
172.29.77.80
$ dig @172.29.77.11 +short lab.example.com MX
10 mail.lab.example.com.
$ dig @172.29.77.11 +short lab.example.com NS
ns1.lab.example.com.
ns2.lab.example.com.
$ dig @172.29.77.11 lab.example.com SOA +noall +answer
lab.example.com. 3600 IN SOA ns1.lab.example.com. hostmaster.lab.example.com. 2026100802 3600 900 604800 3600
A name that does not exist gets NXDOMAIN, together with the SOA in the authority section. Resolvers cache that "no" for the negative TTL, which is the last number of the SOA:
$ dig @172.29.77.11 nope.lab.example.com | grep -E "status|AUTHORITY" -A1
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 13655
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1
;; AUTHORITY SECTION:
lab.example.com. 3600 IN SOA ns1.lab.example.com. hostmaster.lab.example.com. 2026100802 3600 900 604800 3600
That is why a record you just added may "not exist" for up to an hour on a resolver that asked a moment before you added it: cached no. It is the one propagation delay that is real.
A secondary server: NOTIFY and zone transfers
A second authoritative server is the main reason to have DNS "redundancy". If ns1 is down, ns2 keeps answering. (Registrars require at least two name servers anyway.) The secondary keeps no zone files you write: it copies the zone from the primary, so you only ever edit in one place. On ns2, named.conf.local:
zone "lab.example.com" {
type secondary;
file "/var/cache/bind/db.lab.example.com";
primaries { 172.29.77.11; };
};
zone "77.29.172.in-addr.arpa" {
type secondary;
file "/var/cache/bind/db.172.29.77";
primaries { 172.29.77.11; };
};
The file is written into /var/cache/bind, the directory named may write in; keep it out of /etc/bind. And on ns1 each zone says who may copy it and who to tell when it changes:
allow-transfer { 172.29.77.12; };
also-notify { 172.29.77.12; };
The mechanism: when the primary reloads a zone with a new serial, it sends a NOTIFY to its secondaries. The secondary asks for the SOA, sees the higher serial and pulls the zone (AXFR, the full copy, or IXFR, only the difference). Without a NOTIFY the secondary still checks on its own every "refresh" interval of the SOA. This is the serial bumped from 2026100801 to 2026100802, as both servers logged it:
ns1: zone lab.example.com/IN: loaded serial 2026100802
ns1: zone lab.example.com/IN: sending notifies (serial 2026100802)
ns2: client 172.29.77.11#47595: received notify for zone 'lab.example.com'
ns2: zone lab.example.com/IN: notify from 172.29.77.11#47595: serial 2026100802
ns2: zone lab.example.com/IN: Transfer started.
ns2: zone lab.example.com/IN: transferred serial 2026100802
ns2: transfer of 'lab.example.com/IN' from 172.29.77.11#53: Transfer completed: 1 messages, 13 records, 326 bytes, 0.001 secs
$ dig @172.29.77.12 +short app.lab.example.com # now the secondary agrees
172.29.77.60
You can ask for the copy yourself, which is what a secondary does and what an attacker would like to do:
$ dig @172.29.77.12 lab.example.com AXFR +noall +answer | head -4
lab.example.com. 3600 IN SOA ns1.lab.example.com. hostmaster.lab.example.com. 2026100801 3600 900 604800 3600
lab.example.com. 3600 IN NS ns1.lab.example.com.
lab.example.com. 3600 IN NS ns2.lab.example.com.
lab.example.com. 3600 IN MX 10 mail.lab.example.com.
A very common first-day failure, and it happened in the lab. The forward zone arrived, the reverse zone never did, because the reverse zone block had no allow-transfer (and the global one is "none"). The secondary just kept retrying:
ns2: transfer of '77.29.172.in-addr.arpa/IN' from 172.29.77.11#53: failed while receiving responses: REFUSED
ns1: client 172.29.77.12#45317 (77.29.172.in-addr.arpa): zone transfer '77.29.172.in-addr.arpa/AXFR/IN' denied
$ rndc zonestatus 77.29.172.in-addr.arpa # on ns2
rndc: 'zonestatus' failed: zone not loaded
Read BOTH logs: the secondary tells you REFUSED, the primary tells you which rule said no. Fix the primary, reload, and force the secondary with rndc retransfer ZONE rather than waiting for the retry timer. A zone transfer runs over TCP port 53, so a firewall that only lets UDP through breaks it, and so does a primary that only listens on UDP.
Lock transfers with a TSIG key
Allowing "the address 172.29.77.12" is a weak lock: addresses can be faked on a bad network. A TSIG key is a shared secret that signs each transfer, so only a server holding the key can fetch the zone. Make one on the primary:
$ tsig-keygen -a hmac-sha256 xfer-key
key "xfer-key" {
algorithm hmac-sha256;
secret "(a long random base64 string, not shown)";
};
Save that block in /etc/bind/xfer-key.conf on BOTH servers, readable only by root and the bind group (chown root:bind and chmod 640), and include it in named.conf before it is used:
-rw-r----- 1 root bind 100 Oct 8 17:56 /etc/bind/xfer-key.conf
include "/etc/bind/xfer-key.conf"; on both servers, near the top of named.conf
allow-transfer { key xfer-key; }; on the primary, in each zone (replacing the address)
server 172.29.77.11 { keys { xfer-key; }; }; on the secondary: "sign everything you send to this server"
Copy the key between servers over ssh and never paste it into a chat, a ticket or a web page. The result: a plain request is refused, a signed one works, and the secondary's log says it used the key:
$ dig @172.29.77.11 lab.example.com AXFR | grep -E "Transfer failed|XFR size" # no key
; Transfer failed.
$ dig -k /etc/bind/xfer-key.conf @172.29.77.11 lab.example.com AXFR | grep -E "Transfer failed|XFR size"
;; XFR size: 13 records (messages 1, bytes 446)
ns2: transfer of 'lab.example.com/IN' from 172.29.77.11#53: connected using 172.29.77.11#53 TSIG xfer-key
ns2: zone lab.example.com/IN: transferred serial 2026100802: TSIG 'xfer-key'
A key only works if both clocks agree (a signature is valid for about five minutes), so keep both servers on NTP.
Dynamic updates with nsupdate
Some zones change all the time: machines that register themselves, a certificate tool answering a DNS challenge, a DHCP server. Instead of editing a file, a client sends an update that is signed with a key. Make one more key, as above (ddns-key) and give a separate zone to it, so that a leaked key can only damage that one zone:
zone "dyn.lab.example.com" {
type primary;
file "/var/lib/bind/db.dyn.lab.example.com";
allow-update { key ddns-key; };
};
Then send the update:
$ cat up.txt
server 172.29.77.11
zone dyn.lab.example.com
update add laptop.dyn.lab.example.com. 300 A 172.29.77.101
update add phone.dyn.lab.example.com. 300 A 172.29.77.102
send
$ nsupdate -k /etc/bind/ddns-key.conf up.txt && echo "update ok"
update ok
$ dig @172.29.77.11 +short laptop.dyn.lab.example.com phone.dyn.lab.example.com
172.29.77.101
172.29.77.102
$ printf 'server 172.29.77.11\nzone dyn.lab.example.com\nupdate add evil.dyn.lab.example.com. 300 A 6.6.6.6\nsend\n' | nsupdate # no key
update failed: REFUSED
The serial is raised by named itself on each update (it went from 1 to 3 after two). Deleting works the same way: update delete phone.dyn.lab.example.com. A, then send.
THE TRAP that happened in the lab: a dynamic zone also needs a journal file (.jnl) next to the zone file, and named can only write where it is allowed to. With the zone file in /etc/bind/zones, which named may read but not write, the very first update fails:
$ nsupdate -k /etc/bind/ddns-key.conf up.txt
update failed: SERVFAIL
named: /etc/bind/zones/db.dyn.lab.example.com.jnl: create: permission denied
named: updating zone 'dyn.lab.example.com/IN': error: journal open failed: unexpected error
Keep dynamic zones in /var/lib/bind (Debian, Ubuntu) or /var/named/dynamic (RHEL), owned by the bind user. Moving the file after the zone has been loaded needs a restart of named, not a reload: the reload answers "dynamic zone" and refuses. (On Ubuntu with AppArmor, which restricts where named may write, the allowed places are exactly those folders; not run in the lab.)
Once a zone is dynamic, the file on disk is not the truth: the truth is the file plus its journal, and updates sit in the journal for a while. NEVER edit the file by hand while the server runs. To edit it, freeze it, which writes everything into the file, then thaw:
$ ls -l /var/lib/bind
-rw-r--r-- 1 bind bind 113 Oct 8 17:57 db.dyn.lab.example.com
-rw-r--r-- 1 bind bind 1096 Oct 8 17:57 db.dyn.lab.example.com.jnl
$ rndc freeze dyn.lab.example.com && cat /var/lib/bind/db.dyn.lab.example.com
$ORIGIN .
$TTL 300 ; 5 minutes
dyn.lab.example.com IN SOA ns1.lab.example.com. hostmaster.lab.example.com. (
3 ; serial
...
$ORIGIN dyn.lab.example.com.
laptop A 172.29.77.101
$ rndc thaw dyn.lab.example.com # edit between freeze and thaw if you must, then thaw
(the file is rewritten in BIND's own layout, which is why comments and formatting you wrote by hand are lost.)
Delegating a subdomain
When a part of the tree belongs to someone else (a team, a customer, another server), delegate it: in the parent zone you add NS records for the child, and the child's servers answer for it. If the child's name server is inside the child's own domain, you also need a glue record, its address, in the parent, or nobody can find the name server of the name server. The checker knows:
dev IN NS ns1.dev.lab.example.com.
ns1.dev IN A 172.29.77.31 the glue
$ named-checkzone lab.example.com db.lab.example.com # with the NS line and no glue
zone lab.example.com/IN: dev.lab.example.com/NS 'ns1.dev.lab.example.com' has no REQUIRED GLUE address records (A or AAAA)
With the glue added, asking the parent about something below the delegation gets a referral, not an answer: no aa flag, an empty answer section and the NS (with its address) in the authority and additional sections.
$ dig @172.29.77.11 www.dev.lab.example.com +norecurse
;; flags: qr ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 2
;; AUTHORITY SECTION:
dev.lab.example.com. 3600 IN NS ns1.dev.lab.example.com.
;; ADDITIONAL SECTION:
ns1.dev.lab.example.com. 3600 IN A 172.29.77.31
For a real domain the same two steps apply at the top: the registrar publishes your NS (and glue) in the parent zone (.com and friends), and you keep the NS records in your own zone in step with it. When they differ, resolvers get a "lame delegation" and some of your users get the old server's answers. dig +trace example.com shows the whole chain.
Split DNS with views
Sometimes the same name must mean two things: www is 10.0.0.80 for the people inside and 203.0.113.80 for everyone else. A view picks the answer by who is asking. The first view whose match-clients matches wins, so the specific one goes first. This ran as a scratch named on port 5353:
view "internal" {
match-clients { 127.0.0.2; 10.0.0.0/8; };
zone "example.org" { type primary; file "/tmp/views/internal.db"; };
};
view "external" {
match-clients { any; };
zone "example.org" { type primary; file "/tmp/views/external.db"; };
};
$ dig @127.0.0.1 -p 5353 -b 127.0.0.2 +short www.example.org # an inside client
10.0.0.80
$ dig @127.0.0.1 -p 5353 +short www.example.org # anybody else
203.0.113.80
The rule everyone learns once: as soon as ONE view exists, EVERY zone must sit inside a view, including the defaults. A zone left outside gets this:
$ named-checkconf bad.conf
bad.conf:2: when using 'view' statements, all zones must be in views
Put the shared zone blocks in a file and include it into each view, so two views do not drift apart. Views also multiply your work (every zone twice, transfers per view), which is when many people decide on one view and a separate internal subdomain instead.
Logging
By default named logs through syslog (the journal) and says very little. A file-based setup gives you transfer and security messages, and optionally every query. This is the logging block the lab used (in its own file, included from named.conf):
logging {
channel general_file {
file "/var/log/named/named.log" versions 3 size 5m;
severity info;
print-time yes;
print-severity yes;
print-category yes;
};
channel queries_file {
file "/var/log/named/queries.log" versions 3 size 5m;
severity info;
print-time yes;
};
category default { general_file; };
category xfer-in { general_file; };
category xfer-out { general_file; };
category notify { general_file; };
category queries { queries_file; };
};
The directory must exist and belong to bind (mkdir /var/log/named && chown bind:bind /var/log/named). "versions 3 size 5m" is built-in log rotation: three old files of 5 MB each. On Ubuntu, AppArmor must allow the path too (not run in the lab). The categories worth knowing: default, xfer-in and xfer-out (transfers), notify, security (denied requests), queries (every question), dnssec, lame-servers (broken delegations elsewhere; very noisy, send it to null if it drowns the rest).
A query log line, and what the flags mean:
client @0x738b980d55c8 172.29.77.11#58292 (anything.apps.lab.example.com): query: anything.apps.lab.example.com IN A +E(0)K (172.29.77.11)
client @0x738b980d55c8 172.29.77.12#48606/key xfer-key (lab.example.com): query: lab.example.com IN SOA -SE(0) (172.29.77.11)
+ or - the client asked for recursion (+) or not (-) S the request was signed (TSIG) T over TCP E(0) EDNS version 0 K the client sent a DNS cookie
Query logging on a busy resolver is a firehose and costs speed. Switch it on to debug and off after: rndc querylog on, then rndc querylog off. The statistics are cheaper: rndc stats, then read named.stats in the working directory.
Lines like "network unreachable resolving ... 2001:500:..." mean the server tried IPv6 addresses and the machine has no IPv6. Harmless in the log, and "listen-on-v6 { none; }" and a v4-only network are why you see them. Using "-4" in the service options (OPTIONS="-4" in /etc/default/named on Debian and Ubuntu) makes named stay with IPv4 and silences them. (not run in the lab.)
The firewall, and clients that use your server
DNS uses port 53 on BOTH UDP and TCP. UDP carries the everyday questions, TCP carries zone transfers and every answer too big for one packet (DNSSEC answers often are). A firewall rule that opens only UDP 53 makes a server that mostly works and fails on large answers and on every transfer, which is a very specific kind of misery.
sudo ufw allow from 172.29.77.0/24 to any port 53 Ubuntu (ufw covers both protocols with this form)
sudo firewall-cmd --permanent --add-service=dns && sudo firewall-cmd --reload RHEL family
rndc uses 127.0.0.1:953: leave it closed to everyone else
(not run in the lab, where a firewall was not set up.) Then point the clients at the new server. On a server or a laptop with netplan (Ubuntu), under the interface:
nameservers:
addresses: [172.29.77.11, 172.29.77.12]
search: [lab.example.com]
or hand out the same two addresses with DHCP (option 6) so every machine gets them without a visit. Test from the client with the default resolver, without @, and the answer should come back with your data: getent hosts www.lab.example.com or dig www.lab.example.com. The search line lets users type just "www" or "db1".
Security checklist
recursion only for your own networks (allow-recursion), and none at all on a public authoritative server. Test from outside: a REFUSED is what you want.
allow-transfer { none; } globally; open it per zone, and use a TSIG key, not only an address.
allow-update only with a key, per zone, and never an address list ("any" would let the world rewrite your zone).
hide the version, listen only where needed, keep rndc on the loopback and its key private.
run as the bind (or named) user, never as root: the packages do this already; do not undo it. A chroot is not needed on a modern system, the service sandboxing and AppArmor do the job.
keep BIND updated: it is exposed to the internet, and DNS software has regular security releases. Subscribe to your distribution's security announcements.
keep a copy of the zone files in version control (git). They are small text files and "who changed it, when and why" is a question you will be asked.
have at least two name servers in different places, and watch the serial on all of them (a cron job comparing dig +short SOA across servers is five lines of shell).
Common problems
SERVFAIL for your own zone the zone failed to load (the log says why: grep -i "zone" /var/log/named/named.log or journalctl -u bind9). Run named-checkzone, fix, rndc reload
REFUSED from your server allow-query or allow-recursion does not include the client. dig -b ADDRESS from the right source shows it. A secondary with "recursion no" refuses names that are not its zones: normal
NXDOMAIN for a name you just added the serial was not raised so the secondary is stale (compare SOA on both); or a resolver cached the "no" for the negative TTL; or the name has the zone name twice (a missing dot)
A name ends with .lab.example.com.lab.example.com a missing dot at the end of a complete name in the zone file. named-checkzone -D shows the zone as BIND understood it
The secondary never gets the zone read the log on ns2 AND on ns1. REFUSED = allow-transfer on the primary; a timeout = a firewall or a primary not answering on TCP 53; nothing at all = wrong primaries address or the zone name does not match. Then rndc retransfer ZONE
The secondary answers old data the serial. Raise it, reload the primary, check NOTIFY in the log, or wait for the refresh interval. A secondary that cannot reach the primary for longer than expire stops answering: SERVFAIL
rndc: connect failed: connection refused named is not running, or it was started without the control channel. systemctl status; the rndc.key must match on both sides
rndc: 'reload' failed: dynamic zone the zone takes dynamic updates. Use rndc freeze, edit, rndc thaw (or sync); never reload it from hand edits
nsupdate returns SERVFAIL the journal cannot be written: move the dynamic zone file to /var/lib/bind or /var/named/dynamic and restart named
nsupdate returns REFUSED the key is wrong, or the zone has no allow-update for it, or the server and client clocks differ by more than five minutes
permission denied loading a zone file the file or its folder is not readable by the bind user: chown root:bind FILE and chmod 640. The check as the right user: su -s /bin/bash bind -c "named-checkzone ZONE FILE"
Starting fails with "address already in use" another DNS server holds port 53 (on Ubuntu: systemd-resolved on 127.0.0.53, or dnsmasq): use listen-on to pick addresses that are free, or stop the other one. ss -lntup | grep ':53 ' shows who has it (not run in the lab)
named runs, the clients cannot ask firewall (UDP and TCP 53), listen-on without the server's real address, or allow-query without the client's network
The mail server's PTR check fails no reverse zone for the address, or a PTR that does not match the forward name. dig -x ADDRESS, then dig NAME: they must agree
A change made hours ago is not visible a TTL still being served from caches. dig +norecurse @SERVER NAME shows what the authoritative server says. Lower the $TTL a day BEFORE a planned move
SERVFAIL on one website, fine elsewhere DNSSEC: dig +cd works and dig does not. A bad signature at the domain, or the server's clock is wrong. Check timedatectl
Everything stopped after editing named.conf named-checkconf. A reload with a broken config keeps the old one running (it tells you in the log), but a RESTART with a broken config will not start at all
It's DNS it is always DNS. Check this one first and save yourself the afternoon
Tips
Bump the serial. Say it out loud before every reload. Use the date + counter format: 2026100801.
Run named-checkconf and named-checkzone before every reload. A tiny wrapper script that does both and reloads only on success will save you more than anything else in this guide.
End every complete name in a zone file with a dot. If you see the zone name repeated in an answer, a dot is missing.
Keep authoritative and recursive duties separate where you can, and never run an open resolver.
Test from the client's point of view: dig @SERVER with the right source, then ask both servers, then compare the serials.
Lower the TTL to five minutes a day before a planned change and raise it again after. Waiting for propagation is the part you cannot hurry; shortening the wait beforehand is the part you can.
Put the zone files in git. Review changes like code. "Who changed the MX" should have an answer.
Read the log of BOTH ends of a transfer. One side tells you what happened, the other tells you why.
Do not edit dynamic zones by hand: rndc freeze, edit, rndc thaw.
Write down which server is the primary. The first outage drill is a bad time to find out that nobody knows.