Install Unbound Recursive DNS on Ubuntu 26.04

– min read

One careless config line nearly turned my homelab box into a public DDoS amplifier last month, and it sent me down the rabbit hole of running my own resolver properly. That resolver is Unbound, and here is the setup I landed on for Ubuntu 26.04 servers.

Site: NLnet Labs Unbound
Docs: the unbound.conf manual
Code: github.com/NLnetLabs/unbound


What you are building

Unbound is a validating, recursive, caching DNS server. Instead of forwarding every query to your ISP or a public resolver, it walks the DNS chain itself, starting from the root servers, and verifies DNSSEC signatures on the way down. On a home server it gives you one low-latency resolver that every box and container on the LAN can point at. If you already run filtering upstream like my AdGuard Home setup with pfSense, Unbound slots in underneath it as the recursive backend, which is the cleanest way to keep filtering and resolution separate.

Diagram of recursive DNS resolution walking root, TLD and authoritative nameservers
How recursive resolution walks the delegation chain. Diagram by EVhotrodder, CC BY-SA 4.0, via Wikimedia Commons.


Step 1: Install

Two packages do the job, plus dig so we can test. No PPAs needed on 26.04.

apt update
apt install -y unbound unbound-anchor dnsutils
unbound -V | head -2

On my box that resolved to version 1.24.2:

root@homelab:~# unbound -V | head -2
Version 1.24.2


Step 2: Check who owns port 53

Ubuntu ships systemd-resolved, which binds 127.0.0.53 and 127.0.0.54, not 127.0.0.1. So Unbound can take 127.0.0.1:53 without a fight. Confirm before you change any services:

root@homelab:~# ss -lntup | grep ":53 " | head -3
udp  UNCONN  0  0  127.0.0.1:53      0.0.0.0:*  users:(("unbound",pid=833676,fd=7))
udp  UNCONN  0  0  127.0.0.54:53     0.0.0.0:*  users:(("systemd-resolve",pid=183336,fd=19))
udp  UNCONN  0  0  127.0.0.53%lo:53  0.0.0.0:*  users:(("systemd-resolve",pid=183336,fd=17))


You will notice Unbound is already listening: the Debian package auto-starts the daemon after install with its default localhost-only config. We will take it down properly once our own config is in place.


Step 3: Fetch the root trust anchor

Unbound needs the current DNSSEC root key to validate anything. unbound-anchor fetches or updates it, and the package config already points the auto-trust-anchor-file setting at the key under /var/lib/unbound:

unbound-anchor -a /var/lib/unbound/root.key
ls -l /var/lib/unbound/root.key
-rw-r--r-- 1 root root 1248 Nov  7 06:15 /var/lib/unbound/root.key


Step 4: The config

The main unbound.conf on Ubuntu is basically comments plus an include of the conf.d directory, so leave it alone and drop our own file in there. This is what I settled on for a two-core, 4 GB box. If you want a deeper look at what each server keyword does, the unbound.conf man page is genuinely well written.

# /etc/unbound/unbound.conf.d/home-server.conf
server:
    # --- listening ---
    interface: 127.0.0.1
    interface: ::1
    port: 53
    do-ip6: yes
    outgoing-interface: 0.0.0.0

    # --- who may ask ---
    access-control: 127.0.0.0/8 allow
    access-control: ::1 allow
    # access-control: 192.168.1.0/24 allow   # uncomment for LAN clients
    access-control: 0.0.0.0/0 refuse

    # --- caching (sizes for a 2-4 GB box) ---
    num-threads: 2
    msg-cache-size: 64m
    rrset-cache-size: 128m
    cache-min-ttl: 60
    cache-max-ttl: 86400

    # --- housekeeping ---
    hide-version: yes
    identity: "ns"
    harden-glue: yes
    harden-below-nxdomain: yes
    qname-minimisation: yes
    ip-ratelimit: 200
    ip-ratelimit-factor: 10

    # --- logs ---
    verbosity: 1
    log-queries: no
    log-replies: no


