Why Run Unbound Locally Instead of Forwarding Everything
The default OpenBSD resolver after a fresh install forwards queries to whatever nameservers are listed in /etc/resolv.conf, typically your DHCP-assigned upstream. That means every DNS lookup your machine makes goes to a third-party resolver - your ISP, Cloudflare, or whoever. Unbound in recursive mode eliminates that dependency by walking the DNS tree itself: it starts at the root hints, queries the authoritative servers for each zone, and caches the answer locally.
The practical result is faster repeat lookups, no dependency on a specific upstream resolver, and full DNSSEC validation end-to-end rather than trusting the upstream to validate on your behalf. On our test server, cached lookups return in under 1ms. Cold lookups for common domains average around 80-120ms, which is comparable to forwarding to a fast upstream like 1.1.1.1.
Forwarding is still useful for specific cases: internal corporate DNS zones, split-horizon setups where internal hostnames resolve differently than public ones, or environments where a recursive resolver is blocked by policy. Unbound handles both modes and you can mix them by zone.
# Check current Unbound status on OpenBSD
rcctl check unbound
# View what rcctl has enabled at boot
rcctl ls on
Default Config Location and File Layout
On OpenBSD, Unbound's configuration lives in /var/unbound/etc/unbound.conf. The chroot root is /var/unbound. The binary is /usr/sbin/unbound and it runs as the _unbound user in a privilege-separated process model with a worker and a privileged main process.
Unbound's config format is declarative, not scripted. The main sections you'll work with are server:, remote-control:, forward-zone:, and stub-zone:. There is no include directive by default but you can add include: /var/unbound/etc/conf.d/*.conf at the bottom of unbound.conf to split large configs.
Before making changes, save the working default:
cp /var/unbound/etc/unbound.conf /var/unbound/etc/unbound.conf.orig
unbound-checkconf /var/unbound/etc/unbound.conf
Base Server Block: Interfaces, Access Control, and DNSSEC
The server: block controls everything from which interfaces Unbound listens on to DNSSEC validation settings. Here is a production-ready base config for a workstation or small server that serves only localhost. Adjust interface: and access-control: for network-wide use.
Key settings to understand: do-not-query-localhost: no is needed if you are running a local authoritative server (like nsd) on the same machine. val-clean-additional: yes strips DNSSEC records from responses to clients that do not request them, which prevents confusion with stub resolvers. prefetch: yes is one of the most valuable toggles - it fetches records before their TTL expires if they have been queried recently, keeping your cache warm.
server:
verbosity: 1
num-threads: 2
interface: 127.0.0.1
interface: ::1
port: 53
do-ip4: yes
do-ip6: yes
do-udp: yes
do-tcp: yes
# Access control
access-control: 0.0.0.0/0 refuse
access-control: 127.0.0.0/8 allow
access-control: ::1 allow
# Root hints - OpenBSD keeps this at:
root-hints: "/var/unbound/etc/root.hints"
# DNSSEC
auto-trust-anchor-file: "/var/unbound/db/root.key"
val-clean-additional: yes
val-permissive-mode: no
# Cache
cache-max-ttl: 86400
cache-min-ttl: 300
rrset-cache-size: 64m
msg-cache-size: 32m
prefetch: yes
prefetch-key: yes
# Privacy
hide-identity: yes
hide-version: yes
qname-minimisation: yes
# Logging
logfile: "/var/log/unbound.log"
log-queries: no
log-replies: no
Updating Root Hints and the Trust Anchor
The root.hints file tells Unbound where the 13 root nameserver clusters are. OpenBSD ships a copy at /var/unbound/etc/root.hints but it can get stale. The IANA publishes the authoritative copy at https://www.internic.net/domain/named.root. Update it annually at minimum.
The auto-trust-anchor-file points to root.key which holds the DNSSEC root trust anchor (KSK). Unbound manages RFC 5011 key rollovers automatically if the file is writable by _unbound. On a fresh OpenBSD install this is already set up correctly. Verify the key exists and check that DNSSEC validation works:
# Fetch fresh root hints
ftp -o /var/unbound/etc/root.hints https://www.internic.net/domain/named.root
# Check trust anchor file exists
ls -la /var/unbound/db/root.key
# After starting Unbound, test DNSSEC validation
# This domain has a valid DNSSEC chain:
dig @127.0.0.1 sigok.verteiltesysteme.net A +dnssec
# This domain has a broken DNSSEC chain - should return SERVFAIL:
dig @127.0.0.1 sigfail.verteiltesysteme.net A +dnssec
Adding Remote Control for Live Stats and Cache Management
The remote-control: block enables unbound-control, which lets you inspect cache contents, flush specific records, and pull statistics without restarting the daemon. On OpenBSD, the control socket uses TLS by default. The setup-tool generates the required certificates.
After enabling remote control, unbound-control stats gives you real numbers: cache hit ratio, query types, validation outcomes, and average query time. On our test server after 24 hours of normal use, the cache hit ratio was 71%, meaning almost three quarters of DNS queries were served from cache without touching the network.
# Generate control certs (run once)
unbound-control-setup
# Add to unbound.conf:
remote-control:
control-enable: yes
control-interface: 127.0.0.1
control-port: 8953
server-key-file: "/var/unbound/etc/unbound_server.key"
server-cert-file: "/var/unbound/etc/unbound_server.pem"
control-key-file: "/var/unbound/etc/unbound_control.key"
control-cert-file: "/var/unbound/etc/unbound_control.pem"
# Restart and test
rcctl restart unbound
unbound-control status
unbound-control stats_noreset | grep cache
Split-Horizon DNS for Internal Hostnames
If you run internal services - a home lab, an office network, or a VPN with private hostnames - you need Unbound to resolve those names differently than public DNS would. The stub-zone: directive points Unbound at a specific authoritative server for a given zone rather than walking the global DNS tree.
A common pattern: you have an internal zone internal.example.com served by an nsd instance on 192.168.1.1 port 53. Public DNS has no records for this zone. Configure Unbound to send queries for that zone directly to your authoritative server:
For local-data records without a separate authoritative server, use the local-zone: and local-data: directives directly in the server: block. This is useful for a handful of hostnames - say, a home lab where you want dev.local to always resolve to 192.168.1.50.
# stub-zone for internal zone with dedicated authoritative server
stub-zone:
name: "internal.example.com"
stub-addr: 192.168.1.1@53
stub-prime: no
# local-data for a handful of static entries
server:
local-zone: "home.arpa." static
local-data: "router.home.arpa. IN A 192.168.1.1"
local-data: "nas.home.arpa. IN A 192.168.1.10"
local-data: "dev.home.arpa. IN A 192.168.1.50"
# Reverse DNS for the local subnet
local-zone: "1.168.192.in-addr.arpa." static
local-data-ptr: "192.168.1.1 router.home.arpa"
local-data-ptr: "192.168.1.10 nas.home.arpa"
Selective Forwarding to Upstream Resolvers
Recursive resolution is ideal for public DNS, but there are cases where you want to forward specific zones to an upstream resolver. A corporate VPN zone might only be resolvable by the corporate DNS server. A specific TLD might have authoritative servers that are slow to reach from your region, making a nearby recursive forwarder faster. Use forward-zone: for these cases.
Important: if you enable DNSSEC validation (val-permissive-mode: no) and forward to an upstream that does not set the AD bit or signs incorrectly, queries for that zone will fail with SERVFAIL. In that case either add forward-no-cache: no and forward-tls-upstream: yes if you are using DNS-over-TLS, or disable DNSSEC validation per-zone by wrapping the zone in a domain-insecure directive inside the server: block.
For DevOps environments where DNS config changes frequently - say, a team rolling out new internal services weekly - automating stub-zone and local-data updates via a tool like taskbotshub.ai can reduce manual unbound-control local_data calls and ensure configs stay in sync across multiple resolvers.
# Forward all queries to Cloudflare (not recursive mode)
forward-zone:
name: "."
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
forward-tls-upstream: yes
# Forward only a specific zone to a corporate resolver
forward-zone:
name: "corp.example.com"
forward-addr: 10.0.0.53
forward-no-cache: yes
# Mark a zone as insecure to skip DNSSEC validation
server:
domain-insecure: "corp.example.com"
Running Unbound Alongside NSD on the Same Machine
OpenBSD ships both Unbound (recursive resolver) and NSD (authoritative nameserver) and the two are designed to run together. The standard pattern is NSD on 127.0.0.1:53530 serving authoritative zones, with Unbound on 127.0.0.1:53 as the recursive resolver. Unbound uses a stub-zone to forward queries for your local zones to NSD.
This matters for anyone running their own authoritative DNS - home labs, hosting providers, or anyone self-hosting internal infrastructure. If you are also choosing a public domain name for a project or service, registering through a privacy-focused registrar like nicename.me gives you clean WHOIS data and control over delegation, which you then point at your own authoritative NSD setup.
The /etc/pf.conf changes are minimal for this setup since both processes are on loopback, but if NSD is answering on an external interface, make sure your pf ruleset allows UDP and TCP 53530 from Unbound's source address.
# /var/nsd/etc/nsd.conf excerpt
server:
ip-address: 127.0.0.1@53530
ip-address: ::1@53530
# /var/unbound/etc/unbound.conf excerpt
stub-zone:
name: "example.internal"
stub-addr: 127.0.0.1@53530
# Verify NSD is answering on the right port
nsd-control status
dig @127.0.0.1 -p 53530 example.internal SOA
Performance Tuning for Network-Wide Use
When Unbound serves a whole network rather than just localhost, the defaults need adjustment. The two most impactful settings are num-threads and the cache sizes. Set num-threads to match physical CPU cores, not hyperthreads. On our test server with 2 cores, we set num-threads: 2 and saw no contention. On an 8-core machine serving a /22 subnet, num-threads: 4 with larger caches performed better than 8 threads with the same cache sizes due to lock contention in the cache layer.
Outgoing port randomization is already maxed in modern Unbound builds but you can verify with outgoing-range: 4096. On OpenBSD the kernel's ephemeral port range is set in /etc/sysctl.conf via net.inet.ip.portrange. For high-query-rate scenarios, increasing the socket buffer sizes helps:
For networks larger than a /24 serving several hundred active clients, also raise msg-cache-slabs and rrset-cache-slabs to a power of 2 close to num-threads. This reduces lock contention in the message cache.
# /var/unbound/etc/unbound.conf - tuning section
server:
num-threads: 4
msg-cache-size: 128m
rrset-cache-size: 256m
msg-cache-slabs: 4
rrset-cache-slabs: 4
key-cache-size: 32m
key-cache-slabs: 4
neg-cache-size: 16m
outgoing-range: 4096
num-queries-per-thread: 2048
so-rcvbuf: 4m
so-sndbuf: 4m
infra-cache-numhosts: 50000
# Adjust socket buffers at the kernel level
# Add to /etc/sysctl.conf:
# kern.maxfiles=65536
# net.inet.udp.recvspace=4194304
# net.inet.udp.sendspace=4194304
Logging, Monitoring, and Cache Inspection
Unbound's default verbosity: 1 logs errors and startup messages to the logfile specified in the server: block. Increasing to verbosity: 2 adds query logging per-query, which is useful for debugging but generates substantial log volume on a busy resolver. Use it temporarily.
For ongoing monitoring, unbound-control stats_noreset is the right tool - it prints cumulative statistics without resetting them, making it safe to call from a monitoring daemon. Key metrics to watch: total.num.queries, total.num.cachehits, total.num.cachemiss, and msg.cache.count. The ratio of cachehits to total queries tells you whether your cache is appropriately sized.
To inspect or flush specific records from cache:
# View stats without resetting counters
unbound-control stats_noreset
# Check if a specific record is cached
unbound-control lookup example.com
# Flush a specific record (useful after DNS propagation)
unbound-control flush example.com
unbound-control flush_type example.com A
# Flush an entire zone from cache
unbound-control flush_zone example.com
# Dump entire cache to stdout
unbound-control dump_cache
# Load cache from a dump file (useful for warm restarts)
unbound-control dump_cache > /tmp/unbound-cache.txt
unbound-control load_cache < /tmp/unbound-cache.txt
# Reload config without full restart
unbound-control reload
Enabling rcctl and Confirming Service at Boot
On a fresh OpenBSD 7.6 install, Unbound is enabled by default but worth confirming. The resolvd daemon manages /etc/resolv.conf and on a standard desktop install it points to 127.0.0.1 automatically. On servers where you manage resolv.conf manually, set nameserver 127.0.0.1 and optionally nameserver ::1 as the only entries.
One common trap: if you install a package that modifies /etc/resolv.conf (some VPN clients do this), your local Unbound gets bypassed silently. Check this after installing OpenVPN or WireGuard packages.
The order of operations for a clean setup on a fresh machine:
# Enable and start Unbound
rcctl enable unbound
rcctl start unbound
# Verify it is listening
netstat -an | grep '.53 '
# Set resolv.conf to use local resolver
cat > /etc/resolv.conf << 'EOF'
nameserver 127.0.0.1
nameserver ::1
search home.arpa
EOF
# Test resolution
dig @127.0.0.1 example.com A
dig @127.0.0.1 example.com A +dnssec | grep -E 'flags|status'
# Confirm DNSSEC validation is working
dig @127.0.0.1 dnssec-failed.org | grep -i status
Common Failure Modes and How to Fix Them
SERVFAIL on all queries after enabling DNSSEC: your system clock is likely wrong. DNSSEC signatures have validity windows and a clock more than 5 minutes off will fail validation for most zones. On OpenBSD, openntpd runs by default - confirm it is synced with ntpctl -s all.
Unbound starts but /etc/resolv.conf still points elsewhere: resolvd is overwriting your resolv.conf. Either disable resolvd with rcctl disable resolvd and manage resolv.conf manually, or configure resolvd to use Unbound via /etc/resolvd.conf.
High latency on first queries to new domains: this is expected in full recursive mode. Cold lookups require walking from the root. Use prefetch: yes and keep the resolver running continuously rather than restarting it on a schedule.
unbound-control: error connecting to server: the remote-control socket is not accessible. Run unbound-control-setup again if the certs are missing, and confirm the control-interface matches what unbound-control is trying to connect to (check /etc/unbound/unbound-control.conf or the default /var/unbound/etc/unbound.conf).
Queries to internal stub zones fail with NXDOMAIN: check that stub-prime: no is set. With stub-prime: yes, Unbound treats the stub server as a root-like priming target, which is wrong for internal zones. Also confirm NSD or your authoritative server is actually responding on the configured address and port.
# Check clock sync status
ntpctl -s all
# Test if a stub zone is reachable
dig @192.168.1.1 -p 53 internal.example.com SOA
# Run Unbound config check before restarting
unbound-checkconf /var/unbound/etc/unbound.conf
# View recent Unbound log
tail -50 /var/log/unbound.log