DNS HOWTO: resolvers, Unbound and BIND 9 on Linux today

Updated November 2026

DNS on Linux has three separate jobs: the resolver on every machine that asks questions, an optional caching resolver that answers for your whole network, and an authoritative server that publishes your own zone. This page sets up each one with current tools: systemd-resolved, Unbound and BIND 9.20, and shows how to debug with dig and drill.

The classic DNS HOWTO from 2001 (BIND 8 era) is kept by the Linux Documentation Project at tldp.org. Everything below is written for today’s releases.

Program (getaddrinfo) Local stub systemd-resolved Caching resolver Unbound Root servers (.) TLD servers (.com) Authoritative (BIND)
A lookup for www.example.com: the caching resolver walks from the root down to the zone’s authoritative server and caches every answer.

The resolver on every machine

Programs do not talk DNS themselves. They call the C library (getaddrinfo()), which reads the hosts: line in /etc/nsswitch.conf to decide where to look: /etc/hosts, DNS, mDNS and so on. Use getent to test the same path your programs use:

grep '^hosts' /etc/nsswitch.conf
getent hosts www.example.com
ls -l /etc/resolv.conf

systemd-resolved

On Ubuntu and Fedora, systemd-resolved is the local resolver. It runs a stub listener on 127.0.0.53 (full features, including LLMNR and mDNS) and on 127.0.0.54 (plain “proxy” mode). In its recommended mode, /etc/resolv.conf is a symlink to /run/systemd/resolve/stub-resolv.conf, which lists only nameserver 127.0.0.53 plus your search domains. The real upstream servers are kept per network interface; you see them with resolvectl:

resolvectl status
resolvectl query www.example.com
resolvectl statistics
sudo resolvectl flush-caches

For glibc programs to use resolved directly (not only through the stub), the nss-resolve module must be in nsswitch. The systemd manual recommends this line:

hosts:          mymachines resolve [!UNAVAIL=return] files myhostname dns

On Debian 13, resolved is a separate package (systemd-resolved, plus libnss-resolve for the NSS module) and is not installed by default; ifupdown or NetworkManager writes a plain /etc/resolv.conf instead. RHEL, Alma and Rocky also normally let NetworkManager write that file. In that case, cat /etc/resolv.conf shows the real servers.

Do not hand-edit a symlink. If /etc/resolv.conf points into /run/systemd/resolve/, your edits are lost. Set servers through NetworkManager (nmcli connection modify … ipv4.dns), systemd-networkd, or a resolved drop-in file instead.

To send every query from a machine to your own resolver, create /etc/systemd/resolved.conf.d/lan-dns.conf:

[Resolve]
DNS=192.168.1.1
Domains=~.

Domains=~. is a “route-only” domain for the root, which means “use this server for all names”. Apply it with sudo systemctl restart systemd-resolved.

A caching resolver with Unbound

A caching resolver answers for many machines, remembers answers until their TTL runs out, and checks DNSSEC signatures. Unbound (from NLnet Labs) does this job well and has nothing to do with publishing zones. By default it listens only on localhost and refuses queries from anywhere except 127.0.0.0/8, so you must open it to your LAN on purpose.

# Debian / Ubuntu
sudo apt install unbound
# Fedora / RHEL / Alma / Rocky
sudo dnf install unbound

On Debian and Ubuntu, put local settings in a new file such as /etc/unbound/unbound.conf.d/lan.conf; the packaged files in that directory already enable remote control and the DNSSEC root trust anchor. On Fedora and RHEL, add the same lines to the server: section of /etc/unbound/unbound.conf. This example serves the 192.168.1.0/24 network from a router at 192.168.1.1:

server:
    interface: 127.0.0.1
    interface: 192.168.1.1
    access-control: 127.0.0.0/8 allow
    access-control: 192.168.1.0/24 allow
    hide-identity: yes
    hide-version: yes
    prefetch: yes
    private-domain: "lan"
    local-zone: "lan." static
    local-data: "nas.lan. IN A 192.168.1.20"
    local-data-ptr: "192.168.1.20 nas.lan"

The last four lines give local machines names under .lan without running a separate zone. prefetch: yes refreshes popular names shortly before they expire, at the cost of about 10% more upstream traffic. Check and start it:

sudo unbound-checkconf
sudo systemctl enable --now unbound
sudo systemctl restart unbound      # after later changes
dig @192.168.1.1 www.isc.org +dnssec

unbound-checkconf prints no errors in /etc/unbound/unbound.conf when the file parses. In the dig output, the ad flag in the header line means Unbound validated the DNSSEC signatures. Open port 53 to the LAN only: with firewalld, add the service to the zone that holds the LAN interface, not the Internet-facing one (for example sudo firewall-cmd --permanent --zone=internal --add-service=dns then sudo firewall-cmd --reload); with ufw, sudo ufw allow from 192.168.1.0/24 to any port 53.

