The log that describes you better than your browser history
Before your machine can send a single encrypted byte to a website, it has to ask somebody where that website lives. That question travels in the clear, to a resolver you almost certainly did not choose, and the answer to who keeps it decides how much of your life is written down. A browser history is a list of pages you chose to keep. A resolver log is everything: every site, every app checking for updates, every cloud service your phone talks to while you sleep, every domain in every email you opened, in order, with timestamps, whether or not a human was looking at the screen.
Read a week of it and you can reconstruct a person. The bank they use, the airline they just booked, the pharmacy, the dating app, the recruiter's applicant-tracking system, the hour they wake up and the hour they stop. None of that requires breaking any encryption. The names alone carry it, and the names are the one part of the transaction that is still, in most setups, handed to a third party by design.
Today that third party is whoever your DHCP lease pointed you at — your ISP, your employer, the café. People who notice this usually switch to a public resolver, which is a real improvement in integrity and a lateral move in privacy: you have not removed the observer, you have changed which company it is, and traded a regulated telco for an advertising-adjacent CDN or a non-profit whose policy could change with its funding. The good ones publish honest retention policies and some genuinely honour them. But a policy is a promise about behaviour, and a config file is a statement about capability. You cannot audit a promise. You can audit twenty lines you wrote yourself, on a box you rent, in a country you picked.
SP·02Forwarding moves the log. Recursion breaks it up.
Almost every write-up that says "run your own DNS server" ends up building a forwarder: a small cache on your network that passes anything it does not know to 1.1.1.1 or 9.9.9.9. It is a genuinely useful thing — it is fast, it is five lines, and it stops your local network from watching you. It also leaves the aggregate log exactly where it was. Every name you look up still arrives at one company, now conveniently pre-labelled with the single IP address of your resolver, which makes the stream easier to attribute rather than harder.
A recursive resolver does the work instead of delegating it. Asked for news.example.io, it queries a root server for who runs .io, then queries the .io servers for who runs example.io, then asks that operator directly. Three conversations with three unrelated parties, none of whom is in the business of aggregation, and — this is the part that matters — no single one of them ever sees your full query stream. The root operators see a trickle of TLD requests. Verisign sees that somebody at your address touched something under .com. The authoritative server for a domain sees the traffic it was always going to see, because you are about to connect to it anyway.
QNAME minimisation sharpens this considerably, and modern Unbound does it by default. Rather than sending the whole name to every server in the chain — which is what resolvers did for thirty years — it sends each server only the label it needs to answer: io. to the root, example.io. to the .io servers, and only then the full news.example.io. to the operator who is entitled to it. The hierarchy stops being a broadcast of your intentions and becomes what it was drawn as: a delegation.
The trade is real and worth stating plainly. A cold recursive lookup takes several round trips where a forwarder takes one, so the first visit to an unfamiliar domain is measurably slower. You inherit responsibility for a cache that used to be somebody else's problem. And you no longer benefit from a shared cache warmed by millions of other people. In exchange, the complete, ordered, timestamped record of what you looked at stops existing anywhere outside your own disk.
SP·03What this hides, and what it plainly does not
The honest scope is narrower than the marketing around private DNS suggests, and knowing the edges is what keeps you from making a bad decision on the strength of a good feeling.
It removes one thing: the aggregated name log held by a single observer. That is a large thing, because the aggregate is what has commercial value and what gets requested in bulk. It is not everything.
It does not hide the connection. After the lookup resolves, your device still opens a session to that address, and anyone watching your uplink sees the destination IP. For a site on dedicated infrastructure, the IP is the identity. Nor does it hide the hostname on the wire: unless both ends support Encrypted Client Hello, the TLS handshake still carries the server name in plaintext, which is the same information your resolver would have had.
It does not hide the queries from your hosting provider. This is the one people get wrong, so it deserves to be stated without hedging: your resolver's upstream traffic — the questions it asks the root, the TLD and the authoritative servers — leaves the VPS on UDP port 53, unencrypted, and the network your server sits on can read all of it. There is no DoT to the root. You have not deleted the observer so much as moved it, from a consumer ISP that sells data and answers subpoenas in your own country, to a hosting network in a jurisdiction you chose deliberately, seeing a fragmented stream thanks to QNAME minimisation. That is a genuine improvement, and it is an argument about whose law applies to the wire rather than something a config file can settle.
And it does not give you a crowd. A resolver used by one household attributes every query in it to that household with no ambiguity at all. Against a global passive adversary, a busy shared resolver is genuinely the better hiding place. Against your ISP, your employer, the data brokers who buy resolver telemetry, and the routine bulk requests that actually happen to ordinary people, yours is better — provided the last hop from your devices is inside a tunnel. Run this with your own WireGuard tunnel, not instead of it. On its own, a private resolver mostly relocates your metadata. Behind a tunnel, it closes the one channel the tunnel leaves open.
SP·04The mistake that turns your resolver into somebody else's weapon
There is exactly one way to get this badly wrong, it is easy to do by accident, and the consequences land on strangers before they land on you.
A resolver that answers anyone is an open resolver, and an open resolver is an amplifier. DNS runs over UDP, UDP source addresses are trivially forged, and a small query can produce a response many times its size. An attacker sends your box a 60-byte question with a victim's address forged as the sender; your box dutifully sends a response several dozen times larger to the victim. Do that from a few thousand open resolvers at once and the victim is off the internet, having received a flood that appears — accurately, at the packet level — to be coming from you. You are not the target. You are the gun, and the traffic in the incident report is yours.
What follows is unglamorous: abuse reports from networks you have never heard of, a provider that nullroutes your address to protect its own transit, and an account conversation you would rather not have. You end up on the causing end of exactly the event described in our DDoS first-hour runbook, and there is no version of that story where your uptime survives it.
Two independent locks prevent it, and you want both, because each covers the other's failure. The first is Unbound's own access-control, which should refuse the whole internet on both IP families and then explicitly allow loopback and your tunnel subnet — a default-deny list, not an allow list with a permissive tail. The second is where the daemon listens at all: bind it to 127.0.0.1 and the tunnel address, never to 0.0.0.0, and keep port 53 closed on the public interface at the firewall.
The trap is doing only the first. An access-control rule does not make the port disappear; a refused query is still a packet received and a packet sent, your address still shows up in the scans that map open resolvers, and one later edit to the wrong stanza turns a refusal into an answer. Binding and firewalling make the mistake structurally impossible rather than a line of config away. If the box also runs containers, re-read how Docker publishes ports around your firewall before you assume the rule you wrote is the rule in force.
Unbound's defaults are sane. They are not private.
Unbound ships tuned for correctness and stability, which is the right default for software that is mostly deployed by ISPs. A handful of settings turn it into something built for the person running it. None of them are exotic; they are just off, or unopinionated, out of the box.
Stop answering questions about yourself. By default a resolver will happily report its software version and hostname through the CHAOS class — version.bind and hostname.bind — which is free reconnaissance for anyone deciding whether your box is worth attention. hide-identity and hide-version cost nothing and remove a fingerprint.
Fail closed, not open. DNSSEC validation is the difference between a resolver that detects a forged answer and one that serves it. harden-dnssec-stripped refuses to accept an unsigned answer for a zone that should be signed, harden-glue and harden-below-nxdomain close two classic cache-poisoning avenues, and aggressive-nsec lets the resolver answer non-existent names straight from cached denial records instead of asking again. use-caps-for-id adds randomised capitalisation as extra entropy against blind spoofing — cheap, and occasionally incompatible with a badly built authoritative server, which is worth knowing before you spend an afternoon on a domain that will not resolve.
Write nothing down. Unbound does not log queries unless asked, but the settings that would are one uncommented line away and some distribution packages ship a chattier default. Set verbosity: 0 and state log-queries: no explicitly, so that the intent is visible in the file rather than inferred from its absence. Then remember the part that is not a setting: the cache itself is a record. unbound-control dump_cache on a running box prints a recent history of what that box looked up, and it lives in the memory of a machine somebody else's hands can reach. It expires on its own, which is the whole reason short cache lifetimes and privacy are in slight tension, and it is the reason not to keep a resolver alive for months on a host you do not trust at all.
Cache deliberately. Every answer served from cache is an observation that never happens upstream, so a healthy cache is a privacy feature and not only a speed one. prefetch renews popular records before they expire, so the common case stops touching the network at all; serve-expired keeps you online when an authoritative server is briefly unreachable. Raising cache-min-ttl reduces upstream chatter further, but it overrides deliberate choices by domain operators — low TTLs are how CDNs and failover work — so a floor of a minute or two is sensible and an hour will eventually strand you on a dead address.
Two ways in: the tunnel, or DNS-over-TLS
Your devices have to reach the resolver somehow, and the choice between the two sane options is mostly a question of what you are willing to publish.
The tunnel is the better answer for almost everyone. If your laptop and phone already hold a WireGuard session to that server, the resolver can listen on the tunnel address and speak ordinary DNS on port 53. The traffic is already encrypted and already authenticated by the tunnel, so there is no certificate to obtain, no new port open to the internet, no TLS stack exposed to strangers, and — the underrated part — no hostname anywhere. The WireGuard guide on this site leaves clients pointed at DNS = 9.9.9.9 precisely because there was nothing better to put there yet. This is what goes in that line instead: the tunnel address of your own server.
DNS-over-TLS is for the device that cannot hold a tunnel. Android's Private DNS field is the strongest case for it — it applies system-wide, survives reboots, and covers apps you cannot otherwise touch. The cost is a hostname with a valid certificate, and a certificate means a public, permanent entry in the Certificate Transparency logs binding that name to the moment you created it. Passive DNS will then tie the name to the server's address. If the point of the exercise was to keep your name off the infrastructure, use a hostname that leads nowhere near you and register it with the care described in registering a domain anonymously — not a subdomain of the domain you use for everything else, which links the two permanently.
There is a second, subtler cost. A DoT endpoint that roaming phones can reach cannot be restricted by source address, so it is, by definition, a resolver that strangers can use if they learn the name. It is not an amplifier — TLS over TCP requires a completed handshake, so the source cannot be forged and there is nothing to reflect — but it is capacity you are giving away, and a service worth scanning for. Keep the per-IP rate limits on, choose a hostname nobody will guess, and treat it as a deliberate exception for one or two devices rather than the default door.
DNS-over-HTTPS is a third option and usually the wrong one here. It needs a web server in front of the resolver, which is more moving parts and a larger attack surface for the single benefit of being indistinguishable from web traffic. That benefit is decisive if you are getting around a network that blocks DoT, and irrelevant if you are not.
SP·07Blocking is a different product; decide before you bolt it on
Sooner or later somebody suggests adding blocklists, and the pitch is genuinely attractive: filtering at the resolver covers every device on the tunnel, including the smart TV and the phone apps where no extension can reach. On mobile in particular it is the only practical place to intervene. It is also the feature most likely to make you quietly distrust your own infrastructure, and it is worth understanding why before it happens rather than during.
The first cost is that failures do not look like failures. A broken resolver announces itself; a blocked domain presents as a checkout button that does nothing, an app stuck on a spinner, an email that never arrives. The symptom appears weeks after you installed the list, on a device you were not thinking about, and nothing connects it to a DNS decision you made in another month. Every blocklist you install is a policy written by a stranger and enforced silently on your household. If you add one, write down that you did, keep it small and reputable, and keep a one-command way to turn it off — the first debugging step for anything unexplained on your network becomes "bypass the filter and retry", and that step has to be cheap.
The second cost is that it does not do what people hope. An application with a hardcoded resolver address, or its own DoH client compiled in, never asks your resolver anything; it opens a connection to a hardcoded IP and is gone. Browsers increasingly resolve over their own DoH unless told otherwise. DNS blocking is a hygiene layer that removes a lot of low-effort tracking and advertising noise, and it is not a security control, because anything adversarial routes around it by design.
If you want it anyway, prefer a small local zone in Unbound over a second daemon — local-zone: "tracker.example." always_nxdomain needs no extra software, no web UI on a port you then have to defend, and no new service that can fall over and take your name resolution with it. Keep the machine's job description to one line: it resolves names. Every additional responsibility you give it is another way for everything to stop working at once.
A resolver you run is a dependency you own
When a website you host goes down, some people cannot read something. When your resolver goes down, nothing works — not the browser, not email, not the package manager, not the app that was going to tell you the server is down. It is the most load-bearing service you can put on a cheap box, and it fails in ways that do not look like DNS.
The realistic failure modes are worth knowing in advance because each has a different signature. The VPS reboots and Unbound was never enabled at boot, so everything works until the first unplanned restart. A large blocklist pushes the cache into swap on a 1 GB instance and the OOM killer chooses the resolver. A zone somewhere breaks its own DNSSEC signatures, and your correctly-configured resolver refuses the answer while everybody on a non-validating resolver keeps browsing — your box is right and the site still looks broken to you, which is a confusing five minutes if you have forgotten you validate. Or the tunnel drops, and because DNS = 10.66.0.1 only exists inside the tunnel, the device has no resolver at all and reports that it is offline.
The mitigations are mercifully cheap. Enable the service at boot and actually test it with a reboot rather than assuming. Give clients a secondary resolver so a dead tunnel degrades instead of stopping — a public resolver in that slot is a small, explicit privacy compromise that only applies while your own is unreachable, and it is usually the right trade. Keep serve-expired on so a brief upstream outage does not become your outage. Check the resolver from somewhere else, not from the machine itself, which is the only way to notice the difference between "down" and "unreachable".
And keep a copy of the config. The whole thing is a few dozen lines that took you an afternoon to get right and that you will not remember in a year; it belongs in your encrypted off-site backups alongside the WireGuard keys, so that rebuilding is twenty minutes rather than a second afternoon. That is the honest summary of this whole exercise: a small box doing one job, for the price of the cheapest plan on the list, replacing a promise about retention with an arrangement where the record is simply never created.
SP·09Step by step
-
01
Start from a box that is already locked down
A resolver is a small, quiet service, which makes it tempting to install on whatever is lying around. Do not — this box will see every name every device on your tunnel looks up, so it deserves the same treatment as anything else holding secrets. Deploy the smallest plan you like; 1 GB of RAM is ample for a household, since the cache sizes below are measured in tens of megabytes. Work through the first hour after deploy before anything else: a named user, key-only SSH, default-deny on both IP families, unattended security updates.
Then install Unbound. The distribution package ships the root hints and the DNSSEC root trust anchor and wires up the anchor's automatic rollover, which is one of the few cases where the packaged version genuinely saves you from a class of future breakage.
sudo apt update && sudo apt install -y unbound dnsutils unbound -V | head -n 3 # the packaged trust anchor the resolver will validate against sudo ls -l /var/lib/unbound/root.key
If
root.keyis missing, your package did not rununbound-anchorand validation will fail closed on everything. Generate it once withsudo -u unbound unbound-anchor -a /var/lib/unbound/root.keybefore continuing. -
02
Take port 53 back from systemd-resolved
On most current distributions something already owns port 53:
systemd-resolvedruns a stub listener on127.0.0.53, and/etc/resolv.confis a symlink pointing at it. Unbound will refuse to start, or will start and bind nothing useful, until that is resolved. Look before you edit.ss -ulpn 'sport = :53' ls -l /etc/resolv.conf
Read this paragraph before running the next block: between disabling the stub and starting Unbound, this machine has no working DNS at all. Finish steps 3 and 4 in the same sitting, and do not kick off an
aptoperation in another window first — it will hang on a name lookup and you will misdiagnose it as the resolver you have not started yet.# stop resolved from holding the port (appends inside the [Resolve] section) printf 'DNSStubListener=no\n' | sudo tee -a /etc/systemd/resolved.conf sudo systemctl restart systemd-resolved # point the host at the resolver it is about to run sudo rm -f /etc/resolv.conf printf 'nameserver 127.0.0.1\noptions edns0 trust-ad\n' | sudo tee /etc/resolv.conf # the port should now be free ss -ulpn 'sport = :53'
-
03
Write a configuration that recurses and forgets
Leave the packaged configuration alone and add your own file in the drop-in directory, so a package upgrade never silently reverts your decisions. Every line below is either about refusing strangers, recursing from the root, or not keeping records. Replace
10.66.0.1and10.66.0.0/24with the tunnel address and subnet from your own WireGuard server if they differ.sudo tee /etc/unbound/unbound.conf.d/private-resolver.conf > /dev/null <<'EOF' server: # listen for the host itself and for the tunnel — never on 0.0.0.0 interface: 127.0.0.1 interface: 10.66.0.1 port: 53 # default-deny: refuse the internet, then allow what you trust access-control: 0.0.0.0/0 refuse access-control: ::/0 refuse access-control: 127.0.0.0/8 allow access-control: 10.66.0.0/24 allow # recurse from the root and send each server only the label it needs qname-minimisation: yes harden-dnssec-stripped: yes harden-below-nxdomain: yes harden-glue: yes aggressive-nsec: yes use-caps-for-id: yes # answer nothing about the software or the host hide-identity: yes hide-version: yes # keep no query log, and say little to the journal verbosity: 0 log-queries: no log-replies: no # a warm cache is an upstream observation that never happens cache-min-ttl: 120 cache-max-ttl: 86400 prefetch: yes prefetch-key: yes serve-expired: yes # belt and braces if a rule above is ever loosened ratelimit: 1000 ip-ratelimit: 100 # sizing for a small instance num-threads: 2 so-reuseport: yes msg-cache-size: 32m rrset-cache-size: 64m EOFNote what is not in the file: there is no
forward-zone. Its absence is what makes this a recursive resolver rather than a cache in front of somebody else's. If you later paste a snippet from a tutorial that adds one, you have quietly undone the entire point of the exercise. -
04
Start it, then prove DNSSEC really validates
Check the syntax before restarting anything — on a box whose own
resolv.confnow points at Unbound, a config error means no name resolution while you debug it.sudo unbound-checkconf sudo systemctl enable --now unbound systemctl --no-pager status unbound | head -n 5
Now verify the two behaviours that matter, because a resolver that answers is not the same as a resolver that validates. A signed name must come back with the
adflag — authenticated data — and a name with deliberately broken signatures must fail closed withSERVFAILrather than resolving.# should show: flags: qr rd ra ad dig @127.0.0.1 example.com +dnssec | grep -E '^;; flags' # should show: status: SERVFAIL (not NOERROR, not an address) dig @127.0.0.1 dnssec-failed.org | grep -E '^;; ->>HEADER' # and a normal name should simply work dig @127.0.0.1 +short cloudflare.com
If the broken zone resolves to an address, validation is not running: check that
/var/lib/unbound/root.keyexists and is readable by theunbounduser. If everything isSERVFAIL, the usual cause is a clock that is badly wrong — signatures have validity windows, and a box several hours out of sync rejects the whole internet. -
05
Close the public door, open only the tunnel
The configuration already refuses strangers. This step makes it so there is nothing for a stranger to reach in the first place — the second of the two locks, and the one that survives a future edit to the first.
# DNS is reachable from the tunnel interface only sudo ufw allow in on wg0 to any port 53 proto udp sudo ufw allow in on wg0 to any port 53 proto tcp sudo ufw status verbose
Then verify it the only way that counts, from a different machine. Testing from the server itself proves nothing at all — loopback is allowed on purpose. Run this from your laptop with the tunnel down, or from any other host you have.
# must time out. an answer here means you are running an open resolver. dig @SERVER_PUBLIC_IP example.com +time=3 +tries=1 # same question over IPv6, which is the half people forget dig -6 @SERVER_IPV6 example.com +time=3 +tries=1
If either returns an answer, stop and fix it now rather than after the abuse report. The usual causes are an
interface: 0.0.0.0left in a packaged config file, a firewall rule allowing 53 globally from an earlier experiment, or Docker publishing a container port that bypassesufwentirely. -
06
Point your devices at it
On the client side this is a one-line change. In the WireGuard client configuration, the
DNSline becomes the tunnel address of your server instead of a public resolver — this is the edit that retiresDNS = 9.9.9.9from the WireGuard guide.[Interface] PrivateKey = <paste client.key> Address = 10.66.0.2/32, fd86:ea04:1115::2/128 DNS = 10.66.0.1 [Peer] PublicKey = <paste server.pub> Endpoint = YOUR_SERVER_IP:51820 AllowedIPs = 0.0.0.0/0, ::/0 PersistentKeepalive = 25
Bring the tunnel up and confirm two separate things from the client: that answers are coming from your resolver, and that the recursion is actually egressing from your VPS rather than somewhere else. The second check is the useful one —
whoami.akamai.netreturns the address of whichever resolver asked, so it should print your server's public IP and nothing else.# answers should come from the tunnel address dig example.com | grep -E 'SERVER:' # should print your VPS public IP — this is the leak test dig +short whoami.akamai.net # and the ad flag should still be there, end to end dig example.com +dnssec | grep -E '^;; flags'
-
07
Add DNS-over-TLS only for a device that cannot hold the tunnel
Skip this step unless you have a specific device — usually an Android phone, via its system-wide Private DNS setting — that you want covered without a permanent tunnel. It requires a hostname and a certificate, and that hostname becomes a permanent public record in the Certificate Transparency logs, so choose one that leads nowhere near your other identities.
sudo apt install -y certbot sudo ufw allow 80/tcp comment 'certbot, temporarily' sudo certbot certonly --standalone -d dns.example.net sudo ufw delete allow 80/tcp # unbound must be able to read the key sudo usermod -a -G ssl-cert unbound 2>/dev/null || true sudo chgrp -R ssl-cert /etc/letsencrypt/live /etc/letsencrypt/archive sudo chmod -R g+rX /etc/letsencrypt/live /etc/letsencrypt/archive
Add the TLS listener as its own drop-in file, so you can delete it in one move if you change your mind.
# NOTE: drop-in files are read in alphabetical order and the last # access-control line for a given prefix wins — so this file must # sort AFTER private-resolver.conf, or its refuse rule overrides # the allow below and every DoT client gets REFUSED. sudo tee /etc/unbound/unbound.conf.d/zz-dot.conf > /dev/null <<'EOF' server: interface: 0.0.0.0@853 interface: ::0@853 tls-service-key: "/etc/letsencrypt/live/dns.example.net/privkey.pem" tls-service-pem: "/etc/letsencrypt/live/dns.example.net/fullchain.pem" # roaming clients have no fixed address, so this endpoint must accept any. # safe only because port 53 stays bound to loopback + wg0 and blocked at # the firewall — TLS over TCP cannot be spoofed, so it cannot be reflected. access-control: 0.0.0.0/0 allow access-control: ::/0 allow EOF sudo ufw allow 853/tcp sudo unbound-checkconf && sudo systemctl restart unboundTest from outside the tunnel before trusting it, then put the hostname in the phone's Private DNS field. Renewal is the part that breaks silently three months later — certbot replaces the certificate but Unbound holds the old one in memory, so add a deploy hook that reloads it.
kdig -d @dns.example.net +tls-ca +tls-host=dns.example.net example.com echo 'systemctl reload unbound' | sudo tee /etc/letsencrypt/renewal-hooks/deploy/unbound.sh sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/unbound.sh


