Post

Pi-hole + Unbound on Raspberry Pi 5 with Docker

Pi-hole and Unbound setup on Raspberry Pi 5 with Docker, covering recursive DNS, DNSSEC, network clients, testing, and troubleshooting.

Pi-hole + Unbound on Raspberry Pi 5 with Docker

Initial Raspberry Pi setup

Start with a Raspberry Pi 5 Model B with 8 GB of RAM and a 128 GB SanDisk Extreme Pro UHS-I microSDXC card, or equivalent hardware.

NOTE: This tutorial uses a Windows computer for all client-side steps. Commands entered after connecting via SSH are executed on the Raspberry Pi itself.

If your Raspberry Pi is already up and running, skip this part.

Raspberry Pi Imager

Install Raspberry Pi Imager, insert your SD card into your computer, and start the setup process by selecting the following options:

  • Device: Raspberry Pi 5
  • OS: Raspberry Pi OS (other) -> Raspberry Pi OS Lite (64-bit)
  • Storage: Your SD card
  • Customisation:
    • Hostname: choose a hostname that suits you
    • Localisation: choose the settings that suit you
    • User: choose a username and password
    • Wi-Fi: Leave this unconfigured if you use a wired Ethernet connection
    • Remote access: Enable SSH with password authentication
    • Raspberry Pi Connect: Disabled
    • Writing: Review your settings and click Write if everything looks correct. Note that the writing process erases all data on the SD card. Make sure there is nothing on the card that you want to keep.

After Raspberry Pi Imager has finished writing the image, safely eject the SD card. Then, move on to booting and connecting to the Raspberry Pi.

Insert the SD card into the Raspberry Pi, connect it to the network, and then connect the power supply. The Raspberry Pi will boot automatically when it receives power. Give it a few minutes to complete the first boot.

Connecting to the Raspberry Pi

On a computer connected to the same network as the Raspberry Pi, open Command Prompt or PowerShell and connect to it using SSH:

1
ssh username@hostname

Replace username and hostname with the values you configured in Raspberry Pi Imager. If the hostname does not resolve, use the Raspberry Pi’s IP address instead.

NOTE: If you have previously connected to a device over SSH using the same hostname or IP address, you might receive a warning that the remote host identification has changed. If you know this was caused by reinstalling the Raspberry Pi, remove the old key with:

1
ssh-keygen -R hostname

If you connected using an IP address, use:

1
ssh-keygen -R IP-address

Enter your password when prompted. After entering it, you should be logged in to your Raspberry Pi.

Updating the Raspberry Pi

Before updating, the installed OS version can be checked with:

1
cat /etc/os-release

Then, update the system with:

1
sudo apt update && sudo apt full-upgrade -y

You might be prompted to restart services that are using outdated libraries. Leave all listed services selected and continue unless you have a specific reason not to. Navigate the list with the arrow keys and select or deselect entries with the spacebar.

If the Pi does not reboot automatically after the update, reboot it manually with:

1
sudo reboot

After the Raspberry Pi has rebooted, reconnect over SSH using the same command as earlier.

Creating a dedicated user

Creating a dedicated user for managing Pi-hole is optional but recommended. If you skip this step, use your existing Raspberry Pi username in the commands that follow.

Create a dedicated user for managing Pi-hole:

  • Create the user with:

    1
    
    sudo useradd -m -s /bin/bash username
    

    NOTE: Replace username with your desired username.

  • Optionally, set a password with:

    1
    
    sudo passwd username
    

    NOTE: Replace password with your desired password. The password needs to be retyped to set it.

Installing Docker

Next, install Docker and Docker Compose with:

1
sudo apt install docker.io docker-compose -y

On current Debian 13 installations, Docker Compose is provided as a separate package and uses the modern Docker CLI docker compose command syntax instead of the older docker-compose command syntax.

Verify that both Docker and Docker Compose are available:

1
docker --version
1
docker compose version

Then, add the Pi-hole management account to the Docker group with:

1
sudo usermod -aG docker username

This allows that user to run Docker commands without sudo.

NOTE: Adding a user to the docker group effectively gives that user root-level control of the Raspberry Pi, because Docker can be used to mount and modify the host filesystem.

Creating the Pi-hole project

Create a directory for the Pi-hole Docker files with:

