Unbound is a validating, recursive and caching DNS resolver developed by NLnet Labs. Instead of sending every lookup to your provider's or a public resolver, Unbound walks the DNS tree from the root servers itself, validates DNSSEC signatures and caches the answers. In this tutorial you will install Unbound on Ubuntu 24.04, serve the server itself and a private network, verify DNSSEC, add local hostnames and, optionally, forward queries over DNS-over-TLS.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
  • A non-root user with sudo privileges.
  • Optionally, a private network (such as a CubePath private network) with the clients that will use the resolver. This guide uses 10.10.0.0/24 as the private range and your_private_ip as the resolver's address on it; replace both with your own values.

Step 1 - Installing Unbound

Install Unbound together with bind9-dnsutils, which provides the dig command used for testing:

sudo apt update
sudo apt install unbound bind9-dnsutils

The package starts Unbound right away, listening on 127.0.0.1 only. Check the version and the service state:

unbound -V | head -n 1
systemctl status unbound --no-pager
Version 1.19.2
● unbound.service - Unbound DNS server
     Loaded: loaded (/usr/lib/systemd/system/unbound.service; enabled; preset: enabled)
     Active: active (running) since ...

Ubuntu's package already ships two useful snippets in /etc/unbound/unbound.conf.d/: root-auto-trust-anchor-file.conf, which enables DNSSEC validation with an automatically updated root key in /var/lib/unbound/root.key, and remote-control.conf, which enables the unbound-control management tool over a local Unix socket. You will keep both and put your own settings in a separate file.

Unbound also has the root server addresses compiled in, so you do not need to download a root.hints file.

Step 2 - Keeping systemd-resolved out of the way

Ubuntu 24.04 runs systemd-resolved, which listens on 127.0.0.53 and 127.0.0.54 port 53. Because Unbound will bind to 127.0.0.1 and your private IP explicitly, the two do not conflict and you do not need to disable systemd-resolved. Confirm what is listening on port 53:

sudo ss -lnup 'sport = :53'
State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
UNCONN 0      0         127.0.0.54:53        0.0.0.0:*     users:(("systemd-resolve",...))
UNCONN 0      0      127.0.0.53%lo:53        0.0.0.0:*     users:(("systemd-resolve",...))
UNCONN 0      0          127.0.0.1:53        0.0.0.0:*     users:(("unbound",...))

Step 3 - Configuring Unbound as a private resolver

Create a configuration file for your resolver:

sudo nano /etc/unbound/unbound.conf.d/resolver.conf

Add the following content, replacing your_private_ip and 10.10.0.0/24:

server:
    # Listen on loopback and on the private network only
    interface: 127.0.0.1
    interface: your_private_ip
    port: 53
    do-ip6: no

    # Who may query. Anything not listed is refused.
    access-control: 127.0.0.0/8 allow
    access-control: 10.10.0.0/24 allow

    # Performance: roughly one thread per CPU core
    num-threads: 2
    so-reuseport: yes
    msg-cache-size: 64m
    rrset-cache-size: 128m

    # Keep popular records warm and answer from cache if upstream is slow
    prefetch: yes
    prefetch-key: yes
    serve-expired: yes
    serve-expired-ttl: 86400
    aggressive-nsec: yes

    # Privacy and hardening
    hide-identity: yes
    hide-version: yes
    qname-minimisation: yes
    harden-glue: yes
    harden-dnssec-stripped: yes
    edns-buffer-size: 1232

    verbosity: 1

A few notes on these settings:

  • access-control is what keeps the resolver private. By default Unbound refuses every address that is not listed, except localhost.
  • rrset-cache-size should be about twice msg-cache-size. Increase both if the resolver serves many clients and has spare RAM.
  • serve-expired lets Unbound answer with a recently expired record while it refreshes it in the background, which hides slow or failing authoritative servers from your clients.
  • If you only want a resolver for the server itself, remove the interface: your_private_ip and 10.10.0.0/24 lines.

Check the syntax before applying it:

sudo unbound-checkconf
unbound-checkconf: no errors in /etc/unbound/unbound.conf

Restart Unbound to load the configuration:

sudo systemctl restart unbound

Confirm that it now listens on both addresses:

sudo ss -lnup 'sport = :53' | grep unbound

Step 4 - Testing resolution and DNSSEC

Send a query to Unbound on the loopback address:

dig @127.0.0.1 cubepath.com

The first lookup takes longer because Unbound resolves it from the root servers. Run the same command again and the Query time drops to 0 or 1 ms, which means the answer came from the cache:

;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41022
...
;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1) (UDP)

Now verify DNSSEC validation. A correctly signed domain returns the ad (authenticated data) flag:

dig @127.0.0.1 isc.org +dnssec | grep flags:
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

A domain with deliberately broken signatures must fail with SERVFAIL:

dig @127.0.0.1 dnssec-failed.org | grep status:
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 5310

If dnssec-failed.org resolves normally, validation is not active: check that /etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf exists and that /var/lib/unbound/root.key is present.

Step 5 - Opening the firewall for the private network

