Automated Let's Encrypt certificate renewal with Cloudflare DNS-01 challenge.
This guide provides step-by-step instructions for setting up automated wildcard SSL certificate renewal on FusionPBX servers using Let's Encrypt with Cloudflare DNS-01 challenge.
Wildcard certificates (e.g., *.pbx.example.com) allow you to secure all subdomains under a single certificate. This is ideal for multi-tenant FusionPBX deployments where tenant domains are created dynamically.
| Requirement | Details |
|---|---|
| FusionPBX | Version 5.3+ with dehydrated installed |
| DNS Provider | Cloudflare with API access |
| Operating System | Debian 11/12 or Ubuntu 20.04+ |
| Packages | curl, jq |
| Setting | Value |
|---|---|
| Token name | FusionPBX SSL Renewal - [servername] |
| Permissions | Zone → DNS → Edit |
| Zone Resources | Include → Specific zone → yourdomain.com |
| TTL | Optional expiration or unlimited |
Click Continue to summary and then Create Token. Copy the token immediately because it will not be shown again.
Test the token from your FusionPBX server:
curl "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
Expected response:
{"success":true,"messages":[{"message":"This API Token is valid and active"}]}
SSH into your FusionPBX server as root:
apt-get update && apt-get install -y jq curl
cat > /etc/dehydrated/cloudflare.env << 'EOF'
# Cloudflare API Token for DNS-01 challenge
CF_TOKEN="YOUR_CLOUDFLARE_API_TOKEN_HERE"
EOF
chmod 600 /etc/dehydrated/cloudflare.env
YOUR_CLOUDFLARE_API_TOKEN_HERE with your actual Cloudflare API token.
Create the hook directory:
mkdir -p /etc/dehydrated/hooks
Create /etc/dehydrated/hooks/cloudflare.sh:
cat > /etc/dehydrated/hooks/cloudflare.sh << 'HOOKEOF'
#!/usr/bin/env bash
source /etc/dehydrated/cloudflare.env
deploy_challenge() {
local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"
local BASE_DOMAIN=$(echo "$DOMAIN" | sed 's/^\*\.//' | rev | cut -d. -f1-2 | rev)
echo "[Cloudflare Hook] Looking up zone for: ${BASE_DOMAIN}"
local ZONE_RESPONSE=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/zones?name=${BASE_DOMAIN}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json")
local ZONE_ID=$(echo "$ZONE_RESPONSE" | jq -r '.result[0].id')
if [[ "$ZONE_ID" == "null" ]] || [[ -z "$ZONE_ID" ]]; then
echo "[Cloudflare Hook] ERROR: Could not find zone for ${BASE_DOMAIN}"
return 1
fi
local RECORD_NAME="_acme-challenge.${DOMAIN}"
RECORD_NAME=$(echo "$RECORD_NAME" | sed 's/\*\.//')
local RESULT=$(curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"type\":\"TXT\",\"name\":\"${RECORD_NAME}\",\"content\":\"${TOKEN_VALUE}\",\"ttl\":120}")
local SUCCESS=$(echo "$RESULT" | jq -r '.success')
if [[ "$SUCCESS" == "true" ]]; then
echo "[Cloudflare Hook] TXT record created successfully"
echo "[Cloudflare Hook] Waiting 30 seconds for DNS propagation..."
sleep 30
else
echo "[Cloudflare Hook] ERROR creating TXT record: $RESULT"
return 1
fi
}
clean_challenge() {
local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"
local BASE_DOMAIN=$(echo "$DOMAIN" | sed 's/^\*\.//' | rev | cut -d. -f1-2 | rev)
local ZONE_ID=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/zones?name=${BASE_DOMAIN}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" | jq -r '.result[0].id')
[[ "$ZONE_ID" == "null" ]] || [[ -z "$ZONE_ID" ]] && return 0
local RECORD_NAME="_acme-challenge.${DOMAIN}"
RECORD_NAME=$(echo "$RECORD_NAME" | sed 's/\*\.//')
local RECORD_ID=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?type=TXT&name=${RECORD_NAME}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" | jq -r ".result[] | select(.content==\"${TOKEN_VALUE}\") | .id")
if [[ -n "$RECORD_ID" ]] && [[ "$RECORD_ID" != "null" ]]; then
curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" > /dev/null
echo "[Cloudflare Hook] TXT record deleted"
fi
}
deploy_cert() {
local DOMAIN="${1}"
echo "[Cloudflare Hook] Certificate deployed for ${DOMAIN}"
systemctl reload nginx 2>/dev/null && echo "[Cloudflare Hook] nginx reloaded" || true
systemctl restart freeswitch 2>/dev/null && echo "[Cloudflare Hook] freeswitch restarted" || true
}
unchanged_cert() {
local DOMAIN="${1}"
echo "[Cloudflare Hook] Certificate for ${DOMAIN} is still valid, skipping renewal"
}
HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|unchanged_cert)$ ]]; then
"$HANDLER" "$@"
fi
HOOKEOF
chmod +x /etc/dehydrated/hooks/cloudflare.sh
Add the following to /etc/dehydrated/config:
cat >> /etc/dehydrated/config << 'EOF'
# ===== Cloudflare DNS-01 Configuration for Wildcard Certs =====
CHALLENGETYPE="dns-01"
HOOK="/etc/dehydrated/hooks/cloudflare.sh"
EOF
grep -E "^(CHALLENGETYPE|HOOK)=" /etc/dehydrated/config
CHALLENGETYPE="dns-01"HOOK="/etc/dehydrated/hooks/cloudflare.sh"
Edit /etc/dehydrated/domains.txt:
*.pbx.example.com > pbx.example.com
This requests a wildcard certificate for *.pbx.example.com and stores it in a directory named pbx.example.com.
For a FusionPBX server at pbx.example.com with tenant subdomains:
*.pbx.example.com > pbx.example.com
Force a renewal test:
/usr/local/sbin/dehydrated -c --force
Expected flow:
_acme-challenge TXT record.fullchain.pem.FusionPBX typically installs a cron job for dehydrated. Verify it exists:
cat /etc/cron.d/dehydrated 2>/dev/null || crontab -l | grep dehydrated
If no cron exists, create one:
cat > /etc/cron.d/dehydrated << 'EOF'
# Dehydrated SSL certificate renewal
0 3 * * * root /usr/local/sbin/dehydrated -c >> /var/log/dehydrated.log 2>&1
EOF
Cause: API token does not have access to the zone, or the zone name is incorrect.
Solution:
source /etc/dehydrated/cloudflare.env
curl -s "https://api.cloudflare.com/client/v4/zones?name=example.com" \
-H "Authorization: Bearer ${CF_TOKEN}" | jq .
Cause: token lacks DNS edit permission or a stale _acme-challenge TXT record exists.
Solution:
_acme-challenge TXT records.Reload services:
systemctl reload nginx
systemctl restart freeswitch
Increase the wait in deploy_challenge from sleep 30 to sleep 60.
| File | Purpose |
|---|---|
/etc/dehydrated/config |
Main dehydrated configuration |
/etc/dehydrated/domains.txt |
List of domains to manage |
/etc/dehydrated/cloudflare.env |
Cloudflare API credentials |
/etc/dehydrated/hooks/cloudflare.sh |
DNS-01 challenge hook script |
/etc/dehydrated/certs/ |
Generated certificates |
/var/log/dehydrated.log |
Renewal log |
apt-get update && apt-get install -y jq curl
cat > /etc/dehydrated/cloudflare.env << 'EOF'
CF_TOKEN="YOUR_TOKEN_HERE"
EOF
chmod 600 /etc/dehydrated/cloudflare.env
cat >> /etc/dehydrated/config << 'EOF'
CHALLENGETYPE="dns-01"
HOOK="/etc/dehydrated/hooks/cloudflare.sh"
EOF
/usr/local/sbin/dehydrated -c --force
/usr/local/sbin/dehydrated -c --force
openssl x509 -in /etc/dehydrated/certs/your-domain/fullchain.pem -noout -dates
/etc/dehydrated/cloudflare.env with chmod 600.root:root./var/log/dehydrated.log for renewal failures.Document Last Updated: April 30, 2026
Applies To: FusionPBX hosts using Cloudflare DNS validation and ictVoIP WHMCS FusionPBX integrations
Ownership: Developed and maintained by ictVoIP Canada