1
sudo mkdir -p /opt/pihole

Then, make the Pi-hole management user the owner with:

1
sudo chown username:username /opt/pihole

Docker group membership changes take effect after starting a new login session. Switch to the dedicated user with:

1
sudo -iu username

Verify Docker access with:

1
docker ps

If the command runs without a permission error, the Docker group membership is working correctly.

Create the project directory structure with:

1
mkdir -p /opt/pihole/{pihole,unbound}

Then, move into the parent Pi-hole directory with:

1
cd /opt/pihole

Configuring Docker Compose

Next, configure Docker Compose.

Create a docker-compose.yml file with:

1
nano docker-compose.yml

Add the following content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
services:
  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "80:80/tcp"
      - "443:443/tcp"
    environment:
      TZ: "Europe/Helsinki"  # Replace with your timezone
      FTLCONF_webserver_api_password: "password"  # Change this!
      FTLCONF_dns_listeningMode: "all"
      FTLCONF_dns_upstreams: "unbound#53"
    volumes:
      - ./pihole:/etc/pihole
    restart: unless-stopped
    networks:
      dns_network:
    depends_on:
      - unbound

  unbound:
    container_name: unbound
    image: klutchell/unbound:latest
    volumes:
      - ./unbound:/etc/unbound/custom.conf.d
    restart: unless-stopped
    networks:
      dns_network:

networks:
  dns_network:
    driver: bridge

The Pi-hole and Unbound containers share the same Docker bridge network. This allows Pi-hole to use the Unbound container name directly as its upstream resolver with unbound#53.

Pi-hole v6 uses FTLCONF_dns_listeningMode: "all" to provide the required listening behaviour for Docker bridge networking. It does not load /etc/dnsmasq.d by default.

No additional Linux capabilities or seccomp:unconfined configuration are required for this setup because Pi-hole is not being used as the DHCP server or to set the Raspberry Pi’s system time.

Save the file with Ctrl + O, then exit Nano with Ctrl + X.

Configuring Unbound

Next, configure Unbound.

Move into the unbound directory with:

1
cd /opt/pihole/unbound

Create the Unbound configuration file with:

1
nano unbound.conf

Add the following content:

1
2
3
4
5
6
server:
  # Disable IPv6 queries if you do not have native IPv6 connectivity
  do-ip6: no

  # Keep the receive buffer below the typical container host limit
  so-rcvbuf: 400k

Although older versions of Unbound configuration tutorials include suggestions to add a custom root-hints: line, it is not necessary. The klutchell/unbound image includes its own root hints and DNSSEC trust anchor configuration, so you do not need to download or configure a separate root hints file. Adding another root-hints file results in duplicate root-hints definitions and can produce the warning:

1
error: second hints for zone . ignored.

The EDNS buffer size is set to 1232 bytes. This reduces the risk of fragmented DNS responses and matches the current recommendation commonly used for Unbound.

Save the file with Ctrl + O, then exit Nano with Ctrl + X.

Starting the containers

Before starting the containers, return to the parent directory:

1
cd /opt/pihole

Start the containers with:

1
docker compose up -d

Check their status with:

1
docker compose ps

Both the pihole and unbound containers should show as running.

Check the Unbound startup logs with:

1
docker logs unbound --tail=50

Look for a successful startup message such as:

1
unbound[1:0] info: start of service (unbound 1.26.1).

Also check for any syntax errors, permission errors, or problems loading files from /etc/unbound/custom.conf.d.

You can verify that the Unbound configuration itself is valid with:

1
docker exec unbound unbound-checkconf

If the configuration is valid, the command should complete without reporting configuration errors.

Next, test Unbound directly:

1
docker exec unbound drill-hc @127.0.0.1 dnssec.works

A successful response confirms that Unbound can resolve and validate DNS queries.

You can also test DNSSEC failure handling with:

1
docker exec unbound drill-hc @127.0.0.1 fail01.dnssec.works

The deliberately broken DNSSEC domain should not resolve successfully.

Configuring Pi-hole

Next, configure Pi-hole. Access the Pi-hole web interface at http://pi-hole-ip/admin/.

NOTE: Replace pi-hole-ip with your Raspberry Pi’s IP address.

Use the password configured in docker-compose.yml.

