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:
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 Before the first start, free up port 53. Ubuntu’s own resolver is sitting on it:
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 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:
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. 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:
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 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
- Pi-hole documentation — including the Docker image and its releases
- Unbound documentation (NLnet Labs)
- klutchell/unbound — the image the book uses
Stuck on a step? Get in touch.