Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, or root-hints — these are managed internally by the image. The 172.16.0.0/12 access-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/unbound as used by other images.

Important: If Pi-hole uses host networking instead of the dns bridge network, remove the networks section from both the service and the bottom of the file, and ensure access-control: 172.16.0.0/12 allow is 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

SymptomCauseFix
Container exits immediatelyConfig syntax errorCheck docker logs unbound; fix syntax in unbound.conf
REFUSED responsesIP not in access-controlAdd your subnet to access-control in config
Slow browsing on first visitCold cache recursive lookupNormal — warms up with use; serve-expired helps
Very slow YouTube/streamingDynamic CDN subdomainsAdd forward zones for googlevideo.com etc.
Pi-hole not forwarding to UnboundWrong upstream IP or portVerify upstream is set to correct IP/port in Pi-hole; check access-control includes Docker subnet
docker exec commands failDistroless image — no shellUse dig from the host instead
Container shows unhealthyBuilt-in healthcheck uses internal toolingAdd healthcheck: disable: true to compose