A validating, recursive, caching DNS resolver. Unbound resolves DNS queries by walking the DNS tree from the root servers down to the authoritative nameserver for each domain — no third-party DNS provider ever sees your queries.
Used in this homelab as the upstream recursive resolver for Pi-hole, replacing the default forwarders (8.8.8.8, 1.1.1.1) with a fully self-hosted DNS chain.
When Pi-hole forwards queries to 8.8.8.8 or 1.1.1.1, Google or Cloudflare sees every domain you look up. Unbound eliminates that:
- Queries go directly to authoritative DNS servers — no intermediary
- DNSSEC validation is performed locally, not trusted to a third party
- Reduces DNS round-trip time for frequently-queried domains (cache is local)
Client device
│
▼ port 53
Pi-hole ← blocks ads/trackers, caches, logs queries
│
▼ port 5335 (host) or port 53 (Docker dns network)
Unbound ← recursive resolver, DNSSEC validation
│
▼ port 53
Root DNS servers → TLD servers → Authoritative servers
| Setting | Value |
|---|---|
| Image | mvance/unbound:latest |
| Access | DNS only — no web UI |
| Host port | ${UNBOUND_PORT:-5335} (UDP + TCP) |
| Config | /mnt/SSD/Containers/unbound |
| Docker network | dns (bridge, created by this compose) |
Port 5335 is the convention from the official Pi-hole + Unbound guide. It avoids conflict with
systemd-resolvedwhich holds port 53 on most Linux hosts.
Create the config directory and set ownership before deploying:
mkdir -p /mnt/SSD/Containers/unbound
chown -R 1000:1000 /mnt/SSD/Containers/unbound
mvance/unboundruns as theunbounduser (UID 1000) inside the container. The bind-mounted directory must be owned by UID 1000 so Unbound can writevar/root.keyfor DNSSEC trust anchor management. Skippingchowncauses aPermission deniedcrash on startup.
cp example.env .env| Variable | Default | Description |
|---|---|---|
UNBOUND_PORT |
5335 |
Host port Unbound listens on |
The defaults work without changes unless port 5335 is already in use.
docker compose up -dQuery Unbound directly to confirm it's resolving:
dig cloudflare.com @<host-ip> -p 5335Expected: a valid A record response with status: NOERROR.
Test DNSSEC validation (this should return SERVFAIL — that's correct):
dig sigfail.verteiltesysteme.net @<host-ip> -p 5335A SERVFAIL response confirms Unbound is actively rejecting DNSSEC-invalid records.
Unbound replaces Pi-hole's upstream DNS. Two methods depending on how Pi-hole is deployed.
If Pi-hole runs as a Docker container, join it to the dns network so it can reach Unbound by container name without exposing a host port.
In Pi-hole's compose.yaml:
services:
pihole:
# ... existing config ...
networks:
- pihole # Pi-hole's own network (whatever you have)
- dns # shared with Unbound
networks:
pihole:
driver: bridge
dns:
external: true # created and owned by unbound/compose.yamlThen in Pi-hole's settings, set the upstream DNS server to:
Custom 1: unbound#53
Use the container name unbound — Docker's internal DNS resolves it to the right IP on the shared dns network.
Deploy order: Unbound must be running before Pi-hole starts, because Pi-hole tries to reach its upstream DNS at launch. Run
docker compose up -dinunbound/first, thenpihole/.
If Pi-hole is running as a TrueNAS Scale app or on a separate host, use the host port instead:
In Pi-hole's DNS settings, set the upstream DNS server to:
Custom 1: <truenas-ip>#5335
Replace <truenas-ip> with the IP address of your TrueNAS host (e.g. 10.0.1.50).
Do not use
127.0.0.1here — Pi-hole's container has its own loopback and cannot reach Unbound that way.
After configuring Unbound, remove all other upstream DNS servers in Pi-hole's settings (Cloudflare, Google, etc.). Leaving them enabled means some queries bypass Unbound entirely.
In Pi-hole → Settings → DNS:
- Uncheck all preset upstream servers
- Set only your Unbound address under "Custom DNS"
mvance/unbound ships with a well-tuned default configuration that handles:
- DNSSEC validation with auto-updated root hints
- Negative TTL caching (reduces repeated failed lookups)
- Prefetching (refreshes popular cache entries before they expire)
- Hardened security defaults (QNAME minimisation, refuse ANY queries)
To override or extend the configuration, add .conf files to the mounted volume directory. Unbound includes all .conf files from /opt/unbound/etc/unbound/:
# On the TrueNAS host
nano /mnt/SSD/Containers/unbound/custom.confExample: add a local DNS override so a hostname resolves to a specific IP
server:
local-data: "nas.home.arpa. A 10.0.1.50"
local-data-ptr: "10.0.1.50 nas.home.arpa."Restart Unbound after any config change:
docker compose restart unboundUnbound's default config refuses recursive lookups for RFC 1918 addresses (10.x, 192.168.x, 172.16-31.x) because those zones have no authoritative DNS on the public internet. This means reverse lookups for LAN IPs return NXDOMAIN.
To serve your own reverse DNS, add a local zone:
server:
local-zone: "0.10.in-addr.arpa." static
local-data-ptr: "10.0.1.1 router.home.arpa."
local-data-ptr: "10.0.1.50 nas.home.arpa."| Data | Path |
|---|---|
| Unbound config + root hints | /mnt/SSD/Containers/unbound |
docker compose pull
docker compose up -ddocker compose logs -f unbounddocker inspect --format='{{.State.Health.Status}}' unboundConfig is just files — back up the bind-mounted directory:
tar -czf unbound-backup-$(date +%Y%m%d).tar.gz /mnt/SSD/Containers/unboundQueries time out
Check that Unbound can reach the internet. It needs outbound UDP/TCP port 53 to query root and authoritative servers. If your firewall blocks outbound DNS, Unbound cannot resolve anything.
Pi-hole shows "upstream timeout" errors
Confirm the upstream address in Pi-hole matches the container name (unbound) or host IP + port. Check that both containers are on the dns network: docker network inspect dns.
DNSSEC failures for legitimate domains
Some ISPs or corporate firewalls tamper with DNS responses in ways that break DNSSEC. If you're seeing widespread SERVFAIL for domains that should resolve, check whether Unbound is behind a filtering firewall. You can temporarily disable DNSSEC validation in custom.conf with val-permissive-mode: yes to test.
dig returns old cached answers
Unbound caches aggressively. Force a fresh lookup with:
dig +nocache cloudflare.com @<host-ip> -p 5335