Pihole is a great tool to protect your home network from trackers and annoying ads. It can be deployed either directly on a server or in a Docker container. I personally lean toward using Docker whenever possible for the flexibility and isolation it provides. Services deployed in Docker containers are significantly easier to migrate than raw installations and Pihole is no exception.

Running Pihole on Docker is pretty straightforward, but things start to get a bit complicated when it comes to enabling DHCP - using Pihole to serve DHCP requests. With Docker’s default bridge network mode, we can’t use Pihole as a DHCP server. So we have two options: (1) use the host network mode, or (2) run a DHCP relay (more on this later).

Option (1) is the easiest because all we’d have to do is set network_mode: "host" in the service definition, but it comes with a considerable disadvantage. It undermines the network isolation provided by Docker’s bridge driver, not to mention that it will take up ports from the host’s network which could be a concern depending on what’s running on your server. Option (2) allows us to stick to the bridge mode but adds a good amount of complexity. If you’re anything like me, you’ll favour security and try to maintain Docker’s network isolation.

Option (2) it is. Make sure your cup of coffee is full, and let’s dive in!

Pihole on Docker

First, let’s build a docker-compose.yml file with a basic configuration to run Pihole.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
version: "3.2"

services:
  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "8080:80/tcp"
    environment:
      - TZ=America/New_York
      - WEBPASSWORD=very-secure-password # Choose a password for admin
      - PIHOLE_DNS_="1.1.1.1;1.0.0.1" # Set upstream DNS servers
      - ServerIP="10.10.0.10" # Your host's external IP
    volumes:
      - pihole:/etc/pihole/
      - dnsmasqd:/etc/dnsmasq.d/
    restart: unless-stopped

volumes:
  pihole:
  dnsmasqd:

In this example, we define a service called pihole using the official Pihole Docker image. Then, we publish few ports to get DNS requests (UDP port 53) forwarded to the container running Pihole, and to allow access to the web panel (TCP port 80). After that, we set some environment variables:

  • WEBPASSWORD1: The password to use when logging into the admin panel.
  • PIHOLE_DNS_: A semicolon separated list of upstream DNS servers. These are the servers Pihole will reach out to in order to resolve DNS queries.
  • ServerIP: The external host IP. This is the IP that machines on the network will send their DNS queries to. In other words, this should be the hosts IP on the LAN network.

Last, we define a couple of volumes to persist configuration across multiple container restarts.

DHCP through Docker’s Bridge Network

At this point, we have a working Pihole deployment ready to manage DNS on the host’s network. Since we didn’t specify the network mode in the docker-compose file, the container is provisioned using the default driver: bridge network. This means that Pihole lives in a separate network than the host along with other machines on the LAN. Docker daemon bridges these 2 networks and forwards ports as specified in the docker-compose file. This works great for DNS, but not so much for DHCP. If we tried to enable DHCP on Pihole’s web interface, it would unfortunately not work. To understand why, let’s go over a brief overview of how DHCP works.

When a client joins a network it starts broadcasting a DHCP discovery request on that same network on UDP port 67. If there’s a DHCP server listening, it will respond to this request with a lease offer that the client then accepts2. This is how machines get assigned IP addresses dynamically when joining a network.

DHCP is not routable however, so discovery requests don’t span across different networks. In our setup, since Pihole is running in a Docker container with bridge network mode, it lives in a separate network (Docker internal network) than the LAN, so DHCP discovery requests stop at the edge of the LAN and never make it to Pihole. To solve this, we need a DHCP relay connected to both the LAN and Docker’s internal network. It will intercept discovery requests on one network and forward them to Pihole on the other.

With the theory out of the way, let’s move on to a practical solution to our problem. We will use dhcp-helper as a DHCP relay. First, we create an small image with that package and place the Dockerfile in a folder called dhcp-helper.

1
2
3
4
FROM alpine:latest
RUN apk --no-cache add dhcp-helper
EXPOSE 67 67/udp
ENTRYPOINT ["dhcp-helper", "-n"]

The file content is self explanatory. We’re using an alpine image, adding the dhcp-helper package, exposing the UDP port 67 in order to receive DHCP discovery requests, and we’re running the helper command at startup.

The next step is to update the original Pihole docker-compose file to include the DHCP relay. We will need to add a service for the relay, an additional network with a fixed IP for the Pihole container, and make the Pihole service depend on the DHCP relay.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
version: "3.2"

