Pi-hole + Unbound
1. What This Does & Why You Want It
Section titled “1. What This Does & Why You Want It”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:
| Component | What it does | Why you want it |
|---|---|---|
| Pi-hole | DNS sinkhole — intercepts DNS queries and blocks requests to known ad, tracking, and malware domains | Network-wide ad blocking for every device. No browser extensions needed. Works on smart TVs, phones, IoT devices — anything that uses DNS. |
| Unbound | Recursive DNS resolver — resolves queries by talking directly to authoritative nameservers instead of forwarding to a third party | Complete 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.
2. How DNS Actually Works (Quick Primer)
Section titled “2. How DNS Actually Works (Quick Primer)”When you type www.example.com in your browser, here’s what happens with a recursive resolver like Unbound:
- Your device asks Pi-hole: “What’s the IP for
www.example.com?” - Pi-hole checks its blocklists. If the domain is blocked, it returns
0.0.0.0(sinkholed). If not, it forwards to Unbound. - Unbound checks its cache. If it has a fresh answer, it returns it immediately. If not, it starts the recursive process:
- Unbound asks a root nameserver: “Who handles
.com?” → The root server replies with the.comTLD nameservers. - Unbound asks the
.comTLD server: “Who handlesexample.com?” → It replies with the authoritative nameservers forexample.com. - Unbound asks the authoritative server: “What’s the IP for
www.example.com?” → It replies with the actual IP address. - 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 --| |3. Architecture Overview
Section titled “3. Architecture Overview” 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.comKey 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.
4. Prerequisites
Section titled “4. Prerequisites”For both methods
Section titled “For both methods”- 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)
For bare metal
Section titled “For bare metal”- Debian 12/13 or Ubuntu 22.04+ (other distros work but commands may differ)
- Root or sudo access
For Docker
Section titled “For Docker”- 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)”5.1 Install Unbound
Section titled “5.1 Install Unbound”Install Unbound first, since Pi-hole will be configured to use it as upstream.
sudo apt updatesudo apt install -y unbound dns-root-dataThe 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.
5.2 Disable systemd-resolved (if present)
Section titled “5.2 Disable systemd-resolved (if present)”On Ubuntu and some Debian installs, systemd-resolved occupies port 53. Check if it’s running:
sudo ss -tlnp | grep ':53 'If you see systemd-resolved listening on 53, disable it:
# Disable systemd-resolvedsudo systemctl disable --now systemd-resolved
# Point /etc/resolv.conf at localhost (Pi-hole will handle it)sudo rm /etc/resolv.confecho "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf5.3 Install Pi-hole
Section titled “5.3 Install Pi-hole”Run the official installer. It’s a single command:
curl -sSL https://install.pi-hole.net | bashThe 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:
pihole setpassword5.4 Configure Pi-hole to use Unbound
Section titled “5.4 Configure Pi-hole to use Unbound”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):
# Remove any existing upstreams and set Unboundpihole config dns.upstreams '["127.0.0.1#5335"]'6. Option B — Docker Install
Section titled “6. Option B — Docker Install”6.1 Directory structure
Section titled “6.1 Directory structure”sudo mkdir -p /opt/stacks/piholecd /opt/stacks/pihole6.2 Create Unbound config
Section titled “6.2 Create Unbound config”Create the Unbound config file before starting the containers. See Section 7 for an explanation of each setting — this is the full optimised config:
mkdir -p unboundserver: 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: 06.3 Docker Compose
Section titled “6.3 Docker Compose”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:6.4 Start the stack
Section titled “6.4 Start the stack”docker compose up -dCheck that both containers are healthy:
docker compose psYou should see both pihole and unbound with status Up. The Unbound container may take a few seconds to pass its health check.
6.5 Set the admin password
Section titled “6.5 Set the admin password”docker exec -it pihole pihole setpasswordThe web UI is now available at http://<your-ip>:8080/admin.
7. Unbound Configuration (Both Methods)
Section titled “7. Unbound Configuration (Both Methods)”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:
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: 1What each setting does
Section titled “What each setting does”| Setting | Value | Why |
|---|---|---|
interface | 127.0.0.1 | Only accept queries from localhost (Pi-hole). In Docker, use 0.0.0.0 since containers communicate over a bridge network. |
port | 5335 | Avoids conflict with Pi-hole on port 53. This is the standard convention for Pi-hole + Unbound setups. |
do-ip6 | no | Disable if your network doesn’t use IPv6. Prevents unnecessary AAAA lookups that will always fail. Enable if you have working IPv6. |
root-hints | path to file | Tells Unbound where the DNS root servers are. Without this, it relies on a compiled-in list that may become outdated. |
harden-glue | yes | Only trusts glue records if they are within the delegated domain. Prevents cache poisoning via rogue glue. |
harden-dnssec-stripped | yes | Requires DNSSEC data for trust-anchored zones. Prevents downgrade attacks that strip DNSSEC signatures. |
use-caps-for-id | yes | Randomises 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-size | 1232 | The recommended EDNS buffer size to avoid fragmentation with IPv6. The DNS Flag Day 2020 recommendation. |
prefetch | yes | When 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-threads | 1 | Match 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-rcvbuf | 1m | Socket receive buffer size. Helps handle burst traffic. Requires net.core.rmem_max ≥ 1048576 (see below). |
msg-cache-size | 50m | Cache for DNS message responses. Default is ~4MB, far too small for good hit rates. |
rrset-cache-size | 100m | Cache for individual DNS records. Should be roughly 2x msg-cache-size. |
key-cache-size | 50m | Cache for DNSSEC keys. Speeds up validation for frequently-visited domains. |
neg-cache-size | 4m | Cache for NXDOMAIN (non-existent domain) responses. Prevents repeated lookups for domains that don’t exist. |
serve-expired | yes | Returns 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-ttl | 86400 | How long (in seconds) an expired record can be served. 86400 = 1 day. After this, the stale record is discarded. |
serve-expired-reply-ttl | 30 | The TTL returned to the client for stale records. Low value ensures the client will ask again soon and get the fresh answer. |
cache-min-ttl | 300 | Minimum 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-ttl | 86400 | Maximum cache time. Caps records that set absurdly long TTLs. |
private-address | RFC 1918 ranges | Prevents 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:
# Check current valuesysctl net.core.rmem_max
# Set it (immediately)sudo sysctl -w net.core.rmem_max=1048576
# Persist across rebootsecho "net.core.rmem_max=1048576" | sudo tee -a /etc/sysctl.d/99-unbound.confValidate and restart Unbound
Section titled “Validate and restart Unbound”# Check config syntaxsudo unbound-checkconf
# Restart the servicesudo systemctl restart unbound
# Verify it's runningsudo systemctl status unboundTest Unbound directly
Section titled “Test Unbound directly”# Basic resolutiondig @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)8. Pi-hole Configuration
Section titled “8. Pi-hole Configuration”8.1 Set Unbound as the upstream
Section titled “8.1 Set Unbound as the upstream”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#5335For Docker, use the container name instead:
unbound#5335If you used the Docker compose from this guide, this is already set via the FTLCONF_dns_upstreams environment variable.
8.2 Recommended settings
Section titled “8.2 Recommended settings”| Setting | Recommended | Why |
|---|---|---|
| Interface listening behaviour | Listen on all interfaces, permit all origins | Required if Pi-hole serves DNS for other subnets (e.g., VLANs, VPN clients). For a single-subnet network, “Listen only on [interface]” is fine. |
| DNSSEC | Disabled in Pi-hole | Unbound 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 forwarding | Enable if you want local hostnames | If 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 limiting | Default (1000/min) | Prevents a single misbehaving client from overwhelming Pi-hole. The default is fine for home use. |
9. Recommended Adlists
Section titled “9. Recommended Adlists”Pi-hole ships with the StevenBlack hosts list, which is a solid baseline. Here’s what I recommend adding for comprehensive coverage:
Tier 1 — Core (start here)
Section titled “Tier 1 — Core (start here)”| List | What it covers | Domains |
|---|---|---|
| Hagezi Pro | Ads, 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 TIF | Threat 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.
Tier 2 — Extended (optional, recommended)
Section titled “Tier 2 — Extended (optional, recommended)”| List | What it covers | Domains |
|---|---|---|
| Hagezi DynDNS | Blocks dynamic DNS providers commonly abused by malware and phishing campaigns. | ~1.5k |
| Hagezi Spam TLDs | Blocks 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 Extended | Phishing-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 |
Adding lists in Pi-hole v6
Section titled “Adding lists in Pi-hole v6”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.
10. Point Your Network at Pi-hole
Section titled “10. Point Your Network at Pi-hole”Pi-hole only works if devices actually use it as their DNS server. There are two approaches:
Option 1: Router DHCP (recommended)
Section titled “Option 1: Router DHCP (recommended)”Change your router’s DHCP settings to hand out Pi-hole’s IP as the DNS server. This affects all devices automatically.
- Log into your router’s admin panel
- Find the DHCP settings (often under LAN or Network)
- Set the Primary DNS to your Pi-hole’s IP (e.g.,
192.168.1.80) - Remove or leave blank the Secondary DNS
- Save and reboot the router
Option 2: Per-device
Section titled “Option 2: Per-device”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.confor your NetworkManager connection - iOS/Android: Wi-Fi network settings → Configure DNS → Manual
Devices that need a DHCP renewal
Section titled “Devices that need a DHCP renewal”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
11. Testing & Verification
Section titled “11. Testing & Verification”11.1 Verify Pi-hole is answering queries
Section titled “11.1 Verify Pi-hole is answering queries”# From any device using Pi-hole as DNSnslookup google.com# Should return an IP, with "Server: 192.168.1.80" (your Pi-hole IP)
# Or with digdig @192.168.1.80 google.com +short# Should return Google's IP11.2 Verify ad blocking works
Section titled “11.2 Verify ad blocking works”# 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 in11.3 Verify Unbound is resolving recursively
Section titled “11.3 Verify Unbound is resolving recursively”# Check that Unbound is doing recursive resolution, not forwardingdig @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 workingdig @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)11.4 Check the Pi-hole dashboard
Section titled “11.4 Check the Pi-hole dashboard”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#5335orunbound#5335
12. Maintenance & Updates
Section titled “12. Maintenance & Updates”Updating Pi-hole
Section titled “Updating Pi-hole”Bare metal:
# Check for updatespihole -up --check-only
# Apply updatepihole -upDocker:
cd /opt/stacks/piholedocker compose pulldocker compose up -dUpdating gravity (blocklists)
Section titled “Updating gravity (blocklists)”Gravity updates automatically via a cron job (typically daily at a random time). To force an update:
pihole -g# or in Docker:docker exec pihole pihole -gUpdating root hints (bare metal)
Section titled “Updating root hints (bare metal)”The dns-root-data package handles this via apt updates. If you want to update manually:
sudo wget -O /usr/share/dns/root.hints https://www.internic.net/domain/named.rootsudo systemctl restart unboundRoot hints rarely change (maybe once every few years), so this isn’t urgent.
Updating Unbound (bare metal)
Section titled “Updating Unbound (bare metal)”sudo apt update && sudo apt upgrade unboundChecking Unbound cache performance
Section titled “Checking Unbound cache performance”# View cache statisticssudo 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 hoursBackups
Section titled “Backups”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:
# Export volumesdocker 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 UIUnbound: Just back up the config file. There’s no state to preserve — the cache is in memory and rebuilds on restart.
13. Performance Tuning
Section titled “13. Performance Tuning”Scaling Unbound threads
Section titled “Scaling Unbound threads”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: 2Memory usage estimates
Section titled “Memory usage estimates”| Cache setting | Size | Note |
|---|---|---|
| msg-cache-size | 50m | Total: ~200MB max. Actual usage is typically 30-50% of configured sizes. Fine for 512MB+ RAM. |
| rrset-cache-size | 100m | |
| key-cache-size | 50m | |
| neg-cache-size | 4m |
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.
Why cache-min-ttl matters
Section titled “Why cache-min-ttl matters”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.
14. Gotchas & Troubleshooting
Section titled “14. Gotchas & Troubleshooting”Unbound won’t start — port 53 in use
Section titled “Unbound won’t start — port 53 in use”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/.
so-rcvbuf warning in Unbound logs
Section titled “so-rcvbuf warning in Unbound logs”warning: so-rcvbuf 1048576 was not granted. Got 425984.The kernel’s maximum socket buffer is too small. Fix:
sudo sysctl -w net.core.rmem_max=1048576echo "net.core.rmem_max=1048576" | sudo tee -a /etc/sysctl.d/99-unbound.confsudo systemctl restart unboundDNSSEC failures on random domains
Section titled “DNSSEC failures on random domains”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 statusand 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.keyand restart Unbound.
Slow first lookups after reboot
Section titled “Slow first lookups after reboot”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 -gto update blocklists. - Devices aren’t using Pi-hole: Check
nslookup google.comon a client — the “Server” field should be your Pi-hole IP. If it’s127.0.0.53or 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).
Docker: Unbound healthcheck fails
Section titled “Docker: Unbound healthcheck fails”If Unbound is running but the healthcheck reports unhealthy:
- Check that
drillis available in the container image. Themvance/unboundimage includes it. If using a different image, change the healthcheck to usedigornslookup. - 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.
Docker: Pi-hole can’t reach Unbound
Section titled “Docker: Pi-hole can’t reach Unbound”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.
False positives (legitimate site blocked)
Section titled “False positives (legitimate site blocked)”When a site breaks and you suspect Pi-hole:
- Open the Pi-hole dashboard → Query Log
- Find the blocked domain (red entries)
- Click the domain → “Whitelist” to allow it
- 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).