Nick Booth

Pihole With Unbound

Setting up an ad blocking DNS server using PiHole and unbound

Published: Jan 25, 2026 - 6 minute read / Last Updated: Jan 25, 2026

I have been running PiHole as an adblocking DNS server for quite a few years. It lives happily in a Podman container on my Tailnet, and runs DNS for the vast majority of my devices and services.

By default, PiHole uses a forwarding DNS service (FTL) - meaning that it checks it’s own internal cache, and if nothing is found it will forward the request to a separate (authoritative) DNS server. In my previous brilliance I had thankfully decided to use the privacy-focused DNS servers run by Quad9, but that is really just outsourcing trust to a third party. I had intended to set up unbound as a recursive DNS server, but apparently that was a project that had not yet seen the light of day. Today was a snow day, so I decided to get it going. I’m certain that I will need the documentation again in the future, so I’m tossing it here for my own reference - and who knows, maybe someone else will pretend to benefit.

Docker-Compose

I use Podman compose via docker-compose.yaml files to manage a bunch of my services, so step one is to get the core file set up

services: 
  pihole:                                               # Main PiHole image
    container_name: pihole
    image: pihole/pihole:latest
    environment:
      TZ: 'America/New_York'                            # Set to your timezone or leave blank for UTC 
      PIHOLE_DNS_: unbound                              # Use unbound as the DNS server 
      DNSSEC: 'true'
      VIRTUAL_HOST: 'pihole'
      WEBTHEME: 'default-dark'
      DNSMASQ_LISTENING: 'all'
      SKIPGRAVITYONBOOT: 'true'

    volumes:
      - './volumes/pihole/etc..pihole:/etc/pihole'
      - './volumes/pihole/etc..dnsmasq.d:/etc/dnsmasq.d'
    cap_add:
      - NET_ADMIN
    restart: unless-stopped
    network_mode: service:pihole-tailscale              # Use the Tailscale container as network namespace manager

  unbound:                                              # Unbound DNS image 
    image: klutchell/unbound                            # klutchell/unbound is an unofficial distroless container for unbound
    network_mode: service:pihole-tailscale              # Use the Tailscale container as network namespace manager
    healthcheck:
      test: ['CMD', 'drill-hc', '@127.0.0.1', 'dnssec.works']
      interval: 30s
      timeout: 30s
      retries: 3
      start_period: 30s
    volumes:
      - ./unbound_config/:/etc/unbound/custom.conf.d:ro # Mount config file directory as read only 
    restart: unless-stopped

  unbound-redis:                                        # REDIS cache for unbound 
    image: redis:latest
    container_name: redis
    hostname: redis
    network_mode: service:pihole-tailscale              # Use the Tailscale container as network namespace manager
    restart: unless-stopped
    volumes:
      - ./redis:/data
    healthcheck:
      test: "[ $$(redis-cli ping) = 'PONG' ]"
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 30s

  pihole-tailscale:                                     # Tailscale container with reverse proxy and network services 
    image: tailscale/tailscale:latest
    container_name: pihole-tailscale
    hostname: pihole
    environment:
      - TS_AUTHKEY=<obfuscated>
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_USERSPACE=false
      - TS_HOSTNAME=pihole
    volumes:
      - ./volumes/pihole-tailscale/var..lib..tailscale:/var/lib/tailscale
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - net_admin
      - sys_module
      - SYS_TIME
    restart: unless-stopped

Configure unbound

In the unbound_config folder, create a file called ‘custom.conf’. This will enable the unbound server on port 5335, listening on the network localhost. Verbosity defaults to 1 (errors only). Including cachedb will enable a Redis cache to help enable faster lookups.

server:
    module-config: "validator cachedb iterator"
    interface: 127.0.0.1
    port: 5335
    do-ip4: yes
    do-udp: yes
    do-tcp: yes
    
    # Performance and privacy settings
    hide-identity: yes
    hide-version: yes
    minimal-responses: yes
    prefetch: yes


    # Logging
    verbosity: 1

cachedb:
  backend: "redis"
  redis-server-host: 127.0.0.1
  redis-server-port: 6379
  redis-expire-records: yes

Settings changes

Once you bring up the compose stack, navigate to the PiHole dashboard and verify the following settings

Settings -> DNS

Upstream DNS Servers

Disable all upstream DNS providers.

Custom DNS Servers

Add 127.0.0.1#5335 to the first line of this text box to use your unbound server. You can add additional upstream DNS providers as desired; FTL will use them in descending order.

Interface Settings

For this setup, we can safely select Permit all origins, as the PiHole container is only listening to the Tailscale network interface.

