Creating a Docker Stack for Pi-Hole with DNS over HTTPS

by 1st October 2021Atlassian, Development, Docker, Software, Technological Thoughts

A few weeks ago, I wrote about implementing a DNS blocklist script for Synology servers. It was fun to write and refresh my bash shell script skills, but it quickly became apparent that the Synology DNS server was just not up to the huge number of DNS sinkholes I was trying to throw at it.

I had steered away from using another method for some time, mainly because I use Synology Directory Server for PC authentication and that relies on the DNS Server. I am not keen on replicating systems, unless an existing system cannot do what I want it to, and generally replace the old with the new.

In this case, however, I need to keep the Synology DNS server, if only to support the Directory Server. As a result, I decided to deploy a Pi-hole server, which (unsurprisingly) originated as a DNS sinkhole server, allowing you to block unwanted DNS requests, on a Raspberry Pi. Because of its origins, Pi-hole has a small footprint too, so is ideal.

Docker and Networks

Running up a Docker instance of Pi-hole is simple enough, but as it also uses port 53, as does the Synology DNS server, I couldn’t run it on a Docker bridge or on the host (which is not best practice in any case). That meant implementing a MACVLAN network, allowing the Docker instance to appear on the host network with a different IP address.

DNS over HTTPS

At the same time, I decided that, rather than use standard DNS, I would implement DNS over HTTPS where any request made outside my network would be made securely without logging or tracking.

Pi-hole on its own doesn’t support DNS over HTTPS (DoH), so I needed another solution. Enter Cloudflared, a DNS proxy server that listens for requests on port 53, but does its own requests over 443.

Running the Docker Compose File

If you just want to go ahead and run the Docker Compose file (using the command line, or something handy like Portainer) then you can copy the full script below. The script itself uses environment variables which you can modify and store either in an .env file along with the docker_compose.yaml file.

Alternatively you can import the .env file into Portainer, or add the variables manually.

Note that there are still some sections of the Docker Compose file that you need to update, to make sure that the MACVLAN matches your existing network and doesn’t conflict with existing IP addresses.

Environment Settings

For both the Pi-hole and Cloudflared environment settings in the compose script, I have substituted some values with variables that you can set separately in the .env file.

The TZ is a standard Linux timezone (e.g. “Europe/London”).

The ServerIP variable is the IP address of Pi-Hole (In my case, I used 192.168.1.8)

The PIHOLE_DNS_ variable references the Cloudflared IP address (I used 192.168.1.9).

It’s fairly important to use adjacent IP addresses, because we are going to set the MACVLAN up to only be available to IP addresses using CIDR notation. 

And finally, the VIRTUAL_HOST is a domain name for the Pi-hole server. You don’t need to reference it or register it anywhere, but it needs to exist for Pi-hole.

For the Cloudflared environment settings, I have set both DNS resolvers to the Cloudflare servers, because they are extremely responsive, support DNSSEC, and don’t log requests. In fact, these variables are default within the Dockerfile itself, so they aren’t necessary, but I have included them for completeness. You can change these to any number of other servers, a list of which can be found at https://dnscrypt.info/public-servers/.

Pi-Hole Environment Settings

TZ: ${TIMEZONE}
ServerIP: ${PIHOLE_IP}
PIHOLE_DNS_: ${CLOUDFLARED_IP}#53'
VIRTUAL_HOST: ${HOST}.${DOMAIN_NAME}

Cloudflared Environment Settings

TZ: ${TIMEZONE}
PORT: 53
ADDRESS: 0.0.0.0
UPSTREAM1: https://1.1.1.1/dns-query 
UPSTREAM2: https://1.0.0.1/dns-query

The depends_on Parameter

The depends_on parameter tells Pi-Hole to wait until Cloudflared is running and is reporting itself as healthy. For Docker Compose version 3 scripts, the condition value was deprecated, but for the latest version of Docker Compose itself (1.29 and above), you can omit the version from the script and use condition again.

- '/docker/appdata/pihole/:/etc/pihole/'
- '/docker/appdata/pihole/dnsmasq.d/:/etc/dnsmasq.d/'
services:
cloudflared:
  condition: service_healthy
 

Pi-hole Volumes

The Pi-hole volumes map two directories needed to store the Pi-hole data and the dnsmasq data.

 

Pi-hole Network Setup

There are a number of values needed to be set up for Pi-hole. The hostname is preprended to the domainname (which are set in the .env file).

The networks section references the MACVLAN created at the end of the Docker Compose script (dnsnet), with the IP address of the Pi-hole server.

The dns section references the localhost (i.e. Pi-hole), and if needed, calls out to 1.1.1.1 (The Cloudflare DNS server hosted on port 53), which shouldn’t be necessary.

