Running Pi-hole on Docker behind Traefik and see individual clients

I've migrated my Pi-hole from a bare metal RPi to a small x86 server running multiple docker services. As many want to expose a web interface, I decided to put Traefik, a reverse proxy, in front of them to be able to access them via https://github.com/zyedidia/micro`service1.lan`, service2.lan, ....

For the Pi-hole container this was a bit of a challenge, as I had two requirements

  1. The web interface is only accessible via the traefik route
  2. I want to see individual clients on my Pi-hole dashboard

I tried all the docker network modes, but none met my requirements (for general advantages & disadvantages see also here):

  • bridge networking mode: no individual clients on the dashboard
  • host networking mode: web interface wouldn't be behind traefik and conflict with port 80. If the web port is different than 80, access is possible only via pi.hole:PORT/admin which looks ugly
  • macvlan network: the web interface is not only accessible via traefik, but also http://macvlanIP/admin and requires a more complex setup

What I would need would be a dockerized DNS server at the edge of my network stack which would forward the queries to Pi-hole but preserves client information.

Luckily I know such a DNS server - dnsmasq


This guide does not deal with Pi-hole as DHCP server, but should be easily adjustable using the link provided below.


The whole idea was inspired by
DHCP with docker-compose and bridge networking where DerFetzer setup a DHCP relay.

Instead of creating a new dnsmasq image I used the slim, alpine based version from GitHub - brav0charlie/docker-dnsmasq: A simple dnsmasq container based on Alpine Linux Edge

  dnsmasq:
    container_name: dnsmasq
    image: ghcr.io/brav0charlie/docker-dnsmasq
    restart: unless-stopped
    network_mode: 'host'
    volumes:
      - '/etc/localtime:/etc/localtime:ro'
      - './dnsmasq:/etc/dnsmasq.d'

dnsmasq runs in host network mode and listens for incoming requests on port 53. The magic is with the to configuration file in ./dnsmasq/dns.conf

interface=eno1
no-resolv
cache-size=10000
server=172.31.0.100
log-facility=/dev/stdout
log-async

add-subnet=32
add-mac

Substitute interface=eno1 with the appropriate setting for your host, log-facility=/dev/stdout will log to the console so it can be seen on the host via docker logs. server= needs to be set to the Pi-hole container's IP (see below). And here is the magic: add-subnet=32and add-mac will add the IPv4 and the mac of the original client into the DNS query when it's forwarded upstream to the Pi-hole! Pi-hole can use this information to conclude which client was sending the query originally.

The docker-compose.yml for the Pi-hole container is mostly a vanilla one, excetpt that container is part of two networks, one is the traefik backend and one is a dns backend (I use a unbound container as upstream).

    depends_on:
      - dnsmasq
    networks:
      dns_backend:
        ipv4_address: 172.31.0.100
      traefik:

The networks are defined at the end of the compose file

networks:
  traefik:
   external: true
  dns_backend:
    ipam:
      config:
        - subnet: 172.31.0.0/16
    name: dns_backend

The whole compose file looks like this (including unbound)

version: "3"

# More info at https://github.com/pi-hole/docker-pi-hole/ and https://docs.pi-hole.net/
services:
  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    # For DHCP it is recommended to remove these ports and instead add: network_mode: "host"
    #ports:
      #- "53:53/tcp"
      #- "53:53/udp"
      #- "67:67/udp" # Only required if you are using Pi-hole as your DHCP server
      #- "81:80/tcp"
    environment:
      TZ: 'EUROPE/BERLIN'
      WEBPASSWORD: 'SECRETPASSWORD'
      FTLCONF_LOCAL_IPV4: '10.0.1.5'
      PIHOLE_DNS_: 'unbound'
    # Volumes store your data between container upgrades
    volumes:
      - './pihole/etc-pihole:/etc/pihole'
      - './pihole/etc-dnsmasq.d:/etc/dnsmasq.d'
    restart: unless-stopped
    depends_on:
      - dnsmasq
    networks:
      dns_backend:
        ipv4_address: 172.31.0.100
      traefik:
    labels:
      - traefik.enable=true
      - traefik.http.routers.pihole.rule=Host(`pi.hole`)
      - traefik.http.services.pihole.loadbalancer.server.port=80
      - traefik.http.routers.pihole.service=pihole
      - traefik.docker.network=traefik
    hostname: 'pihole'
  
  unbound:
    image: klutchell/unbound
    container_name: unbound
    restart: unless-stopped
    volumes: 
      - './unbound/:/etc/unbound/custom.conf.d'
      - '/etc/localtime:/etc/localtime:ro'
    networks:
      - dns_backend
    cap_add:
      - NET_ADMIN
    healthcheck:
      test: ["CMD", "dig", "-p", "53", "dnssec.works", "@127.0.0.1"]
      interval: 30s
      timeout: 30s
      retries: 3

  dnsmasq:
    container_name: dnsmasq
    image: ghcr.io/brav0charlie/docker-dnsmasq
    restart: unless-stopped
    network_mode: 'host'
    volumes:
      - '/etc/localtime:/etc/localtime:ro'
      - './dnsmasq:/etc/dnsmasq.d'


networks:
  traefik:
   external: true
  dns_backend:
    ipam:
      config:
        - subnet: 172.31.0.0/16
    name: dns_backend

One thing you should do to increase your privacy with this setup is to add is a new dnsmasq config file (e.g 55-strip.conf) within ./pihole/etc-dnsmasq.d to remove subnet/IP and MAC information before Pi-hole forwards queries upstream

strip-mac
strip-subnet