Advanced DNS settings

  • Never forward non-FQDN queries: true
  • Never forward reverse lookups for private IP ranges: true
  • Use DNSSEC: true

Tailscale

Account settings

We did not cover using ACLs and tags to add container sidecars to your Tailnet, but assuming you did so correctly you should see the pihole container listed among your Tailscale machines. Grab the Tailnet IP address (should be in the 100.0.0.0 range) and head to the DNS tab. Under Nameservers there is a toggle switch to allow Tailscale to override client DNS resolution settings. Click it to enabled, and add the IP address of your new PiHole server.

Client settings

On each client ensure that Tailscale is set up to accept DNS. For Linux clients you can use the following:

~
❯ tailscale set --accept-dns

~
❯ tailscale dns status

=== 'Use Tailscale DNS' status ===

Tailscale DNS: enabled.

Tailscale is configured to handle DNS queries on this device.
Run 'tailscale set --accept-dns=false' to revert to your system default DNS resolver.

=== MagicDNS configuration ===

This is the DNS configuration provided by the coordination server to this device.

MagicDNS: enabled tailnet-wide (suffix = <redacted>.ts.net)

Other devices in your tailnet can reach this device at nixlap.<redacted>.ts.net.

Resolvers (in preference order):
  - 100.74.175.8

Split DNS Routes:
  - ts.net.                        -> 199.247.155.53
  - ts.net.                        -> 2620:111:8007::53

Search Domains:
  - <redacted>.ts.net

=== System DNS configuration ===

This is the DNS configuration that Tailscale believes your operating system is using.
Tailscale may use this configuration if 'Override Local DNS' is disabled in the admin console,
or if no resolvers are provided by the coordination server.

  (reading the system DNS configuration is not supported on this platform)

[this is a preliminary version of this command; the output format may change in the future]

~

Validation

That’s it - you should be able to go to the PiHole dashboard and see DNS queries from any of your enabled devices. If you set unbound verbosity to anything higher than 1, you can run the following to see the results of queries:

nick@gondor:~/services/pihole$ podman compose logs unbound --tail 25

unbound-1  | [1769375428] unbound[1:0] debug: cache memory msg=72299 rrset=123994 infra=25576 val=73911 subnet=0
unbound-1  | [1769375428] unbound[1:0] debug: iterator[module 2] operate: extstate:module_wait_reply event:module_event_noreply
unbound-1  | [1769375428] unbound[1:0] info: iterator operate: query connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] info: processQueryTargets: connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] info: sending query: connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] debug: sending to target: <ubuntu.com.> 2620:2d:4000:1::44#53
unbound-1  | [1769375428] unbound[1:0] debug: cache memory msg=72299 rrset=123994 infra=25576 val=73911 subnet=0
unbound-1  | [1769375428] unbound[1:0] debug: iterator[module 2] operate: extstate:module_wait_reply event:module_event_noreply
unbound-1  | [1769375428] unbound[1:0] info: iterator operate: query connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] info: processQueryTargets: connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] info: sending query: connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] debug: sending to target: <ubuntu.com.> 2620:2d:4000:1::44#53
unbound-1  | [1769375428] unbound[1:0] debug: cache memory msg=72299 rrset=123994 infra=25576 val=73911 subnet=0
unbound-1  | [1769375428] unbound[1:0] debug: iterator[module 2] operate: extstate:module_wait_reply event:module_event_noreply
unbound-1  | [1769375428] unbound[1:0] info: iterator operate: query connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] info: processQueryTargets: connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] info: sending query: connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] debug: sending to target: <ubuntu.com.> 2620:2d:4000:1::43#53
unbound-1  | [1769375428] unbound[1:0] debug: cache memory msg=72299 rrset=123994 infra=25576 val=73911 subnet=0
unbound-1  | [1769375428] unbound[1:0] debug: iterator[module 2] operate: extstate:module_wait_reply event:module_event_noreply
unbound-1  | [1769375428] unbound[1:0] info: iterator operate: query connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] info: processQueryTargets: connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] info: sending query: connectivity-check.ubuntu.com. AAAA IN
unbound-1  | [1769375428] unbound[1:0] debug: sending to target: <ubuntu.com.> 185.125.190.66#53
unbound-1  | [1769375428] unbound[1:0] debug: cache memory msg=72299 rrset=123994 infra=25576 val=73911 subnet=0
nick@gondor:~/services/pihole$

Because of the way caching and recursive DNS work, your initial queries about a given TLD may take up to 1 second (though rarely); subsequent requests will be served in < 0.5ns.