The cap_add section is only needed if you intend to use Pi-Hole as your DHCP server as well. I have because, unless your gateway (and DHCP server, which typically is your router) can resolve IP addresses to names, Pi-hole will just present IP addresses in your statistics, which can get confusing.

hostname: ${HOST}
domainname: ${DOMAIN_NAME}          
mac_address: ec:32:45:4d:01:a1
networks:
 dnsnet:
  ipv4_address: ${PIHOLE_IP}
  cap_add:
   - NET_ADMIN
  dns:
   - 127.0.0.1
   - 1.1.1.1

Cloudflared Network Setup

The Cloudflared network setup is a lot more simple than for Pi-hole: it simply needs the ipv4_address of the Cloudflared instance which is supplied in the .env file.

networks:
  dnsnet:
    ipv4_address: ${CLOUDFLARED_IP}

The MACVLAN Network

Finally, we create the MACVLAN network (yes, I know that is RAS syndrome laughing) to allow Pi-Hole and Cloudflared to use IP addresses in the same subnet as the Docker host, and still use port 53 for interrogation.

The parent for the driver_opts section refers to the physical ethernet port that the MACVLAN will listen on. On most Linux servers, this is “eth0”, but you can quickly check by typing ifconfig at the Linux shell prompt and selecting the appropriate name which has an IP address matching your host server.

This latter information is fairly important because you need to make sure the subnet and gateway match up to the same network. The subnet is simply the 255 addresses in your local network, recorded in CIDR format. Just set the subnet variable to whatever your network is (e.g. “192.168.1.0/24).

The gateway is quite often your router and will most likely be the second IP address in your subnet (e.g. “192.168.1.1”).

The ip_range is set to only cover to IP addresses, starting with that of Pi-Hole. By using the CIDR notation of /31, it means that only those two addresses will be available on the MACVLAN.

dnsnet:
  name: dnsnet
  driver: macvlan
  driver_opts:
    parent: ${ETH}
  ipam:
    config:
      - subnet: ${SUBNET}
        gateway: ${GATEWAY}
        ip_range: ${PIHOLE_IP}/31

The Docker Compose Script in its Entirety

Put together, you have a Docker Compose script and a .env file which will stand up Pi-Hole and call Cloudflare (or whichever DoH server you prefer) over HTTPs.

Once it is set up and you have configured Pi-Hole to pull the appropriate block lists (there is a regularly updated list of lists at https://blocklists.info/), you can point your router (and in my case, my Synology DNS resolver as well) to your Pi-Hole IP address. Any DNS requests made in your local network and from your Synology NAS will be routed through Pi-Hole and onward (if not blocked) to the DoH server of your choice.

The Final Docker Compose Script

services:
  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    hostname: ${HOST}
    domainname: ${DOMAIN_NAME}
    mac_address: ec:32:45:4d:01:a1
    networks:
      dnsnet:
        ipv4_address: ${PIHOLE_IP}
    cap_add:
      - NET_ADMIN
    dns:
      - 127.0.0.1
      - 1.1.1.1
    depends_on:
      cloudflared:
        condition: service_healthy
    environment:
      TZ: ${TIMEZONE}
      ServerIP: ${PIHOLE_IP}
      PIHOLE_DNS_: '${CLOUDFLARED_IP}#53'
      VIRTUAL_HOST: ${HOST}.${DOMAIN_NAME}
    volumes:
       - '/docker/appdata/pihole/:/etc/pihole/'
       - '/docker/appdata/pihole/dnsmasq.d/:/etc/dnsmasq.d/'
    restart: unless-stopped

  cloudflared:
    container_name: cloudflared
    image: visibilityspots/cloudflared:latest
    networks:
      dnsnet:
        ipv4_address: ${CLOUDFLARED_IP}
    environment:
      TZ: ${TIMEZONE}
      PORT: 53
      ADDRESS: 0.0.0.0
      UPSTREAM1: https://1.1.1.1/dns-query
      UPSTREAM2: https://1.0.0.1/dns-query
    restart: unless-stopped

networks:
  dnsnet:
    name: dnsnet
    driver: macvlan
    driver_opts:
      parent: ${ETH}
    ipam:
      config:
        - subnet: ${SUBNET}
          gateway: ${GATEWAY}
          ip_range: ${PIHOLE_IP}/31

The Docker Compose Environment Variables

CLOUDFLARED_IP=192.168.1.9
DOMAIN_NAME=domain.com
ETH=eth0
GATEWAY=192.168.1.1
HOST=dns
PIHOLE_IP=192.168.1.8
SUBNET=192.168.1.0/24
TIMEZONE=Europe/London
Matthew Cunliffe

Matthew Cunliffe

Author

Matthew is an IT specialist with more than 23 years experience in software development and project management. He has a wide range of interests, including international political theory; playing guitar; music; hiking, kayaking, and bouldering; and data privacy and ethics in IT.

0 Comments

Share this post