If you use UFW, allow DNS only from your private range. Never open port 53 to everyone:

sudo ufw allow from 10.10.0.0/24 to any port 53 proto udp
sudo ufw allow from 10.10.0.0/24 to any port 53 proto tcp
sudo ufw status

From a client on the private network, test the resolver:

dig @your_private_ip cubepath.com +short

To make a client use it permanently, set your_private_ip as its nameserver. On an Ubuntu client with netplan, add it under nameservers.addresses for the private interface and run sudo netplan apply.

Step 6 - Using Unbound for the server itself

The server still resolves through systemd-resolved, which uses the DNS servers received from DHCP or netplan. To send its own lookups to Unbound, create a drop-in for systemd-resolved:

sudo mkdir -p /etc/systemd/resolved.conf.d
sudo nano /etc/systemd/resolved.conf.d/unbound.conf
[Resolve]
DNS=127.0.0.1
Domains=~.

Domains=~. makes this server the preferred one for all domains. Restart the service and check the result:

sudo systemctl restart systemd-resolved
resolvectl status | head -n 8
Global
         Protocols: -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
  resolv.conf mode: stub
Current DNS Server: 127.0.0.1
       DNS Servers: 127.0.0.1
        DNS Domains: ~.

Step 7 - Adding local hostnames

Unbound can answer for internal names that do not exist in public DNS. Create a separate file for them:

sudo nano /etc/unbound/unbound.conf.d/local-zone.conf
server:
    local-zone: "internal.example.com." static
    local-data: "web01.internal.example.com. IN A 10.10.0.10"
    local-data: "db01.internal.example.com. IN A 10.10.0.20"
    local-data-ptr: "10.10.0.10 web01.internal.example.com"
    local-data-ptr: "10.10.0.20 db01.internal.example.com"

    # Block a domain for every client
    local-zone: "blocked.example.net." always_nxdomain

The static type answers only with the data you define and returns NXDOMAIN for any other name inside internal.example.com, so internal names never leak to the Internet. Check and reload:

sudo unbound-checkconf && sudo systemctl restart unbound
dig @127.0.0.1 web01.internal.example.com +short
dig @127.0.0.1 -x 10.10.0.10 +short
10.10.0.10
web01.internal.example.com.

Step 8 - Forwarding over DNS-over-TLS (optional)

By default Unbound performs full recursion, which is the most private option because no single upstream sees all your queries. If you prefer to send everything to a trusted public resolver, encrypt that traffic with DNS-over-TLS so your provider cannot read or modify it.

sudo nano /etc/unbound/unbound.conf.d/forward-tls.conf
server:
    tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt

forward-zone:
    name: "."
    forward-tls-upstream: yes
    forward-addr: 9.9.9.9@853#dns.quad9.net
    forward-addr: 149.112.112.112@853#dns.quad9.net
    forward-addr: 1.1.1.1@853#cloudflare-dns.com
    forward-addr: 1.0.0.1@853#cloudflare-dns.com

tls-cert-bundle is required: without it Unbound cannot verify the upstream certificates. The name after # must match the certificate the resolver presents. Apply and test:

sudo unbound-checkconf && sudo systemctl restart unbound
dig @127.0.0.1 example.com +short

To confirm that queries leave the server encrypted, watch port 853 in a second terminal while you run a new dig for a name that is not cached yet:

sudo tcpdump -ni any port 853 -c 10

DNSSEC validation stays active when forwarding, so the dnssec-failed.org test from Step 4 must still return SERVFAIL.

Step 9 - Checking cache statistics

unbound-control shows how the resolver is performing:

sudo unbound-control stats_noreset | grep -E '^total\.num\.(queries|cachehits|cachemiss)='
total.num.queries=1284
total.num.cachehits=1051
total.num.cachemiss=233

Useful commands when you change records upstream and do not want to wait for the TTL:

sudo unbound-control flush www.example.com
sudo unbound-control flush_zone example.com

Troubleshooting

Unbound does not start after a change. Run sudo unbound-checkconf to get the exact file and line, then read the service log with sudo journalctl -u unbound -n 50 --no-pager.

error: can't bind socket: Cannot assign requested address. The address in interface: does not exist on the server yet, usually because the private network interface is not configured. Check it with ip -brief addr.

Clients on the private network get REFUSED. Their source address is not covered by an access-control line. Add the correct range and restart Unbound.

Clients time out. Check the firewall rules from Step 5 and confirm that Unbound listens on your_private_ip with sudo ss -lnup 'sport = :53'.

unbound-control fails to connect. Make sure /etc/unbound/unbound.conf.d/remote-control.conf is still present and that you run the command with sudo.

Conclusion

You now have a private, validating and caching DNS resolver on Ubuntu 24.04 that serves the server and your private network, answers for internal hostnames and can optionally forward over DNS-over-TLS. As next steps, you can put an ad-blocking layer such as Pi-hole or AdGuard Home in front of Unbound, publish encrypted DNS-over-TLS and DNS-over-HTTPS endpoints for remote clients, or build a split-horizon setup with BIND views when internal and external clients need different answers for the same domain.