Hand it out by DHCP. Give clients this resolver’s address in your DHCP options (see the DHCP mini-HOWTO) so you do not have to configure each machine.

An authoritative zone with BIND 9

BIND 9 is ISC’s DNS server. As of November 2026, the 9.20 branch is the current stable and extended-support (ESV) version, and the older 9.18 branch reached end of life in mid-2026. Debian 13 ships 9.20. For a server that only publishes your zones, turn recursion off: an authoritative server should not also be a public resolver.

Where BIND lives on each distribution
Debian / UbuntuFedora / RHEL family
Installsudo apt install bind9 bind9-utils bind9-dnsutilssudo dnf install bind bind-utils
Main config/etc/bind/named.conf (includes named.conf.options and named.conf.local)/etc/named.conf
Zone files/var/lib/bind/ (writable by the bind user)/var/named/
Servicenamednamed

On Debian, replace the options block in /etc/bind/named.conf.options with:

options {
    directory "/var/cache/bind";
    recursion no;
    allow-transfer { none; };
    listen-on { any; };
    listen-on-v6 { any; };
};

Then declare the zone in /etc/bind/named.conf.local. Here 198.51.100.53 is a secondary server that may copy the zone:

zone "example.com" {
    type primary;
    file "/var/lib/bind/db.example.com";
    allow-transfer { 198.51.100.53; };
    also-notify { 198.51.100.53; };
};

On Fedora and RHEL, put the zone block in /etc/named.conf with file "example.com.zone"; (relative to /var/named), and change the shipped listen-on, listen-on-v6 and allow-query lines, which limit the server to localhost.

The zone file

$TTL 1h
@       IN  SOA  ns1.example.com. hostmaster.example.com. (
                 2026110401 ; serial (YYYYMMDDnn)
                 12h        ; refresh
                 15m        ; retry
                 4w         ; expire
                 1h )       ; negative-caching TTL
        IN  NS   ns1.example.com.
        IN  NS   ns2.example.net.
        IN  MX   10 mail.example.com.
        IN  A    192.0.2.10
ns1     IN  A    192.0.2.2
mail    IN  A    192.0.2.4
www     IN  A    192.0.2.10
www     IN  AAAA 2001:db8::10
docs    IN  CNAME www

@ means the zone name itself. Names without a trailing dot get the zone name added, so www becomes www.example.com.. The second field of the SOA, hostmaster.example.com., is the contact address with the first dot standing for @. A zone needs at least two name servers, ideally on different networks.

Check, load and test

sudo named-checkconf -z
sudo named-checkzone example.com /var/lib/bind/db.example.com
sudo systemctl enable --now named
sudo rndc reload example.com         # after each zone edit
dig @192.0.2.2 www.example.com A +norecurse

A good zone check prints:

zone example.com/IN: loaded serial 2026110401
OK

In the dig answer, the aa (authoritative answer) flag shows the reply came from your zone, not from a cache. named-checkconf -z also test-loads every primary zone named in the config.

Chroot is no longer the main defense

The old Chroot-BIND HOWTO jailed named in its own directory tree. Today the packages run named as an unprivileged user under systemd, and on Fedora and RHEL SELinux confines it. Red Hat states that BIND under SELinux in enforcing mode is more secure than a change-root setup; a bind-chroot package with a named-chroot service still exists there if a policy demands it.

DNSSEC in two lines

As a resolver, BIND validates DNSSEC by default with the built-in root trust anchor, and Unbound does so whenever a trust anchor file is configured, as Debian’s package does out of the box. To sign your own zone, BIND’s Key and Signing Policy does the whole job. Add one line to the zone block:

    dnssec-policy default;

BIND then creates the keys, signs the zone, keeps a signed copy next to the zone file (with a .signed extension) and rolls signatures over by itself. It needs write access to the zone directory, which is why Debian zones belong in /var/lib/bind/. The last step is manual: give the DS record for your key to your domain registrar, as described in the BIND manual’s DNSSEC chapter.

Debugging with dig and drill

dig comes from bind9-dnsutils (Debian) or bind-utils (Fedora/RHEL). drill, from ldnsutils or ldns-utils, does the same with a smaller code base.

dig www.example.com              # ask the servers in /etc/resolv.conf
dig @192.168.1.1 www.example.com +short
dig example.com MX
dig -x 192.0.2.10               # reverse (PTR) lookup
dig www.example.com +trace       # follow the delegation from the root
drill -T www.example.com         # the same trace with drill
drill -D www.example.com         # ask for DNSSEC records
resolvectl query www.example.com # what systemd-resolved returns

Read the header first: status: NOERROR with an empty answer means the name exists but has no record of that type; NXDOMAIN means the name does not exist; SERVFAIL from a validating resolver often means a DNSSEC failure or an unreachable authoritative server. Logs are in journalctl -u named or journalctl -u unbound.

Mistakes to avoid

Sources