For years my home DNS was Pi-hole forwarding to a public resolver. Pi-hole did the blocking, someone else's resolver did the actual work of finding answers. It's a perfectly sensible setup and most people should stop reading here. But I'd been forwarding all my queries to a single upstream that could see every lookup the house made, and I wanted the work done on my own kit. So I put a recursive resolver behind Pi-hole and cut the middleman out.
The distinction matters and is worth being precise about. A forwarder takes your query and asks someone else, who does the real recursion. A recursive resolver does the recursion itself: it asks a root server who handles .pm, asks that server who handles i0.pm, and walks down the tree until it has the answer. Nobody upstream sees a tidy log of everything you looked up, because there is no single upstream. You're talking to the authoritative servers directly, the way DNS was meant to work.
The setup
Unbound, locally, listening on a port Pi-hole forwards to. The config is small and the defaults are good.
server:
interface: 127.0.0.1
port: 5335
do-ip6: no
prefetch: yes
cache-min-ttl: 300
harden-glue: yes
harden-dnssec-stripped: yes
auto-trust-anchor-file: "/var/lib/unbound/root.key"
Pi-hole then forwards to 127.0.0.1#5335 instead of a public resolver, and that's it: blocking still happens in Pi-hole, recursion happens in Unbound, nobody offsite sees the stream of queries.
The afternoon it all broke
DNSSEC validation is the bit that bites you. auto-trust-anchor-file lets Unbound validate signatures against the root trust anchor, which is good and correct and the whole point of validating rather than blindly trusting. But validation depends on time. If your clock is wrong, signatures appear to be outside their validity window and every signed domain fails. And a startling amount of the internet is signed now.
My mistake was the boot order. The box came up, Unbound started, and systemd-timesyncd hadn't yet corrected a clock that had drifted while the machine was off. For a few minutes Unbound was validating against a clock that thought it was last week, rejecting good signatures, and serving SERVFAIL for half my bookmarks. The fix was unglamorous: make Unbound wait for time sync.
[Unit]
After=systemd-timesyncd.service
Wants=systemd-timesyncd.service
Worth it?
For raw speed, marginally, once the cache warms. The first hit on a cold name is slower than a big public resolver that already had it cached for ten thousand other people; every hit after that is local and instant, and prefetch keeps the popular names warm so they rarely expire under me. That's not really why I did it though. I did it so the lookups happen on my own hardware, validated against the actual root of trust, with no third party sitting in the path keeping a record. The performance is a wash. The control is the point, and the afternoon of SERVFAILs was a fair price for learning exactly how the trust chain hangs together.