Check the following Pi-hole settings:

  1. DNS Settings

    • Go to Settings > DNS
    • Verify that unbound#53 is configured as the upstream DNS server
    • Verify that no public upstream DNS providers such as Google, Cloudflare, or Quad9 are enabled
    • Verify that the DNS listening mode is set to permit queries on all interfaces

    Because FTLCONF_dns_upstreams and FTLCONF_dns_listeningMode are defined through Docker environment variables, these settings may appear read-only in the Pi-hole web interface. This is expected.

    NOTE: Using the “Permit all origins” / ALL listening mode is required for this Docker bridge configuration, but make sure TCP and UDP port 53 are not exposed to the public Internet. Do not create a router port-forward for DNS port 53.

  2. Domain Settings (optional)

    • Set your local domain, for example somethingfun.lan
    • Enable “Expand hostnames” if you want to use simple hostnames on your local network

Configuring host DNS

Next, configure the Raspberry Pi host itself to use Pi-hole for DNS. Current Raspberry Pi OS versions use NetworkManager by default, so configure DNS through NetworkManager instead of manually locking /etc/resolv.conf or editing dhclient.conf.

NOTE: The following NetworkManager commands require an account with sudo privileges, so switch back to the original Raspberry Pi user before continuing. If you previously switched to the dedicated Pi-hole user with:

1
sudo -iu username

return to the original user with:

1
exit

If this closes the SSH session instead of returning you to the original user, reconnect using the original Raspberry Pi account:

1
ssh username@hostname

First, identify the active network connection:

1
nmcli connection show --active

Look for the connection used by the Raspberry Pi, for example:

Wired connection 1

Configure the connection to use the local Pi-hole instance as its DNS resolver:

NOTE: Replace connection-name in the following commands with the actual connection name.

1
sudo nmcli connection modify "connection-name" ipv4.dns "127.0.0.1" ipv4.ignore-auto-dns yes ipv6.ignore-auto-dns yes

Apply the change:

1
sudo nmcli connection up "connection-name"

Check the generated resolver configuration with:

1
cat /etc/resolv.conf

You should see the local resolver being used rather than DNS servers supplied automatically by DHCP.

You can also verify the NetworkManager DNS configuration with:

1
nmcli connection show "connection-name" | grep -E "ipv4.dns|ipv4.ignore-auto-dns|ipv6.ignore-auto-dns"

NOTE: Do not make /etc/resolv.conf immutable with chattr +i. NetworkManager should be allowed to manage the file normally. Do not add a supersede domain-name-servers entry to /etc/dhcp/dhclient.conf. Configure host DNS through NetworkManager, which Raspberry Pi OS uses by default. Also, do not configure Docker’s daemon DNS as:

1
  "dns": ["127.0.0.1"]

A Docker container has its own network namespace, so 127.0.0.1 inside a container refers to that container itself rather than the Raspberry Pi host. Leave Docker’s daemon DNS configuration at its default unless you have a specific reason to change it.

Configuring network clients

Then, configure your DHCP server or router to provide the Pi-hole server’s LAN IP address as the DNS server for your network clients. This step varies depending on your router. Alternatively, configure the Pi-hole IP address manually on individual devices. For example, on Windows:

Windows Settings > Network and Internet > Ethernet > IP configuration > Manual > IPv4(/6) > DNS

Do not use the loopback address 127.0.0.1 on other devices. Use the Raspberry Pi’s actual LAN IP address.

Container autostart

Container autostart does not require a separate Pi-hole systemd service.

Both containers already use:

1
restart: unless-stopped

Docker will therefore restart them automatically when the Docker daemon starts, unless you deliberately stopped them yourself.

Make sure Docker itself is enabled at boot:

1
sudo systemctl enable docker

You can verify this with:

1
systemctl is-enabled docker

Testing

With the main configuration complete, test each part of the DNS chain separately. Change back to the designated Pi-hole user with (or alternatively add sudo in front of every docker command):

