Unbound Setup Tutorial
A step-by-step guide to setting up Unbound as a local recursive DNS resolver alongside Pi-hole, using Docker and Dockge.
What is Unbound?
Unbound is a validating, recursive, caching DNS resolver. Instead of forwarding your DNS queries to a third-party provider like Google or Cloudflare, it resolves them itself by walking the DNS tree from root servers. This gives you:
- Privacy — no single DNS provider sees all your queries
- DNSSEC validation — cryptographic verification of DNS responses
- Performance — fast cached lookups after the first query
The recommended setup is to run Unbound alongside Pi-hole:
Device → Pi-hole (ad blocking) → Unbound (recursive resolution) → Root servers
Image
This guide uses klutchell/unbound, which supports both amd64 and arm64 (Raspberry Pi). It is a distroless image — no shell or package manager is included, so docker exec with shell commands will not work. Use dig from the host for debugging instead.
The image manages root.key, root.hints, and DNSSEC trust anchor bootstrapping internally. You only need to provide a custom config file with your overrides.
Prerequisites
- Docker and Dockge installed
- Pi-hole already running (see Pi-hole setup guide)
- A shared Docker network called
dns(created during Pi-hole setup) — only needed if Pi-hole uses bridge networking
1. Create the Config Directory
mkdir -p /path/to/configs/unbound
2. Create unbound.conf
Create /path/to/configs/unbound/unbound.conf:
server:
# Network
interface: 0.0.0.0@5335
do-ip4: yes
do-ip6: no
do-udp: yes
do-tcp: yes
do-daemonize: no
# Performance
serve-expired: yes
serve-expired-ttl: 604800
serve-expired-ttl-reset: yes
prefetch: yes
prefetch-key: yes
so-reuseport: yes
cache-min-ttl: 300
cache-max-ttl: 86400
# Security
harden-glue: yes
harden-dnssec-stripped: yes
harden-algo-downgrade: yes
harden-below-nxdomain: yes
harden-large-queries: yes
harden-short-bufsize: yes
hide-identity: yes
hide-version: yes
deny-any: yes
aggressive-nsec: yes
# Privacy
use-caps-for-id: yes
qname-minimisation: yes
# Access control
access-control: 127.0.0.1/32 allow
access-control: 192.168.50.0/24 allow # adjust to your LAN subnet
access-control: 172.16.0.0/12 allow # Docker network range
# Logging (set verbosity to 0 in production)
logfile: ""
verbosity: 0
remote-control:
control-enable: no
# Forward zones for CDN domains with dynamic subdomains
# These are forwarded to fast upstream resolvers instead of recursive resolution
forward-zone:
name: "googlevideo.com"
forward-addr: 8.8.8.8
forward-addr: 8.8.4.4
forward-zone:
name: "googleapis.com"
forward-addr: 8.8.8.8
forward-addr: 8.8.4.4
forward-zone:
name: "gstatic.com"
forward-addr: 8.8.8.8
forward-addr: 8.8.4.4
forward-zone:
name: "microsoft.com"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "office.com"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "office365.com"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "akamaiedge.net"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "akamaized.net"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "cloudfront.net"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
forward-zone:
name: "fastly.net"
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
Note: Do not include
username,directory,auto-trust-anchor-file, orroot-hints— these are managed internally by the image. The172.16.0.0/12access-control entry is required for Pi-hole to reach Unbound over the Docker bridge network.
3. Create the Compose Stack
In Dockge, create a new stack called unbound with the following compose.yaml:
services:
unbound:
image: klutchell/unbound:latest
container_name: unbound
restart: unless-stopped
ports:
- "5335:5335/udp"
- "5335:5335/tcp"
volumes:
- /path/to/configs/unbound:/etc/unbound/custom.conf.d
healthcheck:
disable: true
networks:
- dns
networks:
dns:
external: true
name: dns
Note: The config volume mounts into
/etc/unbound/custom.conf.d, not/opt/unbound/etc/unboundas used by other images.
Important: If Pi-hole uses host networking instead of the
dnsbridge network, remove thenetworkssection from both the service and the bottom of the file, and ensureaccess-control: 172.16.0.0/12 allowis still present for any other Docker-networked clients.
4. Configure Pi-hole Upstream
In Pi-hole’s settings, set the custom upstream DNS to:
- If Pi-hole uses bridge networking (dns network): use the Unbound container’s IP on the dns network, port 5335
- If Pi-hole uses host networking: use
127.0.0.1#5335
In pihole.toml or via the Pi-hole web UI, this looks like:
127.0.0.1#5335
5. Verify Unbound is Working
Test basic resolution:
dig @<unbound-host-ip> -p 5335 google.com
Test caching (second query should be ~0ms):
dig @<unbound-host-ip> -p 5335 google.com
dig @<unbound-host-ip> -p 5335 google.com
Test DNSSEC validation:
# Should return NOERROR with 'ad' flag
dig @<unbound-host-ip> -p 5335 sigok.verteiltesysteme.net
# Should return SERVFAIL (invalid signature rejected)
dig @<unbound-host-ip> -p 5335 sigfail.verteiltesysteme.net
Test full chain through Pi-hole:
dig @<pihole-ip> google.com
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Container exits immediately | Config syntax error | Check docker logs unbound; fix syntax in unbound.conf |
REFUSED responses | IP not in access-control | Add your subnet to access-control in config |
| Slow browsing on first visit | Cold cache recursive lookup | Normal — warms up with use; serve-expired helps |
| Very slow YouTube/streaming | Dynamic CDN subdomains | Add forward zones for googlevideo.com etc. |
| Pi-hole not forwarding to Unbound | Wrong upstream IP or port | Verify upstream is set to correct IP/port in Pi-hole; check access-control includes Docker subnet |
docker exec commands fail | Distroless image — no shell | Use dig from the host instead |
| Container shows unhealthy | Built-in healthcheck uses internal tooling | Add healthcheck: disable: true to compose |