Expert knowledge for Pi-hole and Unbound DNS operations. Use when configuring DNS, troubleshooting resolution issues, modifying adlists, or understanding the DNS data flow.
┌─ Pi-hole (pi-k3s:53) ──── Unbound (pi-k3s:5335) ──── Root Servers
User Device ───┤
└─ Pi-hole-secondary (pi5-worker-1:53) ──── Unbound-secondary (pi5-worker-1:5335) ──── Root Servers
Both paths are independent. Clients may use either Pi-hole instance via DHCP-assigned DNS.
Why Unbound? Full recursive resolution directly to authoritative DNS servers. Better privacy (no single upstream sees all queries), no third-party trust required, DNSSEC validation.
pihole/pihole:latest with hostNetwork: truepi-k3s (192.168.1.55)pi5-worker-1 (192.168.1.56)madnuttah/unbound:latest (distroless, minimal — no cat/ls/head)Upstream transport — do NOT set
tcp-upstream: yes. It is not a "fall back to TCP if UDP fails" toggle (that fallback is automatic on truncated UDP) — it forces all upstream recursion over TCP, always. We ran it Dec 2025 → Jun 2026 under that misconception (added in17b857fas generic "home-network resilience"; the AT&T/IPv6 slowness was a separate gateway-side fix — UniFi IPv6 prefix delegation, nothing to do with DNS transport). It caused recurringDNSMASQ_WARN"max concurrent queries (150)" on both Pi-holes at the same instant, plusCONNECTION_ERRORpremature TCP drops to Unbound (the Jan 2026 TCP-pool bump4ed431fonly masked the symptom). Removed 2026-06-12. UDP-first + automatic TCP fallback + the defaultedns-buffer-size: 1232is the correct fragmentation-safe setup. If UDP ever is proven broken, fix EDNS buffer sizing — don't force TCP.
Pi-hole v6 ignores most environment variables. Configuration is done via REST API in postStart hook:
POST /api/auth - Get session IDPATCH /api/config - Set upstream DNS to Unbound ClusterIPPOST /api/lists - Add adlists from ConfigMap (batch format)POST /api/action/gravity - Update gravity databaseReference: docs/pihole-v6-api.md
The madnuttah/unbound image rewrites the main unbound.conf during its entrypoint. It transforms paths and drops directives it doesn't recognize.
/usr/local/unbound/unbound.conf (NOT /opt/unbound/etc/unbound/unbound.conf)/opt/unbound/etc/unbound/unbound.conf → entrypoint processes this into the actual configdomain-insecure and other less common server directives are silently dropped/usr/local/unbound/conf.d/*.conf — mount custom server directives HERE as separate fileskubectl exec -n pihole deploy/unbound -- unbound-checkconf -o <directive>To add custom server directives (e.g., domain-insecure):
dnssec-exceptions.conf)/usr/local/unbound/conf.d/<name>.conf using subPathunbound.conf — it will be silently droppedUnbound validates DNSSEC via:
auto-trust-anchor-file — root trust anchorharden-dnssec-stripped: yes — strict mode, rejects responses that strip DNSSECWhen a domain has broken DNSSEC (DS records published but no valid DNSKEY):
validation failure <domain>: no keys have a DS with algorithm RSASHA256domain-insecure: "domain.com" via conf.d mount (see above)Unbound serve-expired configuration:
serve-expired: yes with serve-expired-ttl: 86400 (24 hours)Pi-hole also caches results independently.
Combined effect: test_dns_query (which runs dig inside Pi-hole) can return a successful cached result even when Unbound is actively returning SERVFAIL. A successful test_dns_query does NOT prove the resolution path is healthy. Always use diagnose_dns for troubleshooting.
The Pi node uses static DNS (1.1.1.1, 8.8.8.8) configured via NetworkManager.
AT&T Fiber has poor IPv6 routing to some CDNs. We selectively block IPv6 for affected domains.
Current blocked domains: See clusters/pi-k3s/pihole/pihole-custom-dns.yaml
When a user reports slow/broken connectivity to a service, DO NOT immediately add it to the IPv6 block list. First verify IPv6 is the cause:
# 1. Check if domain returns AAAA records (if no AAAA, IPv6 isn't the issue)
dig AAAA <domain>
# 2. Compare IPv4 vs IPv6 response times from a device on the network
curl -4 -w "IPv4: %{time_total}s\n" -o /dev/null -s https://<domain>
curl -6 -w "IPv6: %{time_total}s\n" -o /dev/null -s https://<domain>
# 3. If IPv6 is significantly slower (2x+) or times out, add to block list
Only add to the block list after confirming IPv6 is the problem. See docs/known-issues.md for details.
When a user reports a domain is unreachable, follow ALL steps in order. Do NOT stop after step 1 even if it shows success. Do NOT blame the client's browser or machine until the entire server path is proven clean.
diagnose_dns MCP Tool (ALWAYS START HERE)Use diagnose_dns with the reported domain. This single tool tests:
If diagnose_dns is unavailable, manually run all substeps:
test_dns_query → check Unbound logs (BOTH pods) → dig @unbound directly
| Pi-hole | Unbound Primary | Unbound Secondary | Diagnosis |
|---|---|---|---|
| OK | OK | OK | Resolution path is healthy. Issue is client-side. |
| OK | FAIL | FAIL | Stale cache masking upstream failure. Check Unbound logs immediately. |
| OK | OK | FAIL | Secondary Unbound is broken. Client may be using secondary. |
| FAIL | FAIL | FAIL | Complete DNS failure. Check pod health, network connectivity. |
| OK (cached) | FAIL + resolves with +cd | FAIL + resolves with +cd | DNSSEC validation failure. Domain has broken DNSSEC. |
Use get_pod_logs for BOTH Unbound pods:
namespace: pihole, pod: unboundnamespace: pihole, pod: unbound-secondaryLook for:
validation failure — DNSSEC issueSERVFAIL — upstream failureconnection timed out — network issueTCP connection failed — TCP upstream issue (common on Pi 3)| Root Cause | Fix |
|---|---|
| DNSSEC validation failure | Add domain-insecure via conf.d ConfigMap mount |
| Unbound timeout/crash | Restart Unbound deployment, check node health |
| Pi-hole not forwarding | Check Pi-hole upstream DNS config via API |
| Network issue | Check node connectivity to root servers |
hostNetwork is true.postStart hook logs in Pi-hole pod.test_dns_query shows success but client fails: Stale cache. Use diagnose_dns instead.All local DNS records live in clusters/pi-k3s/pihole/pihole-custom-dns.yaml (ConfigMap). This ConfigMap is mounted by both pihole and pihole-secondary; Flux applies it to both instances simultaneously.
Current layout:
*.lab.mtgibbs.dev → 192.168.1.55 (ingress controller)DO NOT add records via the Pi-hole web UI for anything cluster-managed — they will drift from GitOps and will not survive a pod restart. Always edit the ConfigMap and let Flux reconcile.
The pi-k3s master node is configured with public DNS (1.1.1.1, 8.8.8.8) via NetworkManager. Worker nodes use Pi-hole.
Why: Bootstrap resilience. Allows pi-k3s to pull container images (including Pi-hole itself) even when the cluster DNS is down. This is intentional. Do not change it.
Side effect: pi-k3s does not resolve Pi-hole local overrides. Pods scheduled on pi-k3s that need a *.lab.mtgibbs.dev hostname get the public wildcard (192.168.1.55) instead of any Pi-hole-specific override. kubelet uses the host's resolv.conf, not CoreDNS.
Workaround for pi-k3s-local DNS names: Add /etc/hosts entries on the pi-k3s host for any local-only name that pods on that node need. These are managed under Flux at clusters/pi-k3s/coredns-custom/pi-k3s-hosts-overrides.yaml (a ConfigMap mounted by a DaemonSet or postStart hook — verify current implementation in the manifest).
Currently overridden: storage.lab.mtgibbs.dev → 192.168.1.61.
Full notes in memory/pi-k3s-dns-fallback.md.
CoreDNS default config forwards ALL queries (including lab.mtgibbs.dev) to 1.1.1.1/8.8.8.8. The lab.mtgibbs.dev zone has a public wildcard record — so without a local override, all *.lab.mtgibbs.dev in-cluster DNS resolves to the public wildcard answer rather than the Pi-hole local records.
Fix: A lab.mtgibbs.dev.server block in the coredns-custom ConfigMap forwards local-domain queries to Pi-hole. This is under Flux at clusters/pi-k3s/coredns-custom/. Do NOT remove or edit it via kubectl.
If this block is missing, all *.lab.mtgibbs.dev in-cluster DNS breaks silently.
Full notes in memory/coredns-forward-gotcha.md.
clusters/pi-k3s/pihole/pihole-deployment.yaml — Primary Pi-holeclusters/pi-k3s/pihole/pihole-secondary-deployment.yaml — Secondary Pi-holeclusters/pi-k3s/pihole/unbound-deployment.yaml — Primary Unbound + Serviceclusters/pi-k3s/pihole/unbound-secondary-deployment.yaml — Secondary Unbound + Serviceclusters/pi-k3s/pihole/unbound-configmap.yaml — Unbound config (shared by both, includes conf.d entries)clusters/pi-k3s/pihole/pihole-custom-dns.yaml — Local DNS + IPv6 overridesclusters/pi-k3s/coredns-custom/ — CoreDNS lab.mtgibbs.dev forwarding + pi-k3s host overrides