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.ymlExtending 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 # NEWThe 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: 900Encrypt the Pi-hole password:
# interactive mode (recommended)
ansible-vault encrypt_string
# alternative
ansible-vault encrypt_string 'your-pi-hole-password' --name pihole_passwordDNS 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.piholeThe 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.ymlWhat the head1328.pihole Role Does#
| Step | Description |
|---|---|
| Quadlet directory | ~/.config/containers/systemd for rootless Quadlets |
| Podman network | Internal network internal for container communication |
| Pi-hole Quadlet | Container definition with DNS port and volumes |
| Caddy Quadlet | Reverse proxy with internal TLS |
| Caddyfile | Template for HTTPS configuration |
| systemd services | Start Quadlets as user services |
| Auto-update timer | Configure podman-auto-update.timer |
| iptables | Port 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.targetThe 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.targetThe 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.ymlAfter 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.crtVerifying 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 caddyCheck 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 -vAutomatic 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: 900The 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#
- Pi-hole - DNS-based ad and tracker filter
- Caddy - reverse proxy with automatic TLS
- Podman - daemonless container engine
- Ansible - automation and configuration management
- Freifunk München (FFMUC) - privacy-friendly DNS resolvers
Own Roles and Playbooks#
head1328.podman(role): Codeberg · Galaxyhead1328.pihole(role): Codeberg · Galaxy- Complete tutorial playbook: Codeberg
Found this helpful?
Consider supporting via:
