Tutorials

Pi-hole + Unbound: Going Deeper

Deploying Chapter 10's DNS stack step by step, proving Unbound is really doing the resolving, one record for every subdomain, and the IPv6 leak that quietly bypasses it.

intermediate ⏱ 60 min Be Your Own Cloud · Chapter 10 pi-holeunbounddns

Chapter 10 of Be Your Own Cloud builds Pi-hole and Unbound in Docker and explains why each line of the compose file is there. This page is the hands-on companion: the deploy in order, how to prove it’s working, and the things the chapter deliberately leaves out.

It assumes Chapters 5–9 are done: Ubuntu Server on a static IP (192.168.1.50 in the book’s examples), Docker installed, and the /opt/stacks layout.

Deploy it, in order

Make the folder and the password file first. The admin password comes from .env, as Chapter 9 sets up:

mkdir -p /opt/stacks/pihole
cd /opt/stacks/pihole
echo "PIHOLE_PASSWORD=$(openssl rand -base64 48)" > .env
chmod 600 .env
cat .env        # copy this value into your password manager now

Then the compose file, exactly as the book prints it:

File /opt/stacks/pihole/docker-compose.yml

Needs PIHOLE_PASSWORD in /opt/stacks/pihole/.env.

services:
  unbound:
    image: klutchell/unbound:1.26.1
    container_name: unbound
    restart: unless-stopped
    networks:
      dns:
        ipv4_address: 172.30.0.2
  pihole:
    image: pihole/pihole:2026.09.0
    container_name: pihole
    restart: unless-stopped
    depends_on:
      - unbound
    environment:
      - TZ=Your/Timezone
      # web admin login, from .env
      - FTLCONF_webserver_api_password=${PIHOLE_PASSWORD}
      # Unbound's fixed address, below
      - FTLCONF_dns_upstreams=172.30.0.2#53
      # answer the LAN and tailnet, not just Docker's own network
      - FTLCONF_dns_listeningMode=all
    volumes:
      - ./etc-pihole:/etc/pihole
    ports:
      - '53:53/tcp'
      - '53:53/udp'
      - '8081:80'   # web admin
    networks:
      dns:
        ipv4_address: 172.30.0.3

networks:
  dns:
    ipam:
      config:
        - subnet: 172.30.0.0/24
Exactly as printed in Be Your Own Cloud, Chapter 10 , p. 46 · all of Chapter 10's code

Before the first start, free up port 53. Ubuntu’s own resolver is sitting on it:

Run Terminal, on the server — before the first docker compose up -d
sudo sed -i 's/^#\?DNSStubListener=.*/DNSStubListener=no/' \
  /etc/systemd/resolved.conf
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
sudo systemctl restart systemd-resolved
Exactly as printed in Be Your Own Cloud, Chapter 10 , p. 47 · all of Chapter 10's code

Then start it and watch the log until it settles:

docker compose up -d
docker compose logs -f pihole      # Ctrl-C stops following, not the container

The admin page is at http://192.168.1.50:8081/admin.

Prove it’s working

From another machine on your network — not the server — ask Pi-hole directly:

# Linux or macOS
dig @192.168.1.50 example.com +short

# Windows
nslookup example.com 192.168.1.50

A real address back means Pi-hole is answering. To see blocking, pick any domain the Query Log marks as blocked and ask for it the same way: the answer should be 0.0.0.0. No answer at all usually means FTLCONF_dns_listeningMode=all is missing, or port 53 is still held by systemd-resolved (sudo ss -tulpn | grep :53 on the server).

To confirm Unbound is doing the resolving — not some public resolver — open Query Log in the admin page. Queries Pi-hole had to look up (rather than answer from its cache or block) show where they were forwarded, and every one should say 172.30.0.2#53 — Unbound’s fixed address from the compose file.

Point the router at the server, not the container

When Chapter 10 has you set the router’s DHCP DNS server, the address to enter is the server’s LAN IP — 192.168.1.50 in the book’s examples. The 172.30.0.x addresses in the compose file only exist inside Docker; nothing else on your network can reach them.

Then renew each device’s lease (or just reconnect to Wi-Fi) and check it took. On a laptop, nslookup example.com with no server named should report 192.168.1.50 as the server.

One record for every subdomain

Chapter 8 adds a local DNS record per subdomain under Settings → Local DNS Records:

Web UI Pi-hole → Settings → Local DNS Records

One record per subdomain, all pointing at the server. Added once Chapter 10's Pi-hole is running.

vault.home.yourdomain.com  ->  192.168.1.50
nextcloud.home.yourdomain.com  ->  192.168.1.50
# Same IP for all of them -- Nginx Proxy Manager on that one machine
# is what routes each subdomain to the right container behind it.
Exactly as printed in Be Your Own Cloud, Chapter 8 , p. 38 · all of Chapter 8's code

That works, and it’s what the book does. Once you’re past a handful of services, one wildcard entry saves you coming back here for every new one. In the admin page, switch the settings view to Expert, go to Settings → All settings → Miscellaneous, and add two lines to misc.dnsmasq_lines:

address=/home.yourdomain.com/192.168.1.50
local=/home.yourdomain.com/

The first answers anything.home.yourdomain.com with the server’s address. The second keeps the whole zone local, so a device asking for an IPv6 address gets a clean “none” from Pi-hole instead of the question leaking to Unbound and out to the internet.

Blocklists

Add the book’s three lists under Group Management → Lists, then run Tools → Update Gravity:

Web UI Pi-hole → Group Management → Lists

Add each address as a list, then update Gravity.

https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
https://big.oisd.nl
https://raw.githubusercontent.com/hagezi/dns-blocklists/main/hosts/pro.txt
Exactly as printed in Be Your Own Cloud, Chapter 10 , p. 48 · all of Chapter 10's code

If something you need breaks — a shopping site’s checkout, a smart TV’s app store — the Query Log shows the blocked domain. Allow it under Domains rather than removing a whole list.

The IPv6 leak

Many home routers hand out their own IPv6 DNS server alongside the IPv4 one you just changed. Devices happily use it, and those queries never touch Pi-hole — so blocking looks patchy, and Chapter 8’s names resolve only some of the time.

Check the router’s IPv6 or LAN settings for a DNS option (often RDNSS or DHCPv6 DNS) and turn off the router advertising itself as a DNS server. IPv6 connectivity keeps working; only the DNS hand-out changes. The test is the Query Log: once it’s fixed, every device on your network appears there.

Backups and a second Pi-hole

Everything Pi-hole knows lives in /opt/stacks/pihole/etc-pihole, which Chapter 18’s backup of /opt/stacks already covers.

For a quick, portable copy — or to set up the second Pi-hole Chapter 10 recommends — use Settings → Teleporter. Export gives you one file with your lists, allowlist and local records; Import on the second Pi-hole makes it a copy of the first. Deploy the second one with the same compose file on another machine, import, and set its address as the router’s secondary DNS.

Updating

Pi-hole’s Docker tags are dates (2026.09.0); Unbound’s follow its own version numbers. Before bumping either tag, read the release notes — the Pi-hole v5-to-v6 move rewrote every environment variable, and the book’s FTLCONF_ lines only work on v6. Chapter 27 is the routine: backup first, one service at a time, and revert the tag if it breaks.

Official documentation

Stuck on a step? Get in touch.