HomeTechOps

Wi-Fi & Network

Pi-hole + Unbound recursive DNS setup

Pairing Pi-hole with a local Unbound resolver means your lookups go straight to the root and authoritative servers instead of through Google/Cloudflare. This guide sets it up the way the Pi-hole docs do, with the gotchas operators actually hit.

Who this is for

Pi-hole users who want to stop forwarding to a third-party resolver (Google/Cloudflare) and instead run their own recursive resolver with Unbound — for privacy and independence — and who are comfortable editing a config file and a sysctl on the Pi-hole host.

Outcome

A working Pi-hole → Unbound chain where Unbound listens on 127.0.0.1#5335, recurses from the root servers, and validates DNSSEC; Pi-hole forwards only to it (all other upstreams unticked); the common so-rcvbuf and port-53 gotchas are handled; and you understand the cold-cache latency trade-off you accepted.

Required inputs

  • A working Pi-hole (Core v6.x) on a host where you have shell/root access to install a package and edit config.
  • The official Unbound config from the Pi-hole docs (listen on 5335, edns-buffer-size 1232, DNSSEC root trust anchor) — don't hand-roll it.
  • Ability to set a kernel sysctl (net.core.rmem_max) on bare metal, or to lower so-rcvbuf if running in Docker/LXC.
  • On Debian/Ubuntu hosts: awareness of systemd-resolved's :53 stub listener and the resolvconf hijack, which must be disabled.
GuideFollow in order

Step-by-step procedure

1

Install Unbound and drop in the documented config

Do: Install the unbound package, then place the Pi-hole docs' config at /etc/unbound/unbound.conf.d/pi-hole.conf (on some Red Hat-based distros the path is /etc/unbound/conf.d/pi-hole.conf). It listens on port 5335, sets edns-buffer-size 1232, and references the root hints + auto-trust-anchor-file for DNSSEC. Restart Unbound.

Expected result: Unbound starts cleanly and is listening on 127.0.0.1:5335.

If not: If it won't start, read the Unbound log for a config or path error — usually the trust-anchor/root-hints path or a stray duplicate listen directive.

2

Test Unbound directly before touching Pi-hole

Do: Run dig pi-hole.net @127.0.0.1 -p 5335 and confirm an answer. Then test DNSSEC: dig sigfail.verteiltesysteme.net @127.0.0.1 -p 5335 should return SERVFAIL, and dig sigok.verteiltesysteme.net @127.0.0.1 -p 5335 should return NOERROR with an IP.

Expected result: Normal lookups resolve; the bogus DNSSEC domain is rejected (SERVFAIL) and the valid one succeeds.

If not: If the bogus domain resolves instead of SERVFAIL, DNSSEC validation isn't active — re-check the trust-anchor config before pointing Pi-hole at it.

3

Point Pi-hole at Unbound and remove other upstreams

Do: In Pi-hole Settings > DNS, set the Custom (upstream) DNS to 127.0.0.1#5335 and untick every other upstream (Google/Cloudflare/etc.). Save.

Expected result: Pi-hole forwards exclusively to Unbound; clients still resolve normally.

If not: If resolution breaks, confirm Unbound is up (previous step) and that no firewall blocks localhost:5335; don't leave a public upstream ticked 'as backup' — it defeats the point.

4

Fix the so-rcvbuf / kernel-buffer warning

Do: If the Unbound log shows 'so-rcvbuf … was not granted', raise net.core.rmem_max (and wmem_max) via /etc/sysctl.d and apply with sysctl --system on bare metal. In Docker/LXC where you can't change host sysctls, lower so-rcvbuf in the config instead.

Expected result: The warning clears (bare metal) or is rendered harmless (container falls back to the granted size).

If not: If the warning persists on bare metal, confirm the sysctl actually applied (sysctl net.core.rmem_max) and that you restarted Unbound.

5

Clear port-53 conflicts and the resolvconf hijack

