Skip to main content

Pi-hole on Raspberry Pi with Ansible and Quadlets

·1125 words·6 mins
Table of Contents
Raspberry Pi Homelab - This article is part of a series.
Part 2: This Article

This article shows how you deploy Pi-hole as a rootless container with Podman Quadlets on the Raspberry Pi Zero 2 W. The Ansible role head1328.pihole handles the complete configuration, including a Caddy reverse proxy, automatic container updates, and iptables port redirection.

Prerequisite: a working Podman setup as described in the first article of the series .

Architecture
#

Web traffic:

flowchart TB
    A[Browser] --> B["https://pi-hole-primary.fritz.box"]
    B --> C["iptables REDIRECT
443 → 8443"] C --> D["Caddy
internal TLS, port 8443"] D --> E["http://pihole:80
Podman network"] E --> F[Pi-hole Admin UI] A ~~~ F

DNS traffic:

flowchart LR
    A[DNS queries] --> B["iptables REDIRECT
53 → 5353"] B --> C["Pi-hole container
port 5353"]

Since rootless Podman cannot bind privileged ports (< 1024) by default, iptables forwards ports 53 and 443 to the container ports 5353 and 8443.

What is a Quadlet?
#

Podman 5 ships with native Quadlet support. Quadlets are systemd unit files that describe containers declaratively. The podman-system-generator automatically converts them into running services on systemd reload.

Advantages over podman generate systemd:

  • Declarative instead of imperative
  • No more generated unit files
  • Native systemd integration
  • Automatic updates via AutoUpdate=registry

Extending the Ansible Project
#

The complete project is also available here:

This tutorial is based on Version 1.0.0 .

Updating requirements.yml
#

---
collections:
  - name: devsec.hardening
    version: ">=10.4.0,<10.5"
    type: galaxy
  - name: containers.podman
    version: ">=1.18.0,<1.19"
    type: galaxy
  - name: community.general           # NEW
    version: ">=10.0.0,<11.0"         # NEW
    type: galaxy                      # NEW

roles:
  - name: head1328.podman
    version: "v0.1.0"
  - name: head1328.pihole             # NEW
    version: "v0.1.0"                 # NEW
  - name: escalate.swap
    version: "v2.1.0"

The community.general collection is needed for iptables persistence.

ansible-galaxy install -r requirements.yml

Extending the inventory: hosts.yml
#

---
all:
  vars:
    ansible_python_interpreter: /usr/bin/python3
  children:
    podmen:
      children:                                       # NEW
        piholes:                                      # NEW
          hosts:                                      # NEW
            pi-hole-primary:                          # NEW
              ansible_host: pi-hole-primary.fritz.box # NEW

The piholes group is a sub-group of podmen, so both roles get applied.

Group vars: piholes.yml
#

---
pihole_image: "docker.io/pihole/pihole:latest"
pihole_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  ...
pihole_timezone: "Europe/Berlin"
pihole_dns_upstreams: "5.1.66.255;185.150.99.255"

pihole_fqdn: pi-hole-primary.fritz.box
pihole_in_interface: eth0
pihole_dns_port: 5353
pihole_https_port: 8443

pihole_autoupdate_enabled: true
pihole_autoupdate_time: "03:00"
pihole_autoupdate_random_delay: 900

Encrypt the Pi-hole password:

# interactive mode (recommended)
ansible-vault encrypt_string

# alternative
ansible-vault encrypt_string 'your-pi-hole-password' --name pihole_password

DNS upstreams
#

The example uses the Freifunk München (FFMUC) resolvers as DNS upstreams, privacy-friendly, non-commercial DNS servers run by the Freifunk community. Other Freifunk communities in various German cities and metro areas offer similar services. Large commercial providers are intentionally not listed here.

Extending the Playbook
#

play_pihole.yml
#

---
- name: Deploy Pi-hole
  hosts: piholes
  become: true

  pre_tasks:
    - name: Disable avahi-daemon for Pi-hole
      block:
        - name: Stop avahi-daemon service
          ansible.builtin.systemd:
            name: avahi-daemon
            state: stopped
            enabled: false

    - name: Assert podman binary is available
      ansible.builtin.command: podman --version
      register: podman_version
      changed_when: false
      failed_when: podman_version.rc != 0

  roles:
    - role: head1328.pihole

The avahi-daemon is disabled because it occupies port 5353. On a dedicated Pi-hole host, mDNS isn’t needed, there are no printers, AirPlay devices, or other services that would need to be discovered via Zero-Configuration. Local name resolution is handled by the FritzBox via .fritz.box domains anyway.

Importing into play.yml
#

Append the Pi-hole playbook at the end of play.yml:

- name: Import Pi-hole playbook
  import_playbook: play_pihole.yml

What the head1328.pihole Role Does
#

StepDescription
Quadlet directory~/.config/containers/systemd for rootless Quadlets
Podman networkInternal network internal for container communication
Pi-hole QuadletContainer definition with DNS port and volumes
Caddy QuadletReverse proxy with internal TLS
CaddyfileTemplate for HTTPS configuration
systemd servicesStart Quadlets as user services
Auto-update timerConfigure podman-auto-update.timer
iptablesPort redirection 53→5353, 443→8443 (IPv4/IPv6)

The Pi-hole Quadlet
#

The role creates a Quadlet at ~/.config/containers/systemd/pihole.container:

[Container]
ContainerName=pihole
Image=docker.io/pihole/pihole:latest
Network=internal
PublishPort=5353:53/tcp
PublishPort=5353:53/udp
Environment=TZ=Europe/Berlin
Environment=FTLCONF_dns_upstreams=5.1.66.255;185.150.99.255
Environment=FTLCONF_dns_listeningMode=all
Environment=FTLCONF_webserver_api_password=...
AddCapability=SYS_NICE
Volume=pihole-etc-pihole:/etc/pihole:Z
Volume=pihole-etc-dnsmasq.d:/etc/dnsmasq.d:Z
AutoUpdate=registry

[Install]
WantedBy=default.target

The Caddy Quadlet
#

Caddy handles TLS termination with an internal certificate at ~/.config/containers/systemd/caddy.container:

[Container]
ContainerName=caddy
Image=docker.io/caddy:latest
Network=internal
PublishPort=8443:443/tcp
Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z
Volume=caddy-data:/data:Z
Volume=caddy-config:/config:Z
AutoUpdate=registry

[Install]
WantedBy=default.target

The Caddyfile
#

The config (/etc/caddy/Caddyfile) is mounted into the container at the same path:

sudo -u podman bash -c 'cd "$HOME" && podman exec -it caddy sh -c "cat /etc/caddy/Caddyfile"'
pi-hole-primary.fritz.box {
    tls internal
    encode gzip

    @root path /
    redir @root /admin 302

    reverse_proxy pihole:80
}

Running the Playbook
#

ansible-playbook play.yml

After the playbook run, Pi-hole is reachable at https://pi-hole-primary.fritz.box. For all devices in the home network to use Pi-hole, you need to configure the Pi-hole’s IP address as the local DNS server in the FritzBox.

Trusting the TLS Certificate
#

Caddy uses an internal certificate authority. To avoid browser warnings, you need to export the root certificate once and mark it as trusted on your client devices.

Export the certificate
#

ssh ansible@pi-hole-primary.fritz.box
sudo -u podman bash -c 'cd "$HOME" && podman cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-root-ca.crt && chmod a+rw /tmp/caddy-root-ca.crt'

Trust on macOS
#

scp ansible@pi-hole-primary.fritz.box:/tmp/caddy-root-ca.crt .
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain caddy-root-ca.crt

Verifying the Installation
#

Check container status
#

ssh ansible@pi-hole-primary.fritz.box
sudo -u podman bash -c 'cd "$HOME" && podman ps'

Expected output:

CONTAINER ID  IMAGE                           STATUS         NAMES
abc123        docker.io/pihole/pihole:latest  Up 10 minutes  pihole
def456        docker.io/caddy:latest          Up 10 minutes  caddy

Check systemd services
#

sudo -u podman bash -c 'export XDG_RUNTIME_DIR=/run/user/$(id -u) && systemctl --user status pihole.service'
sudo -u podman bash -c 'export XDG_RUNTIME_DIR=/run/user/$(id -u) && systemctl --user status caddy.service'

Check iptables rules
#

sudo iptables -t nat -L PREROUTING -n -v
sudo ip6tables -t nat -L PREROUTING -n -v

Automatic Updates
#

The role configures podman-auto-update for automatic container updates.

Check timer status
#

sudo -u podman bash -c 'export XDG_RUNTIME_DIR=/run/user/$(id -u) && systemctl --user status podman-auto-update.timer'
sudo -u podman bash -c 'export XDG_RUNTIME_DIR=/run/user/$(id -u) && systemctl --user list-timers'

Manual update
#

# Dry-run
sudo -u podman bash -c 'cd "$HOME" && podman auto-update --dry-run'

# Run update
sudo -u podman bash -c 'cd "$HOME" && podman auto-update'

Adjust update time
#

In host_vars/pi-hole-primary.yml:

pihole_autoupdate_time: "03:00"
pihole_autoupdate_random_delay: 900

The random delay prevents multiple Pi-holes from updating at the same time, important for high availability in primary/secondary setups.

Conclusion
#

With the head1328.pihole Ansible role, Pi-hole runs as a rootless container on the Raspberry Pi:

  • Quadlets for declarative container definitions
  • Caddy as reverse proxy with internal TLS
  • iptables for port redirection without privileged ports
  • Automatic updates via the podman-auto-update timer
  • Freifunk München DNS as a privacy-friendly upstream

In the next article, we’ll configure a primary/secondary Pi-hole setup with FritzBox integration and a WireGuard VPN.

Acknowledgments
#

Own Roles and Playbooks
#


Found this helpful?
Consider supporting via:

Raspberry Pi Homelab - This article is part of a series.
Part 2: This Article