Every DNS lookup your server makes — package installs, API calls, database connections, outbound webhooks — goes to whatever resolver the OS or your provider configured, often hundreds of milliseconds away and frequently logged by a third party. Running Unbound locally turns that into a near-instant cache hit, adds DNSSEC validation, and keeps your query history on your own machine. This guide walks through installation, configuration, verification, and the tuning options that matter on a small VPS.
Why Run a Local Resolver
- Latency — cached lookups resolve in under a millisecond instead of 10–50 ms to an upstream resolver.
- Privacy — query logs stay on your server; no third-party resolver sees your traffic patterns.
- DNSSEC — Unbound validates responses by default, blocking spoofed answers.
- Control — you decide which upstreams to use and can block or redirect domains.
Install and Configure Unbound
apt install -y unbound
systemctl enable --now unbound
Replace the default config with a minimal, hardened setup that listens on localhost only:
cat > /etc/unbound/unbound.conf <<'EOF'
server:
interface: 127.0.0.1
port: 53
do-daemonize: no
access-control: 127.0.0.0/8 allow
access-control: ::1 allow
do-ip6: yes
do-udp: yes
do-tcp: yes
prefetch: yes
prefetch-key: yes
qname-minimisation: yes
aggressive-nsec: yes
rrset-cache-size: 20m
msg-cache-size: 10m
hide-identity: yes
hide-version: yes
EOF
systemctl restart unbound
On a typical VPS this uses 30–60 MB of RAM — a fair trade for removing DNS from your request path.
Point the System at It
On modern systemd-based distributions, tell systemd-resolved to use 127.0.0.1, or skip resolved entirely and write /etc/resolv.conf directly:
# Option A: systemd-resolved
mkdir -p /etc/systemd/resolved.conf.d
echo -e "[Resolve]\nDNS=127.0.0.1\nDNSStubListener=no" > /etc/systemd/resolved.conf.d/unbound.conf
systemctl restart systemd-resolved
# Option B: direct resolv.conf
echo "nameserver 127.0.0.1" > /etc/resolv.conf
For Docker containers, set dns: 127.0.0.1 in docker-compose.yml so containers share the host cache instead of each resolving independently.
How Much Faster Does It Get?
The win is largest for repeat lookups. A server that makes the same API, database, or package-manager queries over and over turns every subsequent lookup into a cache hit. On a typical VPS the difference is dramatic:
| Scenario | Typical resolution time |
|---|---|
| First lookup through local Unbound (DNSSEC validated) | 10–50 ms |
| Repeat lookup, cached locally | 0.1–1 ms |
| Lookup via distant provider resolver | 20–80 ms, repeated every time |
Applications that resolve a hostname per connection — which is most of them — stop paying the round trip on every single connection after the first. Web requests that previously waited on DNS now proceed immediately, which shows up directly in time-to-first-byte measurements.
Verify Caching and DNSSEC
dig example.com @127.0.0.1 +time
dig +dnssec example.com @127.0.0.1 | grep -E 'status|flags'
unbound-control stats | grep -E 'num.query|num.cache'
| Lookup type | Typical latency |
|---|---|
| Cache hit (repeated lookup) | 0.1–1 ms |
| First lookup, valid DNSSEC | 10–50 ms |
| Upstream resolver (no local cache) | 10–50 ms + round trip to their location |
Tuning That Matters
The config above already enables the three settings with the biggest impact: prefetch refreshes popular records just before they expire, qname-minimisation sends only the necessary label to each upstream and improves privacy, and aggressive-nsec serves negative answers from cache. If you see many uncached lookups in unbound-control stats, raise rrset-cache-size; if memory is tight, lower it. A good rule of thumb is 20 MB of RRset cache per 1 GB of VPS RAM — most small servers never need more. Enable the unbound-control socket if you want to flush caches after DNS changes:
unbound-control-setup
echo "remote-control:\n control-enable: yes\n control-interface: 127.0.0.1" >> /etc/unbound/unbound.conf
systemctl restart unbound
unbound-control flush_zone example.com
Flushing a zone is useful when you change a record and do not want to wait out the TTL — flush_zone clears just that domain instead of dumping the whole cache and paying the warm-up cost again.
Security: Lock the Resolver Down
An open recursive resolver is a security incident waiting to happen — attackers will find it and use it for DNS amplification attacks. The config above already restricts access to localhost via access-control. If you want containers on a Docker bridge or a private network to use it, add only those subnets explicitly and never expose port 53 publicly:
# allow only the Docker bridge and loopback
access-control: 172.16.0.0/12 allow
access-control: 0.0.0.0/0 refuse
Then verify from outside that the port is not reachable: nmap -p 53 YOUR_IP should show the port filtered or closed. If it is open, fix your firewall before doing anything else — an open 53 is how resolvers get recruited into botnets.
Worth the Ten Minutes
A local resolver removes a variable source of latency and a privacy leak in one small install. Combined with the other basics — caching at the web layer and a tuned kernel — it is exactly the kind of low-effort change that makes a VPS feel faster without buying more hardware. If you are evaluating providers, cloud VPS plans with solid network stacks make the most of a local cache, since every uncached lookup still crosses the network.
Troubleshooting Common Issues
- Port 53 already in use — systemd-resolved binds 127.0.0.53 by default; disable its stub listener (
DNSStubListener=noabove) and restart, then checkss -tulpn | grep :53. - DNS works on the host but not in Docker — containers use their own resolver settings; set
dns: 127.0.0.1per service or add--dnsto the run command. - Lookups slow after reboot — the cache is in memory; warm-up takes a few minutes under normal traffic, which is expected.
- DNSSEC validation failures — a domain with broken DNSSEC will refuse to resolve; that is the validator working as intended, not a misconfiguration.




Leave a Reply
You must be logged in to post a comment.