SSL Automation Guide

FusionPBX Wildcard SSL Certificate Automation

Automated Let's Encrypt certificate renewal with Cloudflare DNS-01 challenge.

Admin use: Use this guide to set up automated wildcard SSL certificate renewal on FusionPBX servers using Let's Encrypt with Cloudflare DNS-01 validation.

Overview

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.

Why Wildcard Certificates?

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.

Why DNS-01 Challenge?
  • HTTP-01 challenge (default) cannot validate wildcard domains
  • DNS-01 challenge validates domain ownership via DNS TXT records
  • With Cloudflare API integration, DNS-01 can be fully automated

Prerequisites

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

Step 1: Create Cloudflare API Token

1.1 Access Cloudflare Dashboard

  1. Log in to Cloudflare Dashboard.
  2. Click your profile icon and open My Profile.
  3. Select API Tokens from the left sidebar.

1.2 Create Token

  1. Click Create Token.
  2. Select Use template next to "Edit zone DNS".
  3. Configure permissions:
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.

1.3 Verify Token

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:

Expected response: {"success":true,"messages":[{"message":"This API Token is valid and active"}]}

Step 2: Install Dependencies

SSH into your FusionPBX server as root:

apt-get update && apt-get install -y jq curl

Step 3: Create Cloudflare Credentials File

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
Important: Replace YOUR_CLOUDFLARE_API_TOKEN_HERE with your actual Cloudflare API token.

Step 4: Create Cloudflare DNS Hook Script

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

Step 5: Configure Dehydrated for DNS-01

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

Verify Configuration

grep -E "^(CHALLENGETYPE|HOOK)=" /etc/dehydrated/config
Expected output:
CHALLENGETYPE="dns-01"
HOOK="/etc/dehydrated/hooks/cloudflare.sh"

Step 6: Configure Domains

Edit /etc/dehydrated/domains.txt:

Domain Format

*.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.

Example

For a FusionPBX server at pbx.example.com with tenant subdomains:

*.pbx.example.com > pbx.example.com

Step 7: Test Certificate Renewal

Force Renewal Test

Force a renewal test:

/usr/local/sbin/dehydrated -c --force

Expected Output

Expected flow:

  • Finds the Cloudflare zone.
  • Creates _acme-challenge TXT record.
  • Waits for DNS propagation.
  • Validates the challenge.
  • Removes the TXT record.
  • Creates fullchain.pem.
  • Reloads nginx and restarts FreeSWITCH.

Step 8: Verify Automatic Renewal (Cron)

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

Troubleshooting

Issue: Could not find zone

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 .

Issue: ERROR creating TXT record

Cause: token lacks DNS edit permission or a stale _acme-challenge TXT record exists.

Solution:

  1. Confirm the token has Zone → DNS → Edit permission.
  2. Check Cloudflare for stale _acme-challenge TXT records.
  3. Delete stale challenge records if needed.

Issue: Certificate not used after renewal

Reload services:

systemctl reload nginx
systemctl restart freeswitch

Issue: DNS propagation timeout

Increase the wait in deploy_challenge from sleep 30 to sleep 60.

File Locations Summary

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

Quick Reference

Initial Setup Commands

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

Manual Renewal

/usr/local/sbin/dehydrated -c --force

Check Certificate Expiry

openssl x509 -in /etc/dehydrated/certs/your-domain/fullchain.pem -noout -dates

Security Best Practices

  • Protect /etc/dehydrated/cloudflare.env with chmod 600.
  • Keep ownership as root:root.
  • Use scoped Cloudflare API tokens with minimal permissions.
  • Rotate tokens periodically.
  • Monitor /var/log/dehydrated.log for renewal failures.

Related Resources

  • Community
  • Documentation
  • Dehydrated Documentation
  • Let's Encrypt Documentation

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