Do: On systemd hosts, set DNSStubListener=no in /etc/systemd/resolved.conf and restart systemd-resolved so it doesn't squat on :53 (Pi-hole/FTL needs it; Unbound is on 5335). On Debian Bullseye+, disable the resolvconf entry for Unbound so it doesn't rewrite /etc/resolv.conf into a loop. Leave Pi-hole's own DNSSEC toggle OFF — let Unbound validate.

Expected result: FTL owns :53, Unbound owns 5335, and there's no resolv.conf loop or double DNSSEC validation.

If not: If the host itself can't resolve during a Pi-hole outage, remember the host uses Pi-hole as upstream — a temporary public resolver restores it while you repair.

6

Verify end to end and warm the cache

Do: From a client, confirm normal browsing and that blocked domains are still blocked; watch the Pi-hole query log show replies coming from 127.0.0.1#5335. Expect the first lookup of a new TLD to be slower; repeats are sub-0.1s.

Expected result: Clients resolve through Pi-hole → Unbound, DNSSEC is validated by Unbound, and performance settles after the cache warms.

If not: If lookups feel persistently slow, it's usually cold cache or a DNSSEC-broken zone, not a misconfig — confirm with dig timing against 5335.

Commands and settings paths

Unbound resolves + validates DNSSEC

dig pi-hole.net @127.0.0.1 -p 5335 ; dig sigfail.verteiltesysteme.net @127.0.0.1 -p 5335

Where: On the Pi-hole/Unbound host.

Expected: pi-hole.net returns an answer; sigfail returns SERVFAIL (and sigok returns NOERROR + IP).

Failure means: If sigfail resolves instead of SERVFAIL, DNSSEC validation isn't active.

Safe next step: Re-check the auto-trust-anchor-file/root-hints config before pointing Pi-hole at Unbound.

Who's on port 53 vs 5335?

sudo ss -lntup | grep -E ':53 |:5335 '

Where: On the host.

Expected: pihole-FTL listens on :53 and unbound on :5335 — no systemd-resolved stub on :53.

Failure means: systemd-resolved squatting on :53 will collide with FTL.

Safe next step: Set DNSStubListener=no and restart systemd-resolved.

Confirm the so-rcvbuf fix

sysctl net.core.rmem_max ; sudo journalctl -u unbound | grep -i so-rcvbuf

Where: On the host (bare metal).

Expected: rmem_max is raised to match the config and the 'not granted' warning no longer appears after restart.

Failure means: A still-low rmem_max means the sysctl didn't apply.

Safe next step: Re-apply via /etc/sysctl.d + sysctl --system and restart Unbound (or lower so-rcvbuf in containers).

Evidence to record

  • The Unbound config path and that it listens on 5335 with edns-buffer-size 1232 and a DNSSEC trust anchor.
  • dig results: a normal lookup against :5335, plus the sigfail (SERVFAIL) / sigok (NOERROR) DNSSEC pair.
  • Whether you raised net.core.rmem_max (bare metal) or lowered so-rcvbuf (container).
  • That Pi-hole's only upstream is 127.0.0.1#5335 with all others unticked, and Pi-hole's own DNSSEC toggle is off.

Common mistakes

  • Leaving a public upstream (Cloudflare/Google) ticked 'as backup' in Pi-hole — it defeats the privacy point and Pi-hole will use it.
  • Enabling Pi-hole's own DNSSEC on top of Unbound's — double validation that just bloats the query database.
  • Ignoring the so-rcvbuf warning's root cause on bare metal (raise rmem_max) vs container (lower so-rcvbuf) — applying the wrong fix.
  • Forgetting systemd-resolved's :53 stub or the resolvconf hijack, so FTL can't bind :53 or resolv.conf loops.

Stop points

  • Stop and restore a temporary public resolver if the Pi-hole host itself loses DNS mid-setup (it uses Pi-hole as upstream) — you can't repair offline.
  • Stop before assuming Unbound is broken if lookups are merely slow on first hit — that's expected cold-cache behavior, not a fault.

Last reviewed

2026-06-02

Source-backed checks

HomeTechOps turns official docs and conservative safety rules into a shorter runbook. These links are the source trail for the page direction.