Update 20th January 2025: Happy new year! I’ve recently revisited my setup over the Christmas holidays and have improved my docker compose file to use internal networks and not expose any unnecessary ports. Any additional comments amended to the original article will be in italics like this.

Also with thanks to Kadar Anwar who emailed me about a year ago with suggestions for improving this post. It’s taken me a long time to update this, though looking after a baby doesn’t give me much free time nowadays!


The humble Raspberry Pi is a very versatile thing. A low-cost computer that can become a simple low-end desktop, a low power server or a controller for electronics projects via its numerous GPIO pins. In my case it’s the middle option, I currently have two Raspberry Pis managing various functions on my home network such as:

  • DHCP to assign IP addresses and routing information to devices depending on what their purpose is.
  • CUPS to allow my USB inkjet printer to receive print jobs from any computer on the network.
  • Pi-Hole to block annoying (and potentially malicious) advertisements at network level
  • Cloudflared (a.k.a. Argo Tunnel) to provide a channel for making DNS requests securely over HTTPS.

It’s the latter two that I’ll be focusing on in this post. For the most part this post is based on an existing how-to by Ben Dews, however recently I have been moving services into Docker containers for the purpose of quick disaster recovery (e.g. should the internal SD card fail). In the case of a full rebuild, just install Raspberry Pi OS, install Docker, run the docker-compose script and everything should be back to normal quickly.

Prerequisites

From a fresh install of Raspberry Pi OS (formerly Raspbian), install Docker and docker-compose from the package manager:

$ sudo apt update
$ sudo apt install docker.io docker-compose

Once those have been installed along with their dependencies, we can make a start with creating our docker-compose script.

Creating the Stack

Since this stack will consist of two containers communicating with one-another, it’s better to make use of docker-compose to organise the stack under one roof rather than bringing up and taking down two separate containers.

Creating the network

In order to make use of internal communications between the cloudflared and pihole containers, we need to first create the network that the two will be using. This is an optional step as any single docker compose file will have its own network automatically created, but I like to keep things nicely defined.

So in a terminal, firstly create the network:

# docker network create pihole_doh

This will create an external network (i.e. can be used by other docker compose configurations if you so choose) called pihole_doh

cloudflared – DNS over HTTPS

So let’s create pihole-doh.yml and firstly define our cloudflared service along with the previously defined network:

network:
  pihole_doh:
    external: true

services:
  cloudflared:
    image: crazymax/cloudflared:latest
    container_name: cloudflared
    networks:
      - pihole_doh
    environment:
      TZ: "Europe/London"
      TUNNEL_DNS_UPSTREAM: "https://1.1.1.1/dns-query,https://1.0.0.1/dns-query"
    restart: always

For those unfamiliar with docker-compose (and I will readily admit I’m still a newcomer to this), this seems like a lot but I’ll break it down.

  cloudflared:
    image: crazymax/cloudflared:latest
    container_name: cloudflared

Firstly we have the image this container will run, in this case I’m using an image of cloudflared created by GitHub user crazy-max. There are a number of alternative images available so if you want to try a different implementation then other options are available. This particular image is the second-most popular image for cloudflared on Docker Hub, with the other created by visibilityspots. The container_name value is just a frendly name we assign to the container, else Docker will randomly generate one.

    network:
      - pihole_doh

This tells the cloudflared container to use the pihole_doh docker network we defined earlier.

    environment:
      - "TZ=Europe/London"
      - "TUNNEL_DNS_UPSTREAM=https://1.1.1.1/dns-query,https://1.0.0.1/dns-query"

The environment section is for your own personal preferences. I live in the UK so I set the timezone accordingly, while the TUNNEL_DNS_UPSTREAM parameter allows you to set your DNS-over-HTTPS provider of choice if you don’t want to use Cloudflare.

    restart: always

Finally, we always want the container to restart in the event of an error, or if the Raspberry Pi reboots.

Verifying cloudflared