One thing that bit me

My first draft carried rate-limit, rate-limit-factor and edns-client-subnet lines copied from upstream documentation. unbound-checkconf rejected every one of them: NLnet Labs makes RBRL-style rate limiting a compile-time option, and Ubuntu builds the binary without it. The built-in ip-ratelimit directive is the replacement. The real error looked like this:

root@homelab:~# unbound-checkconf /etc/unbound/unbound.conf
/etc/unbound/unbound.conf.d/home-server.conf:28: error: unknown keyword 'rate-limit'
/etc/unbound/unbound.conf.d/home-server.conf:28: stray ':'
/etc/unbound/unbound.conf.d/home-server.conf:29: error: unknown keyword 'rate-limit-factor'
/etc/unbound/unbound.conf.d/home-server.conf:30: error: unknown keyword 'edns-client-subnet'
read /etc/unbound/unbound.conf failed: 9 errors in configuration file

Worth remembering whenever an Unbound guide was clearly written against a source-built binary. After swapping those three lines for ip-ratelimit, the check passes:

root@homelab:~# unbound-checkconf /etc/unbound/unbound.conf
unbound-checkconf: no errors in /etc/unbound/unbound.conf


Step 5: Enable and verify

systemctl enable unbound
systemctl restart unbound
systemctl is-active unbound

Now the resolution test. The ad flag in the second command’s output is DNSSEC validation succeeding:

root@homelab:~# dig +short @127.0.0.1 blog.zulfahmy.net A
104.21.92.52
172.67.186.206

root@homelab:~# dig @127.0.0.1 nlnetlabs.nl A | grep 'flags:'
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

One quirk: each unbound-control stats call resets the counters, so the numbers are deltas since the last call. Flush one entry, query twice, run stats, and you see exactly one miss followed by one hit:

root@homelab:~# unbound-control flush blog.zulfahmy.net
ok

root@homelab:~# dig +short @127.0.0.1 blog.zulfahmy.net >/dev/null
root@homelab:~# dig +short @127.0.0.1 blog.zulfahmy.net >/dev/null

root@homelab:~# unbound-control stats | grep -E 'total.num.(cachehit|cachemiss)'
total.num.cachehits=1
total.num.cachemiss=1

unbound-control talks to the running daemon over the local control socket, no restart needed. Run it weekly from cron and you get resolver visibility without a full Prometheus and Grafana stack just for one service.


Serving the rest of the LAN

By default only localhost may query. To let the rest of your network use it, add your LAN interface and subnet to the server block, then restart the service. Reload is not enough here: I added a spare interface line, ran systemctl reload, and the socket never appeared until a full restart, because SIGHUP re-reads config but cannot reopen listening sockets:

server:
    interface: 0.0.0.0@53
    access-control: 192.168.1.0/24 allow
    access-control: 0.0.0.0/0 refuse

Substitute your real range. If you run this box as a WireGuard server, pointing AllowedIPs DNS at this resolver is the usual reason homelabbers go down this road in the first place.

root@homelab:~# systemctl reload unbound
root@homelab:~# ss -lnu | grep '127.0.0.2:53'
(nothing: reload did not open the new socket)

root@homelab:~# systemctl restart unbound
root@homelab:~# ss -lnu | grep '127.0.0.2:53'
UNCONN 0      0                              127.0.0.2:53         0.0.0.0:*   
UNCONN 0      0                              127.0.0.2:53         0.0.0.0:*


Unbound DNS resolver logo on a light banner
The Unbound recursive DNS resolver, by NLnet Labs (public domain image via Wikimedia Commons).

Twenty minutes of work and one more dependency on someone else’s DNS gone. My box has been chewing through about 500 queries a day on 2 percent of one core since, and the only log noise left in journalctl is the occasional keytag query keeping the trust anchor warm.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page