Skip to content

Pi-hole + Unbound

Every time you visit a website, your device asks a DNS server to translate the domain name (like example.com) into an IP address. By default, those queries go to your ISP’s DNS servers, or to public resolvers like Google (8.8.8.8) or Cloudflare (1.1.1.1).

This setup replaces that with two components running on your own network:

ComponentWhat it doesWhy you want it
Pi-holeDNS sinkhole — intercepts DNS queries and blocks requests to known ad, tracking, and malware domainsNetwork-wide ad blocking for every device. No browser extensions needed. Works on smart TVs, phones, IoT devices — anything that uses DNS.
UnboundRecursive DNS resolver — resolves queries by talking directly to authoritative nameservers instead of forwarding to a third partyComplete DNS privacy. No single upstream provider sees all your queries. Queries go directly to the nameserver responsible for each domain.

Why not just use Pi-hole with Cloudflare/Google upstream?

Section titled “Why not just use Pi-hole with Cloudflare/Google upstream?”

That works, and it’s how most people start. But it means Cloudflare or Google sees every domain you visit. They say they don’t log it (or purge logs quickly), but you’re trusting their word. With Unbound as a recursive resolver:

  • No single entity sees all your queries — each authoritative nameserver only sees queries for domains it’s responsible for
  • DNSSEC validation happens locally — you verify signatures yourself instead of trusting the upstream to do it
  • No query logging by third parties — your DNS traffic stays on your network until the final hop to the authoritative server
  • No censorship or filtering — some ISP and public DNS resolvers filter or redirect certain domains

What about DNS-over-HTTPS (DoH) / DNS-over-TLS (DoT)?

Section titled “What about DNS-over-HTTPS (DoH) / DNS-over-TLS (DoT)?”

DoH/DoT encrypt the connection between you and your upstream resolver (e.g., Cloudflare). This prevents your ISP from snooping on your DNS queries, but Cloudflare still sees them all. With a local recursive resolver, there’s nothing to encrypt on the first hop — the query never leaves your machine. The final hop to the authoritative nameserver is unencrypted, but that server only sees the single domain it’s responsible for, not your full browsing history.

When you type www.example.com in your browser, here’s what happens with a recursive resolver like Unbound:

  1. Your device asks Pi-hole: “What’s the IP for www.example.com?”
  2. Pi-hole checks its blocklists. If the domain is blocked, it returns 0.0.0.0 (sinkholed). If not, it forwards to Unbound.
  3. Unbound checks its cache. If it has a fresh answer, it returns it immediately. If not, it starts the recursive process:
  4. Unbound asks a root nameserver: “Who handles .com?” → The root server replies with the .com TLD nameservers.
  5. Unbound asks the .com TLD server: “Who handles example.com?” → It replies with the authoritative nameservers for example.com.
  6. Unbound asks the authoritative server: “What’s the IP for www.example.com?” → It replies with the actual IP address.
  7. Unbound caches the result and returns it to Pi-hole, which returns it to your device.

This is how DNS was designed to work. Forwarding everything to 8.8.8.8 is a shortcut that trades privacy for convenience.

Your Device Pi-hole Unbound
| | |
|--- DNS query ----->| |
| |-- blocked? ------->| (check blocklists)
| |<-- 0.0.0.0 --------| (if blocked)
|<--- 0.0.0.0 -------| |
| | |
| |-- not blocked ---->| (forward to Unbound)
| | |--- ask root server ----> Root (.)
| | |<-- ".com is at x.x.x.x"
| | |--- ask .com TLD ------> .com TLD
| | |<-- "example.com at y.y"
| | |--- ask example.com ---> Authoritative
| | |<-- "93.184.216.34"
| |<-- 93.184.216.34 --| (cached + returned)
|<-- 93.184.216.34 --| |
Your Network
|
All devices use Pi-hole as DNS
|
v
+-----------------------+
| Pi-hole |
| Listening on :53 |
| |
| - DNS sinkhole |
| - Blocklist engine |
| - Query logging |
| - Web dashboard |
| (port 80/8080) |
+----------+------------+
|
Unblocked queries forwarded
to 127.0.0.1:5335
|
v
+-----------------------+
| Unbound |
| Listening on :5335 |
| (localhost only) |
| |
| - Recursive resolver |
| - DNSSEC validation |
| - Response caching |
| - Prefetching |
| - Serve expired |
+----------+------------+
|
Queries go directly to
authoritative nameservers
|
+----+----+
| | |
v v v
Root TLD Auth
. .com example.com

