Unbound Server
The DNS Introduction article established the architecture: Unbound handles recursive resolution and DNSSEC validation, PowerDNS handles authoritative answers for internal and public zones. This article installs and configures Unbound on the primary server.
Once this is done, Unbound is listening on the server’s private IP address. Every device on the network points at it for DNS. External names resolve recursively with DNSSEC validation. Internal names forward to PowerDNS, which does not exist yet but is the next article.
Conflict with systemd-resolved
Ubuntu 24.04 runs systemd-resolved by default, listening on 127.0.0.53:53. Unbound also wants port 53. They cannot both have it.
The clean approach is to disable systemd-resolved and let Unbound take over entirely. The risk is that if Unbound is not running, the server itself has no DNS resolution. A small amount of care during setup avoids this being a problem.
Before touching anything, set a temporary nameserver that does not depend on the local resolver:
sudo bash -c 'echo "nameserver 1.1.1.1" > /etc/resolv.conf'
Now stop and disable systemd-resolved:
sudo systemctl disable --now systemd-resolved
sudo rm -f /etc/resolv.conf
/etc/resolv.conf was a symlink managed by systemd-resolved. Removing it does not delete anything important. Unbound will manage it going forward.
Installation
sudo apt install unbound dns-root-data
dns-root-data provides the root hints file (/usr/share/dns/root.hints) and the root trust anchor (/usr/share/dns/root.key), which Unbound needs for full recursive resolution and DNSSEC validation. On Ubuntu 24.04 the package installs these automatically and Unbound’s default configuration references them.
Check the version installed:
unbound -V
Unbound 1.19.x is the version in Ubuntu 24.04’s repositories. The configuration syntax in this article is current for that version.
Configuration structure
The Ubuntu package creates /etc/unbound/unbound.conf as the main configuration file, which includes everything in /etc/unbound/unbound.conf.d/. Rather than putting everything in the main file, split the configuration into logical files under the .d/ directory. This makes the configuration easier to read and easier to update one section without touching others.
The files to create:
/etc/unbound/unbound.conf.d/
├── server.conf # Core server settings, interfaces, access control
├── security.conf # DNSSEC, hardening, privacy settings
├── performance.conf # Cache sizes, threading, prefetch
├── local.conf # Local data and overrides
└── forward-zones.conf # Forward zones to PowerDNS (internal domains)
Core server configuration
sudo tee /etc/unbound/unbound.conf.d/server.conf << 'EOF'
server:
# Working directory
directory: "/etc/unbound"
# Run as unbound user
username: unbound
# Listen on all interfaces.
# Access control below restricts who can actually query.
interface: 0.0.0.0
interface: ::0
port: 53
# Only serve DNS over UDP and TCP
do-udp: yes
do-tcp: yes
do-ip4: yes
do-ip6: yes
# Access control: allow queries from the local machine and all
# private network ranges used on this network. Refuse everything else.
access-control: 127.0.0.0/8 allow
access-control: 10.0.0.0/8 allow
access-control: 172.16.0.0/12 allow
access-control: 192.168.0.0/16 allow
access-control: ::1/128 allow
access-control: 0.0.0.0/0 refuse
# Root hints: the starting point for full recursive resolution.
# Provided by the dns-root-data package on Ubuntu.
root-hints: "/usr/share/dns/root.hints"
# Logging: send to syslog/journald rather than a file.
# Increase verbosity temporarily for debugging.
verbosity: 1
log-queries: no
log-replies: no
# Do not reveal server identity or version to clients.
hide-identity: yes
hide-version: yes
EOF
Security and privacy
sudo tee /etc/unbound/unbound.conf.d/security.conf << 'EOF'
server:
# DNSSEC validation.
# The root trust anchor is maintained by the dns-root-data package
# and updated automatically via RFC 5011 key rollover.
auto-trust-anchor-file: "/usr/share/dns/root.key"
# Reject responses that claim DNSSEC does not exist for a zone
# that actually has it. Protects against downgrade attacks.
harden-dnssec-stripped: yes
# Require all sub-queries to have the same DNSSEC status.
harden-referral-path: yes
# Reject answers from glue records that are out-of-zone.
harden-glue: yes
# Add random capitalisation to queries (0x20 encoding).
# Makes DNS cache poisoning harder.
use-caps-for-id: yes
# Send minimum information to upstream servers.
# Only sends the hostname being looked up, not the full query.
qname-minimisation: yes
# Synthesise NXDOMAIN responses for names that are provably
# non-existent via NSEC records. Reduces round trips.
aggressive-nsec: yes
# Do not include additional records in responses unless they
# are from the same zone. Reduces information leakage.
val-clean-additional: yes
# Private IP ranges that should never appear in DNS responses
# for public names. Prevents DNS 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: fd00::/8
private-address: fe80::/10
EOF
Performance
sudo tee /etc/unbound/unbound.conf.d/performance.conf << 'EOF'
server:
# Number of threads. Set to the number of CPU cores.
# For a server also running other services, half the cores
# is a more conservative choice.
num-threads: 2
# Cache sizes. Doubling rrset-cache relative to msg-cache is
# the recommended ratio.
msg-cache-size: 64m
rrset-cache-size: 128m
# Number of cache slabs. Must be a power of 2,
# ideally close to num-threads.
msg-cache-slabs: 2
rrset-cache-slabs: 2
key-cache-slabs: 2
# Cache TTL bounds.
cache-min-ttl: 300
cache-max-ttl: 86400
# Prefetch: fetch DNS entries before they expire so clients
# do not see latency spikes when a cached entry is refreshed.
prefetch: yes
prefetch-key: yes
# Serve expired records while refreshing in the background.
# Clients get a fast (possibly stale) answer rather than waiting.
serve-expired: yes
serve-expired-ttl: 86400
# Incoming connections buffer.
so-rcvbuf: 1m
so-sndbuf: 1m
# Increase the number of queries that can be in flight.
num-queries-per-thread: 4096
outgoing-range: 8192
EOF
Forward zones for internal domains
Queries for internal domain names should go to PowerDNS rather than being sent out to the public internet. This is the critical split-DNS configuration that keeps internal names resolving correctly.
sudo tee /etc/unbound/unbound.conf.d/forward-zones.conf << 'EOF'
# Forward internal domain queries to the local PowerDNS authoritative server.
#
# PowerDNS listens on 127.0.0.1:5300 rather than the default port 53,
# because Unbound owns port 53. The PowerDNS article covers this.
#
# do-not-query-localhost must be disabled to allow forwarding to
# a local loopback address.
server:
do-not-query-localhost: no
# Forward the internal zone to PowerDNS.
# Replace internal.yourdomain.net with your actual internal subdomain.
forward-zone:
name: "internal.yourdomain.net."
forward-addr: 127.0.0.1@5300
forward-first: no
# Forward reverse DNS for private IP ranges to PowerDNS.
# These zones cover the RFC 1918 address space used on this network.
# Add or remove blocks to match the subnets actually in use.
forward-zone:
name: "10.in-addr.arpa."
forward-addr: 127.0.0.1@5300
forward-first: no
forward-zone:
name: "168.192.in-addr.arpa."
forward-addr: 127.0.0.1@5300
forward-first: no
EOF
The forward-first: no setting means Unbound sends directly to PowerDNS and does not fall back to recursive resolution if PowerDNS does not respond. For internal zones this is the correct behaviour: if PowerDNS is down, an internal name should fail rather than produce a spurious result from the public DNS.
Local data
The local.conf file is for any records Unbound should answer directly without forwarding or recursion. Useful for overriding public records with private ones, or for serving the server’s own hostname before PowerDNS is set up.
sudo tee /etc/unbound/unbound.conf.d/local.conf << 'EOF'
server:
# The server's own hostname, pointing to its internal IP address.
# Replace with the actual hostname and IP of February.
local-data: "server.internal.yourdomain.net. IN A 10.1.0.10"
local-data-ptr: "10.1.0.10 server.internal.yourdomain.net."
# localhost records. Some clients expect these to be resolvable.
local-zone: "localhost." static
local-data: "localhost. IN A 127.0.0.1"
local-data-ptr: "127.0.0.1 localhost."
local-zone: "127.in-addr.arpa." static
local-data: "1.0.0.127.in-addr.arpa. IN PTR localhost."
EOF
Validate the configuration
Before starting Unbound, check the configuration parses correctly:
sudo unbound-checkconf
If there are errors, fix them before proceeding. Common issues are indentation (Unbound’s config is whitespace-sensitive) and duplicate section headers.
Start Unbound
Enable and start the service:
sudo systemctl enable --now unbound
Check it started correctly:
sudo systemctl status unbound
sudo journalctl -u unbound --since "5 minutes ago"
The journal should show Unbound loading the trust anchor and beginning to serve queries.
Point /etc/resolv.conf at Unbound:
sudo bash -c 'echo "nameserver 127.0.0.1" > /etc/resolv.conf'
sudo chattr +i /etc/resolv.conf
The chattr +i flag makes the file immutable. Without it, various packages (resolvconf, NetworkManager) will overwrite it during updates. If you ever need to edit it, run sudo chattr -i /etc/resolv.conf first, make the change, then chattr +i again.
Verify resolution
Test external resolution:
# Basic resolution
dig @127.0.0.1 example.com A
# Check for the 'ad' flag indicating DNSSEC validation
dig @127.0.0.1 internetsociety.org A +dnssec
# A deliberately broken DNSSEC domain should return SERVFAIL
dig @127.0.0.1 dnssec-failed.org A
The ad (Authenticated Data) flag in the response header confirms DNSSEC validation is working. The dnssec-failed.org test should return SERVFAIL rather than an answer, confirming Unbound rejects invalid signatures.
Test that the forward zones work once PowerDNS is running:
dig @127.0.0.1 server.internal.yourdomain.net A
This will fail until PowerDNS is configured. That is expected. The local-data entry in local.conf provides a fallback for the server’s own name in the meantime.
Check the cache is building:
sudo unbound-control stats_noreset | grep total
After a few minutes of activity, the cache hit count should be rising.
Unbound remote control
The unbound-control tool communicates with the running daemon to query statistics, flush the cache, and reload configuration. It requires certificate-based authentication.
Generate the control certificates:
sudo unbound-control-setup
This creates /etc/unbound/unbound_server.key, unbound_server.pem, unbound_control.key, and unbound_control.pem. Enable remote control by adding to the server configuration:
sudo tee /etc/unbound/unbound.conf.d/remote-control.conf << 'EOF'
remote-control:
control-enable: yes
control-interface: 127.0.0.1
control-port: 8953
server-key-file: "/etc/unbound/unbound_server.key"
server-cert-file: "/etc/unbound/unbound_server.pem"
control-key-file: "/etc/unbound/unbound_control.key"
control-cert-file: "/etc/unbound/unbound_control.pem"
EOF
Reload Unbound to pick up the new configuration:
sudo systemctl reload unbound
Test that unbound-control works:
sudo unbound-control status
sudo unbound-control stats_noreset
Useful control commands:
# Flush the entire cache
sudo unbound-control flush_zone .
# Flush a single name from the cache
sudo unbound-control flush example.com
# Reload configuration without restarting
sudo unbound-control reload
# Show which zones are forwarded
sudo unbound-control list_forwards
Firewall
Devices on the network need to reach Unbound on port 53. If the server’s firewall blocks inbound DNS, add rules:
# Allow DNS from all private network ranges
sudo ufw allow from 10.0.0.0/8 to any port 53
sudo ufw allow from 172.16.0.0/12 to any port 53
sudo ufw allow from 192.168.0.0/16 to any port 53
sudo ufw allow from 127.0.0.0/8 to any port 53
Do not open port 53 to the public internet. Unbound’s access-control configuration already blocks it, but defence in depth means the firewall should block it too. An open recursive resolver on the public internet is a DDoS amplification vector.
Root hints updates
The root hints file (/usr/share/dns/root.hints) lists the addresses of the DNS root servers. These change occasionally. The dns-root-data package updates the file automatically via apt, so no manual maintenance is needed. The root trust anchor updates automatically via RFC 5011 tracking.
Confirm the root key is current:
sudo unbound-anchor -v
The forward zones for internal domains point to
127.0.0.1@5300, where PowerDNS will listen once configured. Until PowerDNS is running, queries for internal names will fail. This is correct behaviour. Bring PowerDNS up before distributing Unbound’s address to network devices via DHCP.