1
sudo -iu username
  1. Test Unbound directly

    Run:

    1
    
     docker exec unbound drill-hc @127.0.0.1 example.com
    

    This tests whether Unbound itself can perform recursive DNS resolution.

  2. Test Pi-hole through Unbound

    Run:

    1
    
    docker exec -it pihole nslookup example.com 127.0.0.1
    

    This sends the query to Pi-hole rather than directly to Unbound.

    Then, open the Pi-hole Query Log in the web interface and confirm that the query was forwarded to unbound#53. This verifies the complete path.

  3. Test from another device

    From a Windows computer using Pi-hole as its DNS server, run:

    1
    
    nslookup example.com your-pihole-ip
    

    Replace your-pihole-ip with the Raspberry Pi’s LAN IP address. The query should return a valid response.

  4. Test DNSSEC

    A valid DNSSEC domain should resolve:

    1
    
    docker exec unbound drill-hc @127.0.0.1 dnssec.works
    

    A deliberately broken DNSSEC domain should fail:

    1
    
    docker exec unbound drill-hc @127.0.0.1 fail01.dnssec.works
    
  5. Test DNS leak behaviour

    Visit dnsleaktest.com and run a standard test.

    Because Unbound performs recursive DNS resolution itself rather than forwarding everything to a public DNS provider, the results may not look the same as when using services such as Google DNS or Cloudflare. You should not see Google, Cloudflare, Quad9, or another public resolver unless something in your network is explicitly using one of them.

Blocklists

Once the basic installation is working, add the blocklists of your choice.

For example:

1
2
3
4
5
6
https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
https://raw.githubusercontent.com/hagezi/dns-blocklists/refs/heads/main/domains/pro.plus.txt
https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/adblock/pro.txt
https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/adblock/tif.txt
https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/adblock/dyndns.txt
https://gitlab.com/hagezi/mirror/-/raw/main/dns-blocklists/adblock/spam-tlds-adblock.txt

Add the lists through the Pi-hole web interface. After adding or changing blocklists, update Pi-hole’s gravity database with:

1
docker exec -it pihole pihole -g

Troubleshooting

Pi-hole: ignoring query from non-local network

If you see:

1
ignoring query from non-local network

Pi-hole is refusing a query because it does not consider the requesting network local. Because Pi-hole runs behind Docker’s bridge network, the correct listening mode for this configuration is already defined in docker-compose.yml:

1
FTLCONF_dns_listeningMode: "all"

Verify that this environment variable is present and then recreate the container if you changed the Compose file:

1
2
cd /opt/pihole
docker compose up -d

Do not use docker compose restart after changing environment variables in the Compose file. A simple restart does not recreate the container with the new environment configuration.

Unbound not working