Key design choices:

  • Unbound listens on port 5335, not 53. This avoids conflicts with Pi-hole, which owns port 53.
  • Unbound binds to 127.0.0.1 only. It should never be exposed to the network — Pi-hole is the only thing that talks to it.
  • No forwarding to public DNS. The entire point is that Unbound resolves recursively. Adding Cloudflare as a “fallback” defeats the purpose.
  • A machine with a static IP on your LAN (this will be your DNS server)
  • At least 512 MB RAM and 1 CPU core (a Raspberry Pi, old laptop, VM, or LXC container all work)
  • Access to your router’s DHCP settings (to point clients at Pi-hole)
  • Debian 12/13 or Ubuntu 22.04+ (other distros work but commands may differ)
  • Root or sudo access
  • Docker Engine 24+ and Docker Compose v2
  • A directory for persistent config (this guide uses /opt/stacks/pihole/)

5. Option A — Bare Metal Install (Debian/Ubuntu)

Section titled “5. Option A — Bare Metal Install (Debian/Ubuntu)”

Install Unbound first, since Pi-hole will be configured to use it as upstream.

Terminal window
sudo apt update
sudo apt install -y unbound dns-root-data

The dns-root-data package provides the root hints file and DNSSEC root trust anchor, and keeps them updated via apt.

Unbound will likely fail to start at this point because it tries to listen on port 53, which may conflict with systemd-resolved. That’s fine — we’ll configure it to use port 5335 in the Unbound Configuration section.

On Ubuntu and some Debian installs, systemd-resolved occupies port 53. Check if it’s running:

Terminal window
sudo ss -tlnp | grep ':53 '

If you see systemd-resolved listening on 53, disable it:

Terminal window
# Disable systemd-resolved
sudo systemctl disable --now systemd-resolved
# Point /etc/resolv.conf at localhost (Pi-hole will handle it)
sudo rm /etc/resolv.conf
echo "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf

Run the official installer. It’s a single command:

Terminal window
curl -sSL https://install.pi-hole.net | bash

The installer is interactive. Key choices:

  • Upstream DNS: Pick anything for now (we’ll change it to Unbound later)
  • Blocklists: Accept the default StevenBlack list (we’ll add more later)
  • Web admin interface: Yes
  • Query logging: Yes (useful for debugging, can disable later)
  • Privacy mode: Your choice — “Show everything” is fine for a home network

After installation, set your admin password:

Terminal window
pihole setpassword

Now configure Unbound (see Section 7), then point Pi-hole at it. Skip ahead to the Unbound config section and come back here when it’s running.

Once Unbound is listening on 5335, set it as Pi-hole’s upstream:

Via the web UI: Settings → DNS → remove all upstream servers → add 127.0.0.1#5335 as a custom upstream.

Via CLI (Pi-hole v6):

Terminal window
# Remove any existing upstreams and set Unbound
pihole config dns.upstreams '["127.0.0.1#5335"]'
Terminal window
sudo mkdir -p /opt/stacks/pihole
cd /opt/stacks/pihole

Create the Unbound config file before starting the containers. See Section 7 for an explanation of each setting — this is the full optimised config:

