Self-Hosted DNS with Unbound and Pi-hole

A recursive resolver with DNSSEC validation and ad filtering, no upstream resolver required.

Most home networks send all DNS queries to an ISP resolver or a third-party service like Cloudflare or Google. Running your own recursive resolver eliminates that dependency: your queries go directly to the authoritative name servers for each domain, no intermediary logs them, and you can validate responses with DNSSEC. Layering Pi-hole on top gives you network-wide ad and tracker blocking without per-device configuration.

Architecture

The setup has two layers:

This is preferable to Pi-hole forwarding to an upstream public resolver because your DNS traffic never leaves your network for resolution.

1. Install Unbound

apt update && apt install -y unbound

Download the root hints file (the list of root DNS servers):

curl -o /var/lib/unbound/root.hints   https://www.internic.net/domain/named.root
# Schedule this for periodic refresh (quarterly is fine)
systemctl enable --now unbound

2. Configure Unbound

Create /etc/unbound/unbound.conf.d/pi-hole.conf:

server:
  verbosity: 1
  interface: 127.0.0.1
  port: 5335
  do-ip4: yes
  do-udp: yes
  do-tcp: yes
  do-ip6: no

  # Root hints
  root-hints: /var/lib/unbound/root.hints

  # Security
  harden-glue: yes
  harden-dnssec-stripped: yes
  harden-algo-downgrade: yes
  use-caps-for-id: yes

  # DNSSEC validation
  auto-trust-anchor-file: /var/lib/unbound/root.key

  # Cache
  cache-min-ttl: 3600
  cache-max-ttl: 86400
  prefetch: yes
  prefetch-key: yes
  num-threads: 2

  # Privacy
  hide-identity: yes
  hide-version: yes
  qname-minimisation: yes

  # Access
  access-control: 127.0.0.1/32 allow

Fetch and initialise the DNSSEC trust anchor:

unbound-anchor -a /var/lib/unbound/root.key
systemctl restart unbound

Test that Unbound resolves and validates correctly:

# Should return SERVFAIL (DNSSEC validation failure on a deliberately broken domain)
dig dnssec-failed.org @127.0.0.1 -p 5335

# Should return a valid A record with AD flag set
dig kernel.org @127.0.0.1 -p 5335 | grep -E "status:|flags:"

The AD flag in the dig output means “Authenticated Data” — Unbound has validated the DNSSEC chain for this response.

3. Install Pi-hole

Pi-hole has a one-liner installer. Audit it before running:

curl -sSL https://install.pi-hole.net -o pihole-install.sh
less pihole-install.sh   # Review the script
bash pihole-install.sh

During the installer:

4. Point Pi-hole at Unbound

Edit /etc/pihole/setupVars.conf and set:

PIHOLE_DNS_1=127.0.0.1#5335
PIHOLE_DNS_2=

Then restart the Pi-hole DNS service:

pihole restartdns
pihole status

Verify the chain works end to end:

dig kernel.org @127.0.0.1

5. Configure clients to use Pi-hole

The simplest approach is to set your router to hand out the Pi-hole’s IP as the DNS server via DHCP. On an OPNsense or pfSense router:

Alternatively, for a subset of machines, configure DNS statically:

# On Debian/Ubuntu with systemd-resolved
sudo mkdir -p /etc/systemd/resolved.conf.d/
cat <<EOF | sudo tee /etc/systemd/resolved.conf.d/custom-dns.conf
[Resolve]
DNS=192.168.10.53
FallbackDNS=
DNSStubListener=no
EOF
sudo systemctl restart systemd-resolved

6. Updating blocklists

Pi-hole fetches blocklists during install and when you run pihole -g. Schedule automatic updates:

# Add to crontab (crontab -e)
0 3 * * 0 pihole -g > /var/log/pihole-update.log 2>&1

Useful additional blocklists beyond the default:

Add them via the Pi-hole web interface under Adlists, or directly in the database:

sqlite3 /etc/pihole/gravity.db   "INSERT INTO adlist (address, enabled) VALUES ('https://...', 1);"
pihole -g

7. Monitoring resolution failures

Pi-hole’s dashboard shows query statistics. For Unbound, enable logging to watch resolution behaviour:

# In /etc/unbound/unbound.conf.d/pi-hole.conf, add:
server:
  verbosity: 2
  log-queries: yes
  logfile: /var/log/unbound/unbound.log
mkdir -p /var/log/unbound
chown unbound:unbound /var/log/unbound
systemctl restart unbound
journalctl -u unbound -f