services:
  pihole:
    container_name: pihole
    # ...

  dhcphelper:
    build: ./dhcp-helper
    restart: unless-stopped
    network_mode: "host"
    command: -s 172.31.0.111
    cap_add:
      - NET_ADMIN

Notice that we’re using the host network mode for the DHCP relay service. This is important if we want to intercept discovery requests on the same network as the host (LAN). Also, we’re adding a flag to the entry point command specifying the server’s IP. We’ll assign this IP to Pihole’s service as follows:

1
2
3
4
5
6
7
8
9
version: "3.2"

services:
  pihole:
    container_name: pihole
    # ...
    networks:
      backend:
        ipv4_address: '172.31.0.111'

In order to use this new network that we named backend we need to define it in our docker-compose file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
version: "3.2"

services:
  pihole:
    container_name: pihole
    # ...

  dhcphelper:
    build: ./dhcp-helper
    # ...

networks:
  backend:
    ipam:
      config:
        - subnet: 172.31.0.0/16

With all these modifications, we end up with the following docker-compose file:

 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
35
36
37
38
39
40
41
42
43
44
version: "3.2"

services:
  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "8080:80/tcp"
    environment:
      - TZ=America/New_York
      - WEBPASSWORD=very-secure-password # Choose a password for admin
      - PIHOLE_DNS_="1.1.1.1;1.0.0.1" # Set upstream DNS servers
      - ServerIP="10.10.0.10" # Your host's external IP
    volumes:
      - pihole:/etc/pihole/
      - dnsmasqd:/etc/dnsmasq.d/
    restart: unless-stopped
    depends_on:
      - dhcphelper
    cap_add:
      - NET_ADMIN
    networks:
      backend:
        ipv4_address: '172.31.0.111'

  dhcphelper:
    build: ./dhcp-helper
    restart: unless-stopped
    network_mode: "host"
    command: -s 172.31.0.111
    cap_add:
      - NET_ADMIN

networks:
  backend:
    ipam:
      config:
        - subnet: 172.31.0.0/16

volumes:
  pihole:
  dnsmasqd:

One More Thing

So far, we’ve managed to:

  1. Run a Pihole container with bridge network mode
  2. Run a DHCP relay container that listens for discovery requests on the LAN and forwards them to Pihole on Docker’s internal network.

However, by default Pihole sends out leases instructing clients to use its IP as a DNS server. The IP used here is Pihole’s IP address on the network where it received the DHCP request. This means that the clients will be configured to send their DNS queries to an address on the network 172.31.0.0/16 and this will not work. This is an internal Docker network that we created to connect Pihole to the DHCP realy, and that machines on the LAN have no access to. In order to resolve this, Pihole needs to grant DHCP leases with the host’s external IP as the DNS. we can accomplish this by adding a configuration option to dnsmasq running inside Pihole’s container. This is as simple as creating a file inside Pihole’s container under /etc/dnsmasq.d/ with the following:

1
dhcp-option=option:dns-server,<PIHOLE_HOST_EXTERNAL_IP>

To make our lives easier, let’s put this in a script file called update-dhcp-dns and also support multiple DNS servers while we’re at it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/bin/bash

DNS_SERV_IPS="" # A comma separated list of DNS IPs. E.g.: "10.10.0.10,1.1.1.1,1.0.0.1"

if [[ -z $DNS_SERV_IPS ]]; then
    echo "Please set DNS servers IPs by modifying the script file."
    exit 1;
fi

docker-compose exec pihole bash -c "echo 'dhcp-option=option:dns-server,$DNS_SERV_IPS' > /etc/dnsmasq.d/07-dhcp-options"

Wrap up

We finally have all the pieces we need to run Pihole in a Docker container with bridge network mode and have it serve DHCP requests on the host’s LAN. After updating docker-compose.yml and update-dhcp-dns with your desired configuration values, like the web password, server IP, and DNS IPs, you can deploy using the following commands.

1
2
docker-compose up -d pihole
./update-dhcp-dns

Note that you only need to run ./update-dhcp-dns once. The modifications introduced by this script will persist across container restarts because we’re using a Docker volume in Pihole’s service definition.


  1. This is actually done in two steps: the client sends a lease request to the server, then the server replies with an acknowledgement. ↩︎

  2. If this environment variable is not set, Pihole will generate a random password. In this case, you’ll need to check the logs and search for the word “random” in order to find the generated password. ↩︎