Terminal window
mkdir -p unbound
/opt/stacks/pihole/unbound/unbound.conf
server:
interface: 0.0.0.0
port: 5335
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: no
prefer-ip6: no
# Root hints (provided by dns-root-data in the container)
root-hints: "/usr/share/dns/root.hints"
# DNSSEC
auto-trust-anchor-file: "/usr/share/dns/root.key"
# Security
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: yes
edns-buffer-size: 1232
# Performance
prefetch: yes
num-threads: 1
so-rcvbuf: 1m
# Cache sizes
msg-cache-size: 50m
rrset-cache-size: 100m
key-cache-size: 50m
neg-cache-size: 4m
# Serve expired records while refreshing in background
serve-expired: yes
serve-expired-ttl: 86400
serve-expired-reply-ttl: 30
# Privacy
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: 172.16.0.0/12
private-address: 10.0.0.0/8
private-address: fd00::/8
private-address: fe80::/10
# Cache TTL bounds
cache-min-ttl: 300
cache-max-ttl: 86400
# Logging (set to 0 for production, 1 for debugging)
verbosity: 0
/opt/stacks/pihole/compose.yaml
services:
pihole:
image: pihole/pihole:latest
container_name: pihole
hostname: pihole
ports:
- "53:53/tcp"
- "53:53/udp"
- "8080:80/tcp" # Web UI (change host port if 8080 is taken)
environment:
TZ: "Europe/Copenhagen" # Change to your timezone
FTLCONF_dns_upstreams: "unbound:5335"
FTLCONF_webserver_port: "80o"
# FTLCONF_webpassword: "your-password-here" # Uncomment and set
volumes:
- pihole_data:/etc/pihole
- pihole_dnsmasq:/etc/dnsmasq.d
networks:
- dns
depends_on:
unbound:
condition: service_healthy
restart: unless-stopped
unbound:
image: mvance/unbound:latest
container_name: unbound
hostname: unbound
volumes:
- ./unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro
networks:
- dns
healthcheck:
test: ["CMD", "drill", "@127.0.0.1", "-p", "5335", "cloudflare.com"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped
networks:
dns:
driver: bridge
volumes:
pihole_data:
pihole_dnsmasq:
Terminal window
docker compose up -d

Check that both containers are healthy:

Terminal window
docker compose ps

You should see both pihole and unbound with status Up. The Unbound container may take a few seconds to pass its health check.

Terminal window
docker exec -it pihole pihole setpassword

The web UI is now available at http://<your-ip>:8080/admin.

This section explains the optimised Unbound configuration. If you’re doing the Docker install, this config is already in your unbound.conf. For bare metal, create this file:

/etc/unbound/unbound.conf.d/pi-hole.conf
server:
# Basic settings
interface: 127.0.0.1
port: 5335
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: no
prefer-ip6: no
# Root hints
root-hints: "/usr/share/dns/root.hints"
# Security settings
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: yes
edns-buffer-size: 1232
# Performance
prefetch: yes
num-threads: 1
so-rcvbuf: 1m
# Cache sizes
msg-cache-size: 50m
rrset-cache-size: 100m
key-cache-size: 50m
neg-cache-size: 4m
# Serve expired records while refreshing
serve-expired: yes
serve-expired-ttl: 86400
serve-expired-reply-ttl: 30
# Privacy - hide local network ranges
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: 172.16.0.0/12
private-address: 10.0.0.0/8
private-address: fd00::/8
private-address: fe80::/10
# Cache settings
cache-min-ttl: 300
cache-max-ttl: 86400
# Logging (set to 0 for production)
verbosity: 1
SettingValueWhy
interface127.0.0.1Only accept queries from localhost (Pi-hole). In Docker, use 0.0.0.0 since containers communicate over a bridge network.
port5335Avoids conflict with Pi-hole on port 53. This is the standard convention for Pi-hole + Unbound setups.
do-ip6noDisable if your network doesn’t use IPv6. Prevents unnecessary AAAA lookups that will always fail. Enable if you have working IPv6.
root-hintspath to fileTells Unbound where the DNS root servers are. Without this, it relies on a compiled-in list that may become outdated.
harden-glueyesOnly trusts glue records if they are within the delegated domain. Prevents cache poisoning via rogue glue.
harden-dnssec-strippedyesRequires DNSSEC data for trust-anchored zones. Prevents downgrade attacks that strip DNSSEC signatures.
use-caps-for-idyesRandomises the case of query names (0x20 encoding). An additional layer of protection against spoofing — the response must match the randomised case of the query.
edns-buffer-size1232The recommended EDNS buffer size to avoid fragmentation with IPv6. The DNS Flag Day 2020 recommendation.
prefetchyesWhen a cached record is about to expire (within 10% of its TTL), Unbound fetches a fresh copy in the background. Users always get a cached response.
num-threads1Match to the number of CPU cores. 1 is sufficient for home use. Set to 2 or 4 if you have the cores and heavy DNS traffic.
so-rcvbuf1mSocket receive buffer size. Helps handle burst traffic. Requires net.core.rmem_max ≥ 1048576 (see below).
msg-cache-size50mCache for DNS message responses. Default is ~4MB, far too small for good hit rates.
rrset-cache-size100mCache for individual DNS records. Should be roughly 2x msg-cache-size.
key-cache-size50mCache for DNSSEC keys. Speeds up validation for frequently-visited domains.
neg-cache-size4mCache for NXDOMAIN (non-existent domain) responses. Prevents repeated lookups for domains that don’t exist.
serve-expiredyesReturns stale cache entries immediately while refreshing in the background. Dramatically improves perceived latency — users never wait for a recursive lookup if the answer was cached before.
serve-expired-ttl86400How long (in seconds) an expired record can be served. 86400 = 1 day. After this, the stale record is discarded.
serve-expired-reply-ttl30The TTL returned to the client for stale records. Low value ensures the client will ask again soon and get the fresh answer.
cache-min-ttl300Minimum time (seconds) to cache records, even if the authoritative server sets a lower TTL. 5 minutes is a good balance. Don’t set this to 3600 — many CDNs use short TTLs for failover, and overriding them causes stale routing.
cache-max-ttl86400Maximum cache time. Caps records that set absurdly long TTLs.
private-addressRFC 1918 rangesPrevents DNS rebinding attacks. Unbound will refuse to return private IP addresses for public domains — so a malicious domain can’t resolve to 192.168.1.1 to attack your router.

Set the socket buffer sysctl (bare metal only)

Section titled “Set the socket buffer sysctl (bare metal only)”

The so-rcvbuf: 1m setting requires the kernel to allow 1MB socket buffers. On most systems, the default is lower:

Terminal window
# Check current value
sysctl net.core.rmem_max
# Set it (immediately)
sudo sysctl -w net.core.rmem_max=1048576
# Persist across reboots
echo "net.core.rmem_max=1048576" | sudo tee -a /etc/sysctl.d/99-unbound.conf
Terminal window
# Check config syntax
sudo unbound-checkconf
# Restart the service
sudo systemctl restart unbound
# Verify it's running
sudo systemctl status unbound
Terminal window
# Basic resolution
dig @127.0.0.1 -p 5335 google.com A +short
# DNSSEC validation (should show RRSIG records)
dig @127.0.0.1 -p 5335 cloudflare.com A +dnssec +short
# Test DNSSEC rejection (this domain has an intentionally broken signature)
dig @127.0.0.1 -p 5335 dnssec-failed.org A +short
# Should return SERVFAIL (refused because DNSSEC validation fails)

Web UI (Pi-hole v6): Go to Settings → DNS → Upstream DNS Servers. Remove all checked public DNS servers. Under “Custom DNS”, add:

127.0.0.1#5335

For Docker, use the container name instead:

unbound#5335

If you used the Docker compose from this guide, this is already set via the FTLCONF_dns_upstreams environment variable.

SettingRecommendedWhy
Interface listening behaviourListen on all interfaces, permit all originsRequired if Pi-hole serves DNS for other subnets (e.g., VLANs, VPN clients). For a single-subnet network, “Listen only on [interface]” is fine.
DNSSECDisabled in Pi-holeUnbound already validates DNSSEC. Enabling it in Pi-hole too causes double validation, which wastes CPU and can cause spurious failures. Let Unbound handle it.
Conditional forwardingEnable if you want local hostnamesIf your router’s DHCP assigns hostnames (e.g., laptop.lan), enable conditional forwarding so Pi-hole can resolve them. Point it at your router’s IP for your local domain.
Rate limitingDefault (1000/min)Prevents a single misbehaving client from overwhelming Pi-hole. The default is fine for home use.

Pi-hole ships with the StevenBlack hosts list, which is a solid baseline. Here’s what I recommend adding for comprehensive coverage:

ListWhat it coversDomains
Hagezi ProAds, tracking, analytics, telemetry. The “one list to rule them all” — it’s a curated superset of most popular blocklists (StevenBlack, AdGuard, EasyList, EasyPrivacy, and dozens more).~182k
Hagezi TIFThreat Intelligence Feeds — malware, C2 servers, phishing, cryptojacking, scam domains. Aggregated from dozens of threat intel sources.~720k

These two lists alone give you better coverage than 20+ individual Firebog/StevenBlack/AdGuard lists combined, with less overlap and faster gravity updates.

Section titled “Tier 2 — Extended (optional, recommended)”
ListWhat it coversDomains
Hagezi DynDNSBlocks dynamic DNS providers commonly abused by malware and phishing campaigns.~1.5k
Hagezi Spam TLDsBlocks entire top-level domains known for spam and abuse (.xyz, .top, .buzz, etc.).~400
Hagezi Spam TLD Allow (allowlist)Companion allowlist for the Spam TLD block — whitelists legitimate domains on those TLDs.~900
Phishing Army ExtendedPhishing-specific blocklist, updated frequently. Catches fresh phishing campaigns not yet in Hagezi TIF.~156k
URLhaus (abuse.ch)Active malware distribution URLs. Updated very frequently by security researchers.~500

Web UI: Go to Adlists → Add a new adlist → paste the URL → Add → then run Tools → Update Gravity (or pihole -g from CLI).

Why I don’t recommend the “use every Firebog list” approach

Section titled “Why I don’t recommend the “use every Firebog list” approach”

Many guides tell you to add all 30+ lists from the Firebog “ticked” list. This was good advice in 2020. Today, Hagezi Pro is a curated superset that includes the vast majority of those domains. Adding both means:

  • Gravity updates take longer (downloading and deduplicating 30 lists vs 2)
  • The gravity database is larger (more RAM/disk used)
  • You gain maybe 1-2% more blocked domains, most of which are dead or inactive
  • Troubleshooting false positives is harder (which of 30 lists blocked it?)

Start with Hagezi Pro + TIF. If something gets through, add targeted lists rather than carpet-bombing with every blocklist you can find.

Pi-hole only works if devices actually use it as their DNS server. There are two approaches:

Change your router’s DHCP settings to hand out Pi-hole’s IP as the DNS server. This affects all devices automatically.

  1. Log into your router’s admin panel
  2. Find the DHCP settings (often under LAN or Network)
  3. Set the Primary DNS to your Pi-hole’s IP (e.g., 192.168.1.80)
  4. Remove or leave blank the Secondary DNS
  5. Save and reboot the router

If you can’t change your router’s DHCP, set Pi-hole as the DNS server on individual devices. This varies by OS:

  • Windows: Network adapter settings → IPv4 properties → “Use the following DNS server addresses”
  • macOS: System Settings → Network → Wi-Fi → Details → DNS
  • Linux: Edit /etc/resolv.conf or your NetworkManager connection
  • iOS/Android: Wi-Fi network settings → Configure DNS → Manual

After changing router DHCP, existing devices won’t pick up the new DNS server until their DHCP lease renews. You can force it:

  • Disconnect and reconnect to Wi-Fi
  • On Windows: ipconfig /renew
  • On Linux: sudo dhclient -r && sudo dhclient
  • Or just wait — most home DHCP leases are 24 hours
Terminal window
# From any device using Pi-hole as DNS
nslookup google.com
# Should return an IP, with "Server: 192.168.1.80" (your Pi-hole IP)
# Or with dig
dig @192.168.1.80 google.com +short
# Should return Google's IP
Terminal window
# This domain should be blocked (it's an ad server)
dig @192.168.1.80 ads.google.com +short
# Should return 0.0.0.0 or ::
# Or just open the Pi-hole dashboard and watch queries come in

11.3 Verify Unbound is resolving recursively

Section titled “11.3 Verify Unbound is resolving recursively”
Terminal window
# Check that Unbound is doing recursive resolution, not forwarding
dig @127.0.0.1 -p 5335 example.com +short +stats 2>&1 | head -20
# Look for "SERVER: 127.0.0.1#5335" in the output
# Verify DNSSEC is working
dig @127.0.0.1 -p 5335 sigfail.verteiltesysteme.net A +short
# Should return SERVFAIL (intentionally broken DNSSEC)
dig @127.0.0.1 -p 5335 sigok.verteiltesysteme.net A +short
# Should return an IP (valid DNSSEC)

Open http://<pihole-ip>/admin (bare metal) or http://<pihole-ip>:8080/admin (Docker) in your browser. You should see:

  • Queries being logged in real time
  • A percentage of queries being blocked (typically 15-40% on a home network)
  • The upstream listed as 127.0.0.1#5335 or unbound#5335

Bare metal:

Terminal window
# Check for updates
pihole -up --check-only
# Apply update
pihole -up

Docker:

Terminal window
cd /opt/stacks/pihole
docker compose pull
docker compose up -d

Gravity updates automatically via a cron job (typically daily at a random time). To force an update:

Terminal window
pihole -g
# or in Docker:
docker exec pihole pihole -g

The dns-root-data package handles this via apt updates. If you want to update manually:

Terminal window
sudo wget -O /usr/share/dns/root.hints https://www.internic.net/domain/named.root
sudo systemctl restart unbound

Root hints rarely change (maybe once every few years), so this isn’t urgent.

Terminal window
sudo apt update && sudo apt upgrade unbound
Terminal window
# View cache statistics
sudo unbound-control stats_noreset | grep -E "(total.num|cache)"
# Key metrics to watch:
# total.num.cachehits vs total.num.cachemiss
# A healthy setup should show >50% cache hits after running for a few hours

Pi-hole (bare metal): The Pi-hole Teleporter feature (Settings → Teleporter) exports all settings, adlists, whitelists, blacklists, and DNS records as a single .tar.gz. Schedule this or do it before updates.

Pi-hole (Docker): Back up the Docker volumes:

Terminal window
# Export volumes
docker run --rm -v pihole_data:/data -v $(pwd):/backup alpine tar czf /backup/pihole-backup.tar.gz -C /data .
# Or just use Pi-hole's built-in Teleporter from the web UI

Unbound: Just back up the config file. There’s no state to preserve — the cache is in memory and rebuilds on restart.

If you have multiple CPU cores and heavy DNS traffic:

server:
num-threads: 2 # Match to CPU cores (up to 4 for home use)
msg-cache-slabs: 2 # Must be a power of 2, same as num-threads
rrset-cache-slabs: 2
infra-cache-slabs: 2
key-cache-slabs: 2
Cache settingSizeNote
msg-cache-size50mTotal: ~200MB max. Actual usage is typically 30-50% of configured sizes. Fine for 512MB+ RAM.
rrset-cache-size100m
key-cache-size50m
neg-cache-size4m

If you’re running on a very low-memory device (256MB Pi Zero), halve all cache sizes. If you have 2GB+ RAM, you can double them for even better hit rates.

Setting cache-min-ttl too high (e.g., 3600) causes real problems:

  • CDNs like Cloudflare and Fastly use 60-second TTLs to route you to the nearest edge server. Caching for 1 hour can route you to a server on the wrong continent.
  • DNS-based failover systems (AWS Route53, Google Cloud DNS) rely on short TTLs to shift traffic during outages. Override their TTL and you keep hitting the dead server.
  • 300 (5 minutes) is the sweet spot — meaningfully reduces recursive lookups without breaking TTL-dependent systems.

This happens when Unbound tries to bind to port 53 (its default) and something else is already there. Fix: make sure your config has port: 5335. If using Debian’s default config with include-toplevel, another config file might be setting port 53. Check all files in /etc/unbound/unbound.conf.d/.

warning: so-rcvbuf 1048576 was not granted. Got 425984.

The kernel’s maximum socket buffer is too small. Fix:

Terminal window
sudo sysctl -w net.core.rmem_max=1048576
echo "net.core.rmem_max=1048576" | sudo tee -a /etc/sysctl.d/99-unbound.conf
sudo systemctl restart unbound

If some domains fail to resolve with SERVFAIL but work fine with a public DNS server, check:

  • System clock: DNSSEC is time-sensitive. If your clock is more than a few minutes off, signatures will appear invalid. Run timedatectl status and ensure NTP is active.
  • Pi-hole DNSSEC is enabled: This is the #1 cause. Disable DNSSEC in Pi-hole (Settings → DNS → uncheck “Use DNSSEC”). Let Unbound handle it.
  • Root trust anchor is stale: Run sudo unbound-anchor -a /var/lib/unbound/root.key and restart Unbound.

This is normal. Unbound’s cache is in memory and lost on restart. The first lookup for each domain requires a full recursive walk (root → TLD → authoritative). With prefetch and serve-expired enabled, the cache fills up quickly and subsequent lookups are instant. After 10-15 minutes of normal use, performance should be excellent.

Pi-hole shows “0 queries blocked” despite adlists loaded

Section titled “Pi-hole shows “0 queries blocked” despite adlists loaded”

Common causes:

  • Gravity hasn’t run yet: Run pihole -g to update blocklists.
  • Devices aren’t using Pi-hole: Check nslookup google.com on a client — the “Server” field should be your Pi-hole IP. If it’s 127.0.0.53 or your router’s IP, DHCP isn’t configured correctly.
  • Browser is using DoH: Firefox and Chrome can bypass your DNS with DNS-over-HTTPS. In Firefox: Settings → Privacy → DNS over HTTPS → Off. In Chrome: Settings → Privacy → Use secure DNS → Off (or point it at your Pi-hole if it supports DoH).

If Unbound is running but the healthcheck reports unhealthy:

  • Check that drill is available in the container image. The mvance/unbound image includes it. If using a different image, change the healthcheck to use dig or nslookup.
  • Check the container logs: docker logs unbound
  • Verify the config is mounted correctly: docker exec unbound cat /opt/unbound/etc/unbound/unbound.conf

”subnetcache: serve-expired is set but not working” warning

Section titled “”subnetcache: serve-expired is set but not working” warning”

This is harmless. It means the ECS (EDNS Client Subnet) module doesn’t support serve-expired. Since you’re not using ECS (it’s only relevant for CDN geo-routing in large ISP deployments), the warning has zero impact. Your main cache still uses serve-expired correctly.

Ensure both containers are on the same Docker network. In the compose file above, both use the dns network. Pi-hole reaches Unbound via the hostname unbound, not 127.0.0.1. If you changed the container name or network, update the upstream accordingly.

When a site breaks and you suspect Pi-hole:

  1. Open the Pi-hole dashboard → Query Log
  2. Find the blocked domain (red entries)
  3. Click the domain → “Whitelist” to allow it
  4. Or from CLI: pihole allow example.com

If you find a domain that’s clearly a false positive, consider reporting it to the blocklist maintainer (especially Hagezi, who actively maintains and curates their lists).