How to Set Up Unbound as a Recursive DNS Resolver on Linux
Install and configure Unbound as a privacy-focused recursive DNS resolver on Linux. Covers DNSSEC validation, Pi-hole integration, DNS-over-TLS, performance tuning, and monitoring.
Unbound Recursive Resolution Flow
What is Unbound DNS?
Unbound is a validating, recursive, and caching DNS resolver developed by NLnet Labs in the Netherlands. It was released in 2007 as a purpose-built alternative to using BIND for recursive resolution. Where BIND tries to be everything (authoritative server, recursive resolver, zone signer, dynamic update handler), Unbound does one thing: recursive DNS resolution with DNSSEC validation. It does that one thing well.
Unbound is the default resolver in FreeBSD and OPNsense, and it ships in the repositories of every major Linux distribution. It is the most commonly recommended resolver for Pi-hole setups when users want to stop forwarding queries to Google or Cloudflare and instead resolve everything themselves.
The codebase is written in C with privilege separation and chroot support, and is significantly smaller than BIND. It parses a single configuration file, starts fast, and uses minimal memory for a home or small-office deployment.
This guide covers installing Unbound on Linux, configuring it for full recursion, enabling DNSSEC, integrating it with Pi-hole, setting up DNS-over-TLS forwarding, and tuning it for production workloads. Every command is copy-paste ready for Debian/Ubuntu and RHEL/AlmaLinux.
If you just need to point a Linux machine at a different DNS resolver rather than running your own, see our Linux DNS settings guide. If you need authoritative zone hosting, look at our BIND setup guide instead.
How recursive DNS resolution works
When your browser needs to find the IP address for www.example.com, it asks the operating system's stub resolver, which forwards the query to whatever DNS server is configured (typically your router, your ISP, or a public resolver like 1.1.1.1).
A recursive resolver like Unbound takes that query and does the legwork:
- It checks its local cache. If the answer is already stored and the TTL has not expired, it returns the cached result immediately.
- If there is no cache hit, it queries a root server (one of 13 anycast clusters). The root server does not know the IP for
www.example.com, but it knows who handles.comand returns a referral to the TLD servers. - Unbound queries the .com TLD server, which returns a referral to the authoritative nameservers for
example.com. - Unbound queries the authoritative server for
example.com, which returns the actual A record (e.g.,93.184.216.34). - Unbound caches the answer (and all the intermediate referrals) and returns the result to the client.
The next time any client asks for www.example.com, Unbound answers from cache in under a millisecond. That is the point of running your own resolver: after the first lookup, everything is local and fast.
With DNSSEC enabled, Unbound also verifies cryptographic signatures at each step. If a response has been tampered with or the signatures do not chain back to the root trust anchor, Unbound returns SERVFAIL instead of a poisoned answer.
Unbound architecture overview
Unbound is built around a modular pipeline. Each query passes through a series of modules:
| Module | Role |
|---|---|
| Iterator | Handles the actual recursive resolution logic. It walks the DNS tree from root to authoritative, following referrals and handling CNAMEs. |
| Validator | Performs DNSSEC validation. It checks signatures (RRSIG records), builds the chain of trust from the root, and rejects forged or expired signatures. |
| Cache | Stores resolved records in memory. Unbound maintains separate caches for messages (full responses) and RRsets (individual record sets). |
| Respip | Optional module for response IP-based access control (filtering responses by the IP addresses they contain). |
Unbound also supports multi-threading. Each thread gets its own set of listening sockets and its own portion of the cache. This allows Unbound to scale across CPU cores without lock contention on a single shared cache.
The binary itself is unbound. Unlike BIND, there is no separate control daemon. The unbound-control utility talks to the running Unbound process over a local socket (or TLS if configured for remote access) to issue commands like cache flushes, statistics dumps, and config reloads.
Why choose Unbound over forwarding resolvers
Most home routers and default OS configurations forward DNS queries to an upstream provider: your ISP, Google (8.8.8.8), Cloudflare (1.1.1.1), or Quad9 (9.9.9.9). This works, but it means that single provider sees every domain you look up.
Privacy
With full recursion, no single server sees the complete picture. The root server sees you asking about .com but not www.example.com. The .com TLD server sees you asking about example.com but not the specific subdomain. The authoritative server sees the full query but does not know who you are (it sees the IP of your resolver, not your client). Your query is split across multiple parties instead of concentrated at one.
Reliability
When Cloudflare or Google has an outage, millions of users lose DNS resolution at the same time. When you run Unbound, your resolver depends on the distributed DNS infrastructure (root servers, TLD servers, authoritative servers) rather than a single provider. If one root server is down, Unbound tries another.
Control
Running your own resolver means you control what gets cached, what gets blocked, and what policies apply. You can set custom TTLs, override specific domains, and inspect your own cache. No third party is injecting NXDOMAIN hijacking, filtering results, or logging your queries for advertising purposes.
When forwarding makes more sense
Forwarding is appropriate when your network has strict egress firewall rules that block outbound DNS to anything except approved resolvers, or when you want the benefit of a large shared cache (public resolvers like Cloudflare serve billions of queries and have nearly everything cached). Unbound supports both modes, so you can switch between them by changing a few lines in the config.
Unbound hardware and OS requirements
Unbound is lightweight. The hardware requirements depend on how many clients you serve and how large you want your cache.
| Deployment | CPU | RAM | Disk |
|---|---|---|---|
| Home / single user | 1 core (any) | 256 MB total | 50 MB |
| Small office (10-50 users) | 1 core | 512 MB total | 100 MB |
| Large network (500+ users) | 2-4 cores | 2-4 GB total | 500 MB |
| Raspberry Pi (Pi-hole + Unbound) | ARM (any Pi) | 512 MB minimum | SD card |
Unbound runs on any Linux distribution that packages it. This guide covers Debian 12, Ubuntu 24.04, RHEL 9, and AlmaLinux 9. It also runs on FreeBSD (where it is the default resolver), OpenBSD, and macOS (via Homebrew). OPNsense and pfSense both ship Unbound as their built-in DNS resolver, so the configuration principles here apply there too, though the web GUI handles the config file for you.
Installing Unbound on Debian/Ubuntu
On Debian 12 (Bookworm) and Ubuntu 24.04 (Noble), Unbound is in the default repositories.
sudo apt update
sudo apt install -y unbound dns-root-data
This installs:
unbound— the resolver daemon, unbound-control, unbound-anchor, unbound-checkconfdns-root-data— DNSSEC root trust anchor and root hints, kept up to date by the package manager
After installation, Unbound starts automatically and listens on 127.0.0.1:53. Verify it is running:
sudo systemctl status unbound
You should see active (running). Enable it to start at boot (usually already enabled by the package):
sudo systemctl enable unbound
Check the installed version:
unbound -V
Debian 12 ships Unbound 1.17.x. Ubuntu 24.04 ships 1.19.x or later. Both versions support everything in this guide.
Conflict with systemd-resolved
On Ubuntu, systemd-resolved listens on 127.0.0.53:53 by default. This does not conflict with Unbound on 127.0.0.1:53, but if you want Unbound to be the sole resolver, disable systemd-resolved:
sudo systemctl disable --now systemd-resolved
sudo rm /etc/resolv.conf
echo "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf
On Debian 12, systemd-resolved is not installed by default, so this step is usually unnecessary.
Installing Unbound on RHEL/AlmaLinux
On RHEL 9, AlmaLinux 9, and Rocky Linux 9, install from the base repositories:
sudo dnf install -y unbound
This installs the Unbound daemon, unbound-control, unbound-anchor, and unbound-checkconf.
Start and enable the service:
sudo systemctl start unbound
sudo systemctl enable unbound
sudo systemctl status unbound
Initialize the DNSSEC trust anchor (this downloads the root key and verifies it):
sudo unbound-anchor -a /var/lib/unbound/root.key
If this returns exit code 1, it means the key was updated (this is normal on first run). Exit code 0 means it was already up to date.
Check the version:
unbound -V
RHEL 9 ships Unbound 1.16.x. It receives security patches from Red Hat.
You will also want dig and drill for testing. Install them:
sudo dnf install -y bind-utils ldns-utils
Directory structure and key files
Unbound uses a simple directory layout. The key paths differ slightly between Debian and RHEL.
Debian/Ubuntu layout
| Path | Purpose |
|---|---|
/etc/unbound/unbound.conf | Main configuration file |
/etc/unbound/unbound.conf.d/ | Drop-in config directory (files here are included automatically) |
/usr/share/dns/root.hints | Root hints file (from dns-root-data package) |
/usr/share/dns/root.key | DNSSEC root trust anchor (from dns-root-data package) |
/var/lib/unbound/ | Working directory (auto-trust-anchor-file writes here) |
/var/log/unbound/ | Log directory (you may need to create it) |
RHEL/AlmaLinux layout
| Path | Purpose |
|---|---|
/etc/unbound/unbound.conf | Main configuration file |
/etc/unbound/conf.d/ | Drop-in config directory |
/etc/unbound/local.d/ | Local zone overrides directory |
/var/lib/unbound/root.key | DNSSEC root trust anchor (managed by unbound-anchor) |
/var/lib/unbound/root.hints | Root hints file (you may need to download this manually) |
/var/log/unbound/ | Log directory (create it if using file logging) |
The key difference from BIND: Unbound has a single configuration file (or a small set of includes), no zone files, and no separate control daemon. The entire configuration is in unbound.conf.
Understanding unbound.conf
The unbound.conf file uses a YAML-like syntax with key: value pairs grouped into named sections. Every directive belongs to a section header.
server section
The main section. Controls interfaces, access control, caching, DNSSEC, and logging.
server:
interface: 0.0.0.0
port: 53
access-control: 10.0.0.0/8 allow
do-ip6: no
num-threads: 2
cache-max-ttl: 86400
verbosity: 1
forward-zone section
Defines upstream forwarders for specific zones (or all zones with name: "."). Used when you want Unbound to forward instead of recurse.
forward-zone:
name: "."
forward-addr: 1.1.1.1
forward-addr: 9.9.9.9
stub-zone section
Points Unbound at specific authoritative servers for a zone, bypassing normal recursion for that zone. Useful for private internal domains.
stub-zone:
name: "internal.corp"
stub-addr: 10.0.0.5
remote-control section
Configures the unbound-control interface for runtime management.
remote-control:
control-enable: yes
control-interface: 127.0.0.1
control-port: 8953
include directive
You can split configuration across files:
include: /etc/unbound/unbound.conf.d/*.conf
Debian sets this up by default. You can drop custom configs into the directory and they will be picked up on restart.
Validation
Always check your config before restarting:
unbound-checkconf
No output means no errors. If there is a problem, it prints the line number and error description.
Basic Unbound recursive resolver configuration
This is the core use case: Unbound resolves everything itself by querying root servers, without forwarding to any third party. This is the configuration that gives you the most privacy and control.
Create a new config file (or replace the default):
sudo tee /etc/unbound/unbound.conf > /dev/null <<'EOF'
server:
# Listen on localhost and your LAN IP
interface: 127.0.0.1
interface: 10.0.0.1
port: 53
# Allow queries from your LAN
access-control: 127.0.0.0/8 allow
access-control: 10.0.0.0/24 allow
access-control: 0.0.0.0/0 refuse
# Disable IPv6 if your network does not use it
do-ip6: no
# Performance basics
num-threads: 2
msg-cache-slabs: 4
rrset-cache-slabs: 4
infra-cache-slabs: 4
key-cache-slabs: 4
# Cache sizes
msg-cache-size: 64m
rrset-cache-size: 128m
# DNSSEC
auto-trust-anchor-file: "/var/lib/unbound/root.key"
# Root hints (Debian: /usr/share/dns/root.hints, RHEL: download manually)
root-hints: "/usr/share/dns/root.hints"
# Privacy: minimize query names sent upstream (RFC 7816)
qname-minimisation: yes
# Prefetch records before TTL expires
prefetch: yes
# Logging
verbosity: 1
logfile: ""
use-syslog: yes
# Hardening
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
harden-referral-path: yes
use-caps-for-id: yes
# Prevent private address rebinding
private-address: 10.0.0.0/8
private-address: 172.16.0.0/12
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: fd00::/8
private-address: fe80::/10
remote-control:
control-enable: yes
control-interface: 127.0.0.1
control-port: 8953
EOF
Validate and restart:
unbound-checkconf
sudo systemctl restart unbound
Test it:
dig @127.0.0.1 example.com
You should get an A record back with status: NOERROR. The first query will take 50-200 ms (cache miss, full recursion). Repeat it and the time drops to 0 ms (cache hit).
Enabling DNSSEC validation
DNSSEC adds cryptographic verification to DNS responses. Unbound was designed with DNSSEC as a core feature, not a bolt-on.
How it works in Unbound
Unbound's validator module checks every response against the chain of trust rooted at the DNS root zone. The root zone's KSK (Key Signing Key) is the trust anchor. Unbound downloads and maintains this trust anchor automatically using RFC 5011 automated trust anchor updates.
Configuration
DNSSEC validation is enabled by default when you set the trust anchor file:
server:
auto-trust-anchor-file: "/var/lib/unbound/root.key"
On Debian, the dns-root-data package manages this file. On RHEL, initialize it manually:
sudo unbound-anchor -a /var/lib/unbound/root.key
sudo chown unbound:unbound /var/lib/unbound/root.key
Hardening directives
These settings strengthen DNSSEC enforcement:
server:
# Require DNSSEC data for trust-anchored zones
harden-dnssec-stripped: yes
# Validate glue records (NS referral addresses)
harden-glue: yes
# Harden against out-of-zone referrals
harden-referral-path: yes
# Aggressive NSEC: use cached NSEC records to synthesize
# negative answers without querying upstream (RFC 8198)
aggressive-nsec: yes
Testing DNSSEC
Query a known signed domain:
# Should return NOERROR with AD (Authenticated Data) flag
dig @127.0.0.1 sigok.verteiltesysteme.net A
# Should return SERVFAIL (bad signature)
dig @127.0.0.1 sigfail.verteiltesysteme.net A
If the first returns an answer and the second returns SERVFAIL, DNSSEC is working. If both return answers, your validator is not running.
You can also use drill for more detailed DNSSEC output:
drill -S example.com @127.0.0.1
The -S flag traces the chain of trust from root to the queried zone.
Access control and interface binding
You need to control who can query your resolver and which interfaces it listens on. An open resolver on the internet will be found and abused for DNS amplification attacks within hours.
Interface binding
By default, Unbound listens on 127.0.0.1 only. To serve your LAN, add your server's LAN IP:
server:
interface: 127.0.0.1
interface: 10.0.0.1
interface: 192.168.1.1
To listen on all interfaces (useful for Docker or dynamic IPs, but be careful):
server:
interface: 0.0.0.0
Access control
The access-control directive accepts a CIDR block and an action:
server:
# Allow localhost
access-control: 127.0.0.0/8 allow
# Allow your LAN
access-control: 10.0.0.0/24 allow
access-control: 192.168.1.0/24 allow
# Refuse everything else
access-control: 0.0.0.0/0 refuse
access-control: ::0/0 refuse
Access control actions
| Action | Behavior |
|---|---|
allow | Allow queries (recursive and non-recursive) |
deny | Drop the query silently (no response sent) |
refuse | Send a REFUSED response (lets the client know it was blocked) |
allow_snoop | Allow queries including cache snooping (non-recursive queries that check if something is cached). Only grant this to your monitoring tools. |
Use refuse for public networks (it is the polite response) and deny if you want to make the server invisible to scanners.
Configuring logging
Unbound logs via syslog by default. You can switch to file-based logging for easier filtering and rotation.
Syslog (default)
server:
use-syslog: yes
verbosity: 1
Logs go to the system journal. View them with:
sudo journalctl -u unbound -f
File-based logging
# Create log directory
sudo mkdir -p /var/log/unbound
sudo chown unbound:unbound /var/log/unbound
server:
use-syslog: no
logfile: "/var/log/unbound/unbound.log"
verbosity: 1
log-time-ascii: yes
log-queries: no
log-replies: no
Verbosity levels
| Level | What it logs |
|---|---|
0 | Errors only |
1 | Operational information (start, stop, errors) |
2 | Detailed operational info |
3 | Query-level detail |
4 | Algorithm-level detail |
5 | Cache miss detail (very verbose) |
For production, use verbosity 1. Enable log-queries: yes temporarily for debugging specific issues, then disable it. On a busy resolver, query logging generates enormous output.
Log rotation
If using file-based logging, set up logrotate. Create /etc/logrotate.d/unbound:
/var/log/unbound/unbound.log {
weekly
missingok
rotate 4
compress
delaycompress
notifempty
postrotate
/usr/sbin/unbound-control log_reopen 2>/dev/null || true
endscript
}
Performance tuning (threads, cache, prefetch)
Out of the box, Unbound works well for a single user or small household. For larger deployments, these settings make a measurable difference.
Threads
Set num-threads to the number of CPU cores available:
server:
num-threads: 4
Each thread handles its own slice of the cache. To avoid lock contention, set the cache slab count to a power of 2 that is close to (or equal to) the thread count:
server:
msg-cache-slabs: 4
rrset-cache-slabs: 4
infra-cache-slabs: 4
key-cache-slabs: 4
Cache sizes
The two main caches are the message cache (complete DNS responses) and the RRset cache (individual record sets). The RRset cache should be roughly twice the message cache:
server:
msg-cache-size: 128m
rrset-cache-size: 256m
On a dedicated resolver with 4 GB RAM, you can increase these to 512m and 1g respectively. On a Raspberry Pi, keep them at 16m and 32m.
Prefetching
server:
# Fetch records before they expire (when 10% of TTL remains)
prefetch: yes
# Also prefetch DNSKEY and DS records for DNSSEC
prefetch-key: yes
Prefetching keeps popular records in cache permanently. When a record is at 10% of its remaining TTL and a client queries it, Unbound refreshes it in the background. The client gets the cached answer immediately, and the next client gets a fresh answer. This eliminates the "first-query penalty" for frequently accessed domains.
Cache TTL controls
server:
# Minimum TTL: prevents extremely short TTLs from causing excessive queries
cache-min-ttl: 300
# Maximum TTL: caps how long records stay cached (1 day)
cache-max-ttl: 86400
Outgoing query tuning
server:
# Number of outgoing ports to use (more = better randomization, harder to spoof)
outgoing-range: 8192
# Number of queries that can be pending per thread
num-queries-per-thread: 4096
If you hit "socket count exceeded" errors in the log, increase outgoing-range. You may also need to raise the file descriptor limit:
sudo systemctl edit unbound
Add:
[Service]
LimitNOFILE=65536
Full production config example
server:
num-threads: 4
msg-cache-slabs: 4
rrset-cache-slabs: 4
infra-cache-slabs: 4
key-cache-slabs: 4
msg-cache-size: 128m
rrset-cache-size: 256m
cache-min-ttl: 300
cache-max-ttl: 86400
prefetch: yes
prefetch-key: yes
outgoing-range: 8192
num-queries-per-thread: 4096
so-rcvbuf: 4m
so-sndbuf: 4m
Integrating Unbound with Pi-hole
Pi-hole handles DNS-based ad blocking, and Unbound handles recursive resolution. Pair them and you stop depending on any third-party DNS provider.
For the Pi-hole installation itself, see our Pi-hole setup guide.
Architecture
Clients on your network point at Pi-hole (port 53). Pi-hole checks its blocklists and either sinkhole the domain or forward the query to Unbound (port 5335 on localhost). Unbound resolves it recursively and returns the answer to Pi-hole, which caches and returns it to the client.
Configure Unbound for Pi-hole
Create a dedicated config file:
sudo tee /etc/unbound/unbound.conf.d/pi-hole.conf > /dev/null <<'EOF'
server:
verbosity: 0
interface: 127.0.0.1
port: 5335
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: no
# Trust Pi-hole on localhost
access-control: 127.0.0.0/8 allow
# DNSSEC
auto-trust-anchor-file: "/var/lib/unbound/root.key"
root-hints: "/usr/share/dns/root.hints"
# Privacy and hardening
qname-minimisation: yes
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
aggressive-nsec: yes
# Performance
prefetch: yes
num-threads: 1
msg-cache-size: 16m
rrset-cache-size: 32m
cache-min-ttl: 300
cache-max-ttl: 86400
# Prevent rebinding attacks
private-address: 10.0.0.0/8
private-address: 172.16.0.0/12
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: fd00::/8
private-address: fe80::/10
EOF
Validate and restart:
unbound-checkconf
sudo systemctl restart unbound
Test Unbound directly on port 5335:
dig @127.0.0.1 -p 5335 example.com
Point Pi-hole at Unbound
In the Pi-hole web interface:
- Go to Settings → DNS
- Uncheck all upstream DNS servers (Google, Cloudflare, etc.)
- Under Custom 1 (IPv4), enter:
127.0.0.1#5335 - Save
Or edit the config file directly:
echo "PIHOLE_DNS_1=127.0.0.1#5335" | sudo tee /etc/pihole/setupVars.conf.d/99-unbound.conf
pihole restartdns
DNSSEC note
Do not enable DNSSEC in Pi-hole's settings when using Unbound. Unbound already validates DNSSEC. If Pi-hole also tries to validate, it can cause double-validation failures. Let Unbound handle all DNSSEC validation.
Verify the full chain
# Query through Pi-hole (port 53)
dig @127.0.0.1 example.com
# Query Unbound directly (port 5335)
dig @127.0.0.1 -p 5335 example.com
# Test a blocked domain through Pi-hole
dig @127.0.0.1 ads.doubleclick.net
The blocked domain should return 0.0.0.0 (Pi-hole sinkhole). The other two should return the real IP.
Using Unbound with DNS-over-TLS (DoT) forwarding
If your network blocks outbound DNS (port 53) or you prefer the speed of a large shared cache, you can configure Unbound to forward queries over TLS to privacy-focused providers. This encrypts the DNS traffic between Unbound and the upstream resolver, preventing your ISP from snooping on queries.
For a comparison of DoT and DoH protocols, see our DoH vs DoT guide.
Configuration
server:
# All the same server settings as before...
tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
forward-zone:
name: "."
forward-tls-upstream: yes
# Cloudflare
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
# Quad9
forward-addr: 9.9.9.9@853#dns.quad9.net
forward-addr: 149.112.112.112@853#dns.quad9.net
The #hostname after the port is the TLS authentication name. Unbound verifies the server's TLS certificate matches this hostname. The tls-cert-bundle directive points to the system's CA certificate store so Unbound can validate the TLS chain.
On RHEL/AlmaLinux, the CA bundle path is typically /etc/pki/tls/certs/ca-bundle.crt.
Choosing providers
Good DoT providers with public privacy policies (see also our DNS servers by country directory):
| Provider | IP | Port | TLS Name |
|---|---|---|---|
| Cloudflare | 1.1.1.1, 1.0.0.1 | 853 | cloudflare-dns.com |
| Quad9 | 9.9.9.9, 149.112.112.112 | 853 | dns.quad9.net |
| 8.8.8.8, 8.8.4.4 | 853 | dns.google | |
| Mullvad | 194.242.2.2 | 853 | dns.mullvad.net |
You can browse more resolvers in our public DNS server directory.
Trade-off: forwarding vs full recursion
DoT forwarding is not the same as full recursion. You get encrypted transport to the upstream provider, but that provider still sees every domain you query. For maximum privacy, run Unbound in full recursive mode (no forward-zone) and accept the unencrypted queries between Unbound and the authoritative servers. The DNS community is working on encrypting this last hop too, but it is not widely deployed yet.
Root hints and trust anchor management
Root hints
Root hints tell Unbound the IP addresses of the 13 DNS root server clusters (a.root-servers.net through m.root-servers.net). This is how Unbound knows where to start a recursive query.
On Debian, the dns-root-data package keeps the hints file updated. On RHEL, download it manually:
sudo curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.cache
sudo chown unbound:unbound /var/lib/unbound/root.hints
Then reference it in your config:
server:
root-hints: "/var/lib/unbound/root.hints"
Root hints change rarely. A root server IP has not changed since 2017. Updating every 6 months is plenty. Even with outdated hints, Unbound will still work because it learns the correct addresses from the root servers themselves after the first successful query.
Trust anchor
The DNSSEC trust anchor is the public key of the root zone's Key Signing Key (KSK). Unbound uses it to validate the entire DNSSEC chain of trust.
The auto-trust-anchor-file directive tells Unbound to manage the trust anchor automatically using RFC 5011. When the root KSK rolls over (which has happened once, in October 2018), Unbound detects the new key and updates the file without manual intervention.
server:
auto-trust-anchor-file: "/var/lib/unbound/root.key"
On first install, bootstrap the trust anchor:
sudo unbound-anchor -a /var/lib/unbound/root.key
sudo chown unbound:unbound /var/lib/unbound/root.key
After that, Unbound keeps it current as long as it runs regularly. If the server is offline for an extended period (months), you may need to run unbound-anchor again before starting Unbound.
Firewall configuration
If you are serving DNS to clients beyond localhost, you need to open port 53 on your firewall.
firewalld (RHEL/AlmaLinux/Fedora)
sudo firewall-cmd --permanent --add-service=dns
sudo firewall-cmd --reload
The dns service opens both TCP and UDP port 53.
ufw (Ubuntu/Debian)
sudo ufw allow 53/tcp
sudo ufw allow 53/udp
If you only serve your LAN, restrict it:
sudo ufw allow from 10.0.0.0/24 to any port 53
iptables (manual)
sudo iptables -A INPUT -p tcp --dport 53 -s 10.0.0.0/24 -j ACCEPT
sudo iptables -A INPUT -p udp --dport 53 -s 10.0.0.0/24 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 53 -j DROP
sudo iptables -A INPUT -p udp --dport 53 -j DROP
What about port 8953?
Port 8953 is the unbound-control port. Keep this localhost-only. It should never be exposed to the network unless you have a specific remote management need and are using TLS client certificates.
Outbound access
For full recursive mode, Unbound needs outbound access on port 53 (UDP and TCP) to the internet. If your firewall restricts outbound traffic, make sure port 53 is allowed. For DoT forwarding mode, Unbound needs outbound access on port 853 instead.
Testing with dig and drill
dig (from bind-utils or bind9-dnsutils) and drill (from ldnsutils or ldns-utils) are the standard tools for testing DNS resolvers.
Basic resolution test
# Query Unbound on localhost
dig @127.0.0.1 example.com A
# Short output (just the IP)
dig @127.0.0.1 example.com +short
# Query with full output
dig @127.0.0.1 example.com A +noall +answer +stats
DNSSEC verification
# Check for the AD (Authenticated Data) flag
dig @127.0.0.1 example.com A +dnssec
# Known-good DNSSEC test domain
dig @127.0.0.1 sigok.verteiltesysteme.net A +dnssec
# Known-bad DNSSEC test domain (should return SERVFAIL)
dig @127.0.0.1 sigfail.verteiltesysteme.net A
# Detailed DNSSEC trace with drill
drill -S example.com @127.0.0.1
Cache behavior
# First query — cache miss, full recursion
dig @127.0.0.1 example.com | grep "Query time"
# Output: ;; Query time: 87 msec
# Second query — cache hit
dig @127.0.0.1 example.com | grep "Query time"
# Output: ;; Query time: 0 msec
Checking response flags
dig @127.0.0.1 example.com
Look at the flags line:
qr— This is a response (not a query)rd— Recursion desired (you asked for it)ra— Recursion available (the server supports it)ad— Authenticated data (DNSSEC validated)
If ra is missing, Unbound is not allowing recursion for your IP. Check access-control.
Testing a specific port (Pi-hole setup)
dig @127.0.0.1 -p 5335 example.com
Reverse DNS lookup
dig @127.0.0.1 -x 8.8.8.8
Should return dns.google.
You can also test your resolver using our DNS dig tool from the browser.
Monitoring and statistics
Unbound provides runtime statistics through unbound-control. You need the remote-control section enabled (see the basic config above).
Setup unbound-control
Generate the control keys (required on first use):
sudo unbound-control-setup
This creates TLS certificates in /etc/unbound/ for authenticated communication between unbound-control and the daemon. Restart Unbound after generating keys:
sudo systemctl restart unbound
Viewing statistics
# Full stats dump
sudo unbound-control stats_noreset
# Key metrics only
sudo unbound-control stats_noreset | grep -E "total.num|cache.count|num.query"
Key metrics to monitor
| Metric | What it means |
|---|---|
total.num.queries | Total queries received since start |
total.num.cachehits | Queries answered from cache (higher is better) |
total.num.cachemiss | Queries that required upstream resolution |
total.num.recursivereplies | Answers returned after recursion |
msg.cache.count | Number of entries in the message cache |
rrset.cache.count | Number of entries in the RRset cache |
num.query.type.A | A record queries |
num.query.type.AAAA | AAAA record queries |
unwanted.queries | Refused queries (unauthorized clients) |
Cache hit ratio
Calculate your cache hit rate:
sudo unbound-control stats_noreset | awk -F= '
/total.num.cachehits/ {hits=$2}
/total.num.cachemiss/ {miss=$2}
END {printf "Cache hit rate: %.1f%%\n", hits/(hits+miss)*100}'
A healthy resolver should show 70-90%+ cache hit rate after running for a few hours.
Flushing the cache
# Flush a specific domain
sudo unbound-control flush example.com
# Flush everything under a domain
sudo unbound-control flush_zone example.com
# Flush the entire cache
sudo unbound-control reload
Prometheus integration
For production monitoring, use the unbound_exporter for Prometheus. It scrapes unbound-control stats and exposes them as Prometheus metrics. This lets you build Grafana dashboards for query rates, cache performance, latency distributions, and error rates.
Troubleshooting common Unbound issues
Unbound will not start
Check the config first:
unbound-checkconf
Then check the journal:
sudo journalctl -u unbound -e --no-pager
Common causes:
- Port 53 already in use — Another service (systemd-resolved, dnsmasq) is occupying port 53. Stop or disable it.
- Permission denied on trust anchor — The unbound user cannot read
root.key. Fix:sudo chown unbound:unbound /var/lib/unbound/root.key - Config syntax error — unbound-checkconf will tell you the exact line and problem.
# Check what is using port 53
sudo ss -tlnp | grep :53
Queries refused
If dig @your-server example.com returns status: REFUSED:
- Check
access-control— is the client's IP or subnet listed withallow? - Check
interface— is Unbound listening on the IP the client is connecting to? - Check the firewall — is port 53 open for the client's source IP?
SERVFAIL responses
SERVFAIL usually means one of:
- DNSSEC validation failure — The upstream zone has broken DNSSEC. Test with
dig +cd(checking disabled). If the query works with+cd, DNSSEC is the problem (upstream, not your server). - Cannot reach upstream servers — Your firewall blocks outbound port 53. Test:
dig @198.41.0.4 com NS(query a root server directly). If this times out, it is a network/firewall issue. - Root hints or trust anchor missing — Verify the files exist and are readable by the unbound user.
# Enable more verbose logging temporarily
sudo unbound-control verbosity 3
# Check logs, then reset
sudo unbound-control verbosity 1
Slow resolution
If the first query to a new domain takes several seconds:
- IPv6 timeouts — If your server lacks IPv6 connectivity but Unbound tries IPv6 queries, each one times out before falling back to IPv4. Fix:
do-ip6: no - Rate-limited by upstream — Some authoritative servers rate-limit queries. This is rare for normal usage.
- Insufficient outgoing sockets — Increase
outgoing-rangeand the file descriptor limit.
Port conflict with Pi-hole
If both Pi-hole (via dnsmasq/FTL) and Unbound try to use port 53:
# Unbound should use a different port for Pi-hole integration
# In unbound.conf:
# port: 5335
Pi-hole's FTL uses port 53. Unbound must listen on a different port (5335 is the convention).
Backup, updates, and maintenance
Backing up Unbound
Unbound has no zone files or databases to back up. The configuration is everything:
sudo tar czf /root/unbound-backup-$(date +%Y%m%d).tar.gz \
/etc/unbound/ \
/var/lib/unbound/root.key \
/var/lib/unbound/root.hints
Run this from cron weekly or before any configuration change. To restore on a new server, install Unbound, extract the backup, and start the service.
Updating Unbound
# Debian/Ubuntu
sudo apt update && sudo apt upgrade unbound
# RHEL/AlmaLinux
sudo dnf update unbound
After updating, check the release notes for any config syntax changes. NLnet Labs publishes release notes at https://nlnetlabs.nl/projects/unbound/about/. Major versions occasionally deprecate old directives.
Restart the service after updating:
sudo systemctl restart unbound
Updating root hints
If not using the dns-root-data package (Debian), update root hints periodically:
sudo curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.cache
sudo chown unbound:unbound /var/lib/unbound/root.hints
sudo systemctl restart unbound
Automate with a cron job (monthly is more than enough):
0 3 1 * * curl -sfo /var/lib/unbound/root.hints https://www.internic.net/domain/named.cache && systemctl restart unbound
Health check script
A simple script to verify Unbound is responding:
#!/bin/bash
if ! dig @127.0.0.1 example.com +short +time=2 +tries=1 > /dev/null 2>&1; then
echo "Unbound DNS is not responding on $(hostname)" | \
mail -s "DNS ALERT" admin@example.com
# Optionally attempt a restart
# sudo systemctl restart unbound
fi
Run it from cron every 5 minutes:
*/5 * * * * /usr/local/bin/check_unbound.sh
Cache warm-up after restart
After a restart, the cache is empty and all queries incur full recursion latency. For high-traffic resolvers, you can pre-populate the cache by dumping and reloading it:
# Before restart: dump the cache
sudo unbound-control dump_cache > /tmp/unbound_cache.txt
# After restart: reload the cache
sudo unbound-control load_cache < /tmp/unbound_cache.txt
This restores cached records so clients do not experience a performance cliff after maintenance.
Frequently asked questions
What is the difference between a recursive resolver and a forwarding resolver?
A recursive resolver queries the DNS hierarchy directly, starting from root servers, and assembles the answer itself. A forwarding resolver sends every query to an upstream resolver (like 1.1.1.1 or 8.8.8.8) and caches the response. Unbound can operate in either mode. Full recursion gives you more privacy because no single upstream provider sees all your queries. Forwarding is simpler and faster for the first lookup but puts trust in the upstream provider.
Does Unbound support DNS-over-HTTPS (DoH)?
Unbound does not natively support inbound DNS-over-HTTPS as a server. It supports outbound DNS-over-TLS (DoT) for forwarding. If you need DoH for clients, place a DoH proxy like dnscrypt-proxy or nginx in front of Unbound, or use Unbound behind a reverse proxy that terminates HTTPS and forwards plain DNS to Unbound on localhost.
How much RAM does Unbound need?
For a home network or small office, Unbound runs comfortably on 50 to 100 MB of RAM. The main consumer is the cache. By default, the message cache is 4 MB and the RRset cache is 4 MB. For a busy resolver serving hundreds of users, allocating 256 MB to 512 MB for cache improves hit rates and reduces upstream queries.
Can Unbound replace Pi-hole?
No. They serve different purposes. Pi-hole is a DNS sinkhole that blocks ads and trackers using blocklists. Unbound is a recursive resolver that fetches DNS answers. They work best together: Pi-hole filters queries and forwards the rest to Unbound, which resolves them without relying on a third-party DNS provider.
Is Unbound better than BIND for recursive resolution?
For pure recursive resolution, Unbound is generally the better choice. It was designed from the ground up as a validating recursive resolver. It has a smaller attack surface, uses less memory, and requires less configuration than BIND for this role. BIND is better suited when you also need authoritative zone hosting on the same server.
Does Unbound cache DNS responses?
Yes. Caching is one of its core functions. Unbound stores resolved records in memory and serves them for subsequent queries until the TTL expires. It also supports aggressive NSEC caching (RFC 8198), which synthesizes negative answers from cached NSEC records to reduce queries for nonexistent domains.
How do I update root hints?
Download the latest root hints file from IANA: curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.cache. Then restart Unbound. Root hints rarely change (the last significant update was in 2017 when a root server IP changed), so checking every six months is sufficient.
Can Unbound run on a Raspberry Pi?
Yes. Unbound runs well on any Raspberry Pi model with at least 512 MB of RAM. It is commonly paired with Pi-hole on Raspberry Pi for a combined ad-blocking and privacy-focused DNS setup. The ARM packages are available in the default Raspberry Pi OS (Debian-based) repositories.
Finding reliable DNS servers
If you need upstream resolvers for forwarding mode or just want to compare public DNS providers, browse our DNS directory for live-tested servers filtered by country and reliability.