Setting Up a Local DNS Resolver with Unbound on Your VPS: Lower Query Latency and More Privacy

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:

ScenarioTypical resolution time
First lookup through local Unbound (DNSSEC validated)10–50 ms
Repeat lookup, cached locally0.1–1 ms
Lookup via distant provider resolver20–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 typeTypical latency
Cache hit (repeated lookup)0.1–1 ms
First lookup, valid DNSSEC10–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=no above) and restart, then check ss -tulpn | grep :53.
  • DNS works on the host but not in Docker — containers use their own resolver settings; set dns: 127.0.0.1 per service or add --dns to 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