If Unbound fails to start or respond to queries:

  1. Check the container status:

    1
    
    docker compose ps
    
  2. Check the logs:

    1
    
    docker logs unbound --tail=100
    
  3. Validate the configuration:

    1
    
    docker exec unbound unbound-checkconf
    
  4. Test the resolver directly:

    1
    
    docker exec unbound drill-hc @127.0.0.1 dnssec.works
    
  5. Verify that your custom configuration is mounted correctly.

    The local directory should be /opt/pihole/unbound. The Docker Compose mount should be ./unbound:/etc/unbound/custom.conf.d. The configuration file should be /opt/pihole/unbound/unbound.conf. The klutchell/unbound image expects custom configuration files in /etc/unbound/custom.conf.d/.

  6. Check the local configuration file permissions:

    1
    
    ls -la /opt/pihole/unbound
    

    The Unbound Docker image must be able to read the files in this directory. A normal readable configuration file can be set with:

    1
    
    sudo chmod 644 /opt/pihole/unbound/*.conf
    

    And the directory itself can be made traversable with:

    1
    
    sudo chmod 755 /opt/pihole/unbound
    

    Avoid recursively changing the directory to an arbitrary UID such as 1001:1001. The Unbound image runs using its own container user and only requires that the mounted files are readable.

Unbound DNSSEC failure

If normal domains resolve but DNSSEC validation fails, check:

1
docker logs unbound --tail=100

Then run:

1
docker exec unbound drill-hc @127.0.0.1 dnssec.works

If this fails, Unbound may be unable to reach the DNS root or authoritative servers correctly. You can also validate the configuration again with:

1
docker exec unbound unbound-checkconf

Duplicate root hints warning

If you see:

1
error: second hints for zone . ignored.</code>

check your /opt/pihole/unbound/unbound.conf file.There should not be a custom root-hints: line in this configuration. The klutchell/unbound image already provides root hints internally, so a second root-hints configuration is unnecessary.

Pi-hole container permission issues

If Pi-hole reports permission errors, first inspect the actual volume rather than recursively changing its ownership:

1
ls -la /opt/pihole/pihole

Also inspect the container logs:

1
docker logs pihole --tail=100

Avoid using commands such as:

1
chown -R 1001:1001

unless you have first confirmed that the UID and GID are actually correct for the container and the specific files involved. Likewise, avoid applying chmod -R 755 to the entire volume. Regular configuration and database files do not need to be executable.

Host DNS stops working when Pi-hole is down

After configuring NetworkManager to use 127.0.0.1, the Raspberry Pi itself depends on Pi-hole for DNS. If Docker or Pi-hole is stopped, DNS resolution on the Raspberry Pi will therefore stop as well.

First, try bringing the containers back up:

1
2
cd /opt/pihole
docker compose up -d

If you temporarily need the Raspberry Pi to use DNS supplied by DHCP again, find the connection name with:

1
nmcli connection show --active

Then run:

1
sudo nmcli connection modify "connection-name" ipv4.ignore-auto-dns no ipv6.ignore-auto-dns no ipv4.dns ""

Apply the connection:

1
sudo nmcli connection up "connection-name"

After Pi-hole is working again, reapply the local Pi-hole DNS configuration:

1
2
sudo nmcli connection modify "connection-name" ipv4.dns "127.0.0.1" ipv4.ignore-auto-dns yes ipv6.ignore-auto-dns yes
sudo nmcli connection up "connection-name"

Docker container DNS issues

Do not set the Docker daemon DNS server to 127.0.0.1. A Docker container’s loopback address points back to the container itself. It does not point to the Raspberry Pi host. If you previously created /etc/docker/daemon.json containing:

1
2
3
{
  "dns": ["127.0.0.1"]
}

remove that DNS override before troubleshooting container DNS. If the file contains nothing else you need, remove it:

1
sudo rm /etc/docker/daemon.json

Then restart Docker:

1
sudo systemctl restart docker

Return to the Pi-hole directory and make sure both containers are running:

1
2
cd /opt/pihole
docker compose up -d

Pi-hole gravity list errors

If you see output like:

1
2
3
4
5
6
7
8
9
[i] Target: https://raw.githubusercontent.com/hagezi/dns-blocklists/refs/heads/main/wildcard/nsfw.txt
[✓] Status: No changes detected
[✓] Parsed 0 exact domains and 0 ABP-style domains (blocking, ignored 75907 non-domain entries)
Sample of non-domain entries:
- *.0-porno.com
- *.0000sex.com
- *.000freeproxy.com
- *.000pussy69pornxxxporno.com
- *.000webhostapp.co

the list is probably in a format Pi-hole’s gravity parser cannot use directly. Use a Pi-hole-compatible domain list instead of a wildcard or dnsmasq-specific list.

Updating the containers

Because this configuration currently uses the latest image tags, Docker will use the latest available images when you explicitly pull them.

To update the images:

1
2
cd /opt/pihole
docker compose pull

Then recreate the containers:

1
docker compose up -d

Check both containers afterwards:

1
docker compose ps

Then test DNS again:

1
2
docker exec unbound drill-hc @127.0.0.1 dnssec.works
docker exec -it pihole nslookup example.com 127.0.0.1

For a setup where reproducibility is more important than automatically following new releases, replace the latest image tags with specific versions that you have tested.

Summary

This project turned out to be more demanding than a basic Pi-hole installation, mainly because Pi-hole, Unbound, Docker, and the Raspberry Pi host all had to work together correctly. Most of the problems came from small configuration details, such as the updated Pi-hole misc parameters, duplicate Unbound settings, Docker DNS behaviour, permissions, and making sure the Raspberry Pi itself used Pi-hole without creating circular DNS issues. Troubleshooting often meant checking logs, validating configuration files, and changing one thing at a time to avoid breaking an otherwise working setup.

In the end, the project was a good practical exercise in Linux administration, Docker networking, DNS, and systematic troubleshooting. It also showed how important it is to understand what a Docker image already configures by default instead of blindly duplicating settings in custom files.

All rights reserved. Any use of my content is strictly prohibited!