We can manually verify the cloudflared service is working by deploying the container and making a DNS request using dig. However firstly we need to temporarily expose port 5053 to the wider network by adding:

    ports:
      - "5053:5053/udp"

under the cloudflared service in docker-compose.yml

$ docker-compose -f "pihole-doh.yml" up -d

Once the container has successfully started we can make a DNS query over port 5053 using dig:

$ dig @127.0.0.1 -p 5053 michaeldodd.net

; <<>> DiG 9.11.5-P4-5.1+deb10u2-Debian <<>> @127.0.0.1 -p 5053 michaeldodd.net
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 16039
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
; COOKIE: 27b0cfa3930c9191 (echoed)
;; QUESTION SECTION:
;michaeldodd.net.		IN	A

;; ANSWER SECTION:
michaeldodd.net.	883	IN	A	69.163.157.115

;; Query time: 3 msec
;; SERVER: 127.0.0.1#5053(127.0.0.1)
;; WHEN: Sun Nov 08 12:41:37 GMT 2020
;; MSG SIZE  rcvd: 87

We can verify that the cloudflared container is making this request by using:

$ docker-compose -f "pihole-doh.yml" down

to bring down the container and re-running the dig command. This time it should time out.

$ dig @127.0.0.1 -p 5053 michaeldodd.net

; <<>> DiG 9.11.5-P4-5.1+deb10u2-Debian <<>> @127.0.0.1 -p 5053 michaeldodd.net
; (1 server found)
;; global options: +cmd
;; connection timed out; no servers could be reached

Remember to remove the ports: definition again once you’re done verifying.

Pi-Hole – Network-wide ad-blocking

Time to add our second service:

  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    networks:
      - pihole_doh
    depends_on: 
      - cloudflared
    links:
      - cloudflared
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "80:80/tcp"
      - "443:443/tcp"
    environment:
      TZ: 'Europe/London'
      WEBPASSWORD: 'superSecurePasswordHonest!'
      DNS1: 'cloudflared#5053'
      DNS2: 'no'
    # Volumes store your data between container upgrades
    volumes:
      - './pihole/etc-pihole/:/etc/pihole/'
      - './pihole/etc-dnsmasq.d/:/etc/dnsmasq.d/'
    restart: always

As above, we give our container a friendly name, and this time we’re using an official image provided by Pi-Hole. We want to ensure that this service comes up after cloudflared is ready, and that we can communicate with the cloudflared container. We also want docker to establish a network link between cloudflared and pihole using the pihole_doh network.

    network:
      - pihole_doh
    depends_on:
      - cloudflared
    links:
      - cloudflared

Next we have our list of ports:

    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "67:67/udp"
      - "80:80/tcp"
      - "443:443/tcp"

A few more this time, and most of these we want to directly map:

  • 53 (TCP and UDP) – DNS
    Listening ports for DNS requests. It’s important to directly map these ports as DNS handling is at the heart of what Pi-Hole does.
  • 67DHCP
    Pi-Hole can also act as a DHCP server so it can be beneficial to leave this in. However in my personal setup I have removed this mapping as I am using isc-dhcp-server to handle DHCP requests on my home network.
  • 80Web Server
    The web interface for Pi-Hole’s admin console. This one should be safe to map to whatever port you like, for example to avoid conflict if you’re also running a web server.
  • 443SSL
    Allows Pi-Hole to catch adverts served up over SSL.

There are a number of environment variable configuration options available on the Docker Hub page, but for now we’ll only make use of a few.

    environment:
      TZ: 'Europe/London'
      WEBPASSWORD: 'superSecurePasswordHonest!'
      DNS1: 'cloudflared#5053'
      DNS2: 'no'

The TZ value is the same as before albeit formatted differently due to the way the image is set up. This could probably be improved upon by using variable substitution so we only need to change one line to change the timezone for all services.

It’s generally a very bad idea to stick any credentials within a docker-compose file, and docker-compose allows for the importing of secrets. But for the sake of simplicity we’ll use a plain ol’ password here.

Finally, we want to configure Pi-Hole to make use of secure DNS requests by ensuring that upstream DNS requests are only routed via our cloudflared service. Therefore we’re sending all upstream DNS queries via the docker container network on port 5053, and not using any additional DNS providers. Having our own internal pihole_doh network allows us to just specify the name of the cloudflared service as the address, and docker will automatically resolve it for our pihole container.

    # Volumes store your data between container upgrades
    volumes:
      - './pihole/etc-pihole/:/etc/pihole/'
      - './pihole/etc-dnsmasq.d/:/etc/dnsmasq.d/'

These lines create storage directories for Pi-Hole outside of the container, so that configuratons are retained if the container is recreated after an upgrade. These folders will be created in the same directory as the pihole-doh.yml config file.

    # Recommended but not required (DHCP needs NET_ADMIN)
    #   https://github.com/pi-hole/docker-pi-hole#note-on-capabilities
    cap_add:
      - NET_ADMIN
      - NET_BIND_SERVICE
    restart: always

Finally, some additional capabilities that Pi-Hole requires should you wish to use it as a DHCP server. Additional details can be found here.

Running and verifying the stack

So your final pihole-doh.yml file should look something like this:

network:
  pihole_doh:
    external: true

services:
  cloudflared:
    image: crazymax/cloudflared:latest
    container_name: cloudflared
    networks:
      - pihole_doh
    environment:
      TZ: "Europe/London"
      TUNNEL_DNS_UPSTREAM: "https://1.1.1.1/dns-query,https://1.0.0.1/dns-query"
    restart: always

  pihole:
    container_name: pihole
    image: pihole/pihole:latest
    networks:
      - pihole_doh
    depends_on: 
      - cloudflared
    links:
      - cloudflared
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "80:80/tcp"
      - "443:443/tcp"
    environment:
      TZ: 'Europe/London'
      WEBPASSWORD: 'superSecurePasswordHonest!'
      DNS1: 'cloudflared#5053'
      DNS2: 'no'
    # Volumes store your data between container upgrades
    volumes:
      - './pihole/etc-pihole/:/etc/pihole/'
      - './pihole/etc-dnsmasq.d/:/etc/dnsmasq.d/'
    restart: always

(It should go without saying that you should use a different password, or better still, put your credentials in a different file)

The next thing to do is to run the script from docker-compose:

$ docker-compose -f pihole-doh.yml up -d

Providing you don’t have any other services that have already nabbed the ports, both containers should be up and running within a few seconds. You can verfiy this by visiting the IP of your Raspberry Pi in a web browser (e.g. http://192.168.0.10/admin), which should present you with the Pi-Hole admin panel.

Pi-Hole Admin Panel

You can now also verify that your DNS requests are being made over HTTPS by visiting Cloudflare’s ESNI Checker tool. After running the test, the first two columns (Secure DNS and DNSSEC) should both be green. The latter two (TLS 1.3 and Encrypted SNI) are browser-based features so fall outside the scope of this post.

Cloudflare ESNI test results showing secure DNS

The last thing to do is to ensure that all devices in your network are using your Raspberry Pi’s IP address as its DNS server. This can be done via the DHCP settings on your router or DHCP server, or manually on each device.

… or you can run this locally

During the process of verifying these steps on a Debian virtual machine, I found that I could run this stack locally, configuring the DNS server on my network connection to point to 127.0.0.1. This means that when used in combination with a VPN this stack could provide an extra layer of security when using public WiFi networks for example, as well as blocking annoying adverts without the need for a browser extension.

While I’ve locally tested this under Debian linux, I’ve not been able to verify this as working under Mac OS or Windows. I will update this post with my findings as and when I’m able to check.

UPDATE February 2022 – I’ve revisited this, and by setting the value of DNS1 to point at the Docker container network (172.22.0.1) rather than localhost, I now have DNS-over-HTTPS running locally on my MacBook. I have updated this post to reflect this.