Running a single DNS server on a Raspberry Pi or a tiny VM is a common pattern in home labs.
When Let’s Encrypt asks you to prove domain ownership via a DNS‑01 challenge, the classic “edit a TXT record” dance becomes a chore if you’re juggling Cloudflare’s web UI.
Below is a quick, repeatable recipe that plugs a Pi‑hole into Cloudflare’s API, lets Cert‑bot finish the challenge automatically, and keeps the whole thing lean and secure.
1. Prerequisites
| Item | Why it matters |
|---|---|
| Pi‑hole (v5.x) | Acts as the local DNS server and a convenient spot to run scripts. |
| Cloudflare API token | Needed to modify DNS records. Create a token with Zone.Zone and Zone.DNS read/write permissions only. |
| Cert‑bot (v2.10+) | The ACME client that will request certificates. |
| Python 3.8+ | Required for the cloudflare library used in the helper script. |
Tip: Keep the API token in
/etc/cloudflare/tokenandchmod 600it. Avoid hard‑coding it in scripts.
2. Install the Cloudflare Python client
sudo apt-get update
sudo apt-get install -y python3-pip
sudo pip3 install cloudflare
3. Create the DNS‑01 helper
Save the following as /usr/local/bin/certbot-cloudflare.py and make it executable.
#!/usr/bin/env python3
import sys, os
from cloudflare import CloudFlare
cf = CloudFlare(token=os.environ["CF_API_TOKEN"])
zone_name = sys.argv[1]
record_name = sys.argv[2]
record_type = sys.argv[3]
record_content = sys.argv[4]
record_ttl = 120
zone_id = cf.zones.get(name=zone_name)[0]["id"]
records = cf.zones.dns_records.get(zone_id, params={"name": record_name, "type": record_type})
if record_content == "DELETE":
for rec in records:
cf.zones.dns_records.delete(zone_id, rec["id"])
else:
if records:
rec_id = records[0]["id"]
cf.zones.dns_records.put(zone_id, rec_id, data={
"type": record_type,
"name": record_name,
"content": record_content,
"ttl": record_ttl
})
else:
cf.zones.dns_records.post(zone_id, data={
"type": record_type,
"name": record_name,
"content": record_content,
"ttl": record_ttl
})
Make it executable:
sudo chmod +x /usr/local/bin/certbot-cloudflare.py
4. Tell Cert‑bot to use the helper
Create a file /etc/letsencrypt/cli.ini with:
dns-cloudflare = /usr/local/bin/certbot-cloudflare.py
dns-cloudflare-credentials = /etc/cloudflare/token
Now request a certificate:
sudo certbot certonly --dns-cloudflare -d example.com -d *.example.com
Cert‑bot will call the helper to add a TXT record, wait for propagation, then delete it automatically.
5. Security & maintenance
- Least‑privilege token – the token only has DNS read/write rights for the zone in question.
- Pi‑hole isolation – keep the Pi‑hole subnet separate from the public internet; use
iptablesorufwto block inbound traffic except for DNS (port 53). - Automated renewal – add a cron job that runs
certbot renewnightly. The helper will again touch Cloudflare, so keep the token file secure. - Audit logs – Cloudflare keeps a record of API calls. Periodically review them for unexpected changes.
6. Common hiccups
| Symptom | Fix |
|---|---|
| “Zone not found” | Verify the zone name matches exactly (case‑sensitive). |
| “Permission denied” | Double‑check the token scopes. |
| TXT record not visible after creation | Cloudflare may cache DNS. Use dig +noall +answer _acme-challenge.example.com TXT from an external host to confirm. |
The helper script is intentionally minimal; you can extend it to support other record types or to log actions.
With this setup, your home lab can automatically renew Let’s Encrypt certificates without manual DNS edits, while keeping the Pi‑hole as a single point of control.
See also
- How I stopped a 16‑GB microSD Raspberry Pi from dying mid‑boot because /var/log grew to 10 GB after a year of unattended cron jobs
- When my Raspberry Pi Home Server Ran Out of Space During a Midnight Backup, I Built a One‑Line Bash Alert Script to Save the Day.
- How a Forgotten SSH Key Stopped My Nightly Off‑Site Borg Backup and the Quick Fix I Implemented
- A tiny zsh function that lets you jump up any number of directories with one command
- Why my 4GB Raspberry Pi 4 keeps swapping during a Python script – and how I fixed it by moving swap to USB