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 and FS PBX servers using Let's Encrypt with Cloudflare DNS-01 challenge.
This guide covers two approaches for wildcard SSL certificates:
php artisan app:install-lets-encrypt-certificate) only supports single-domain certificates via HTTP-01 challenge. It cannot generate wildcard certificates. For wildcard SSL on FS PBX, you must use the manual dehydrated + Cloudflare DNS-01 method (Method 1) described in this guide.
Wildcard certificates (e.g., *.pbx.example.com) allow you to secure all subdomains under a single certificate. This is ideal for multi-tenant FusionPBX/FS PBX deployments where tenant domains are created dynamically.
| Requirement | Details |
|---|---|
| FusionPBX / FS PBX | 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 FS PBX servers with a main host (e.g., pbx.example.com) and wildcard tenant subdomains (e.g., *.tenant.example.com), you need two separate certificates:
*.tenant.example.com > tenant.example.com
pbx.example.com > pbx.example.com
php artisan app:install-lets-encrypt-certificate). Use this guide only for the wildcard certificate for tenant subdomains.
For a FusionPBX server at pbx.example.com with tenant subdomains:
*.pbx.example.com > pbx.example.com
For FS PBX with a main host and wildcard tenant subdomains, follow these substeps in order:
Before installing any certificates, configure the FS PBX .env file for wildcard session support:
cd /var/www/fspbx
nano .env
Update these settings:
APP_URL=https://pbx.example.com
SESSION_DOMAIN=.tenant.example.com
SANCTUM_STATEFUL_DOMAINS=pbx.example.com,*.tenant.example.com
Then clear the config cache:
php artisan config:cache
pbx.example.com with your main host domain and tenant.example.com with your wildcard base domain. The SESSION_DOMAIN should have a leading dot (e.g., .tenant.example.com) to work across all subdomains.
Now install the main host certificate using the FS PBX artisan command:
cd /var/www/fspbx
php artisan app:install-lets-encrypt-certificate
Enter your main host domain (e.g., pbx.example.com). This configures nginx automatically for the main host.
/usr/bin/dehydrated rather than /usr/local/sbin/dehydrated. Create a symlink before running the test:
ln -s /usr/bin/dehydrated /usr/local/sbin/dehydrated
Now generate the wildcard certificate:
/usr/local/sbin/dehydrated -c --force
Expected flow:
_acme-challenge TXT record.fullchain.pem for both main host and wildcard domains.Create a separate nginx server block for wildcard subdomains. This configuration mirrors the main fspbx.conf but uses the wildcard certificate:
ls /var/run/php/ to find the correct socket path (e.g., php8.4-fpm.sock). Update the fastcgi_pass lines below with your actual PHP version.
cat > /etc/nginx/sites-available/wildcard-tenant.conf << 'EOF'
server {
listen 80;
server_name *.tenant.example.com;
root /var/www/fspbx/public;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ^~ /.well-known/acme-challenge {
default_type "text/plain";
auth_basic "off";
alias /var/www/dehydrated;
}
location = /logout.php {
rewrite ^.*/logout.php?$ https://$host/logout permanent;
}
location = /login.php {
rewrite ^.*/login.php?$ https://$host/login permanent;
}
if ($args ~* "/?domain_uuid=([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})&domain_change=true") {
rewrite ^ https://$host/domains/switch/$arg_domain_uuid? last;
}
location /core/domains/domain_json.php {
rewrite ^ https://$host/domains/filter/?$args permanent;
}
client_max_body_size 80M;
client_body_buffer_size 128k;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/fspbx/public$fastcgi_script_name;
}
location = /core/upgrade/index.php {
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/fspbx/public$fastcgi_script_name;
fastcgi_read_timeout 15m;
}
location ~ .htaccess { deny all; }
location ~ .htpassword { deny all; }
location ~^.+.(db)$ { deny all; }
location ~ /\.git { deny all; }
location ~ /\.lua { deny all; }
location ~ /\. { deny all; }
}
server {
listen 443 ssl;
server_name *.tenant.example.com;
root /var/www/fspbx/public;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
include snippets/fspbx-reverb.conf;
ssl_certificate /etc/dehydrated/certs/tenant.example.com/fullchain.pem;
ssl_certificate_key /etc/dehydrated/certs/tenant.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!ADH:!MD5:!aNULL;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Accept-Ranges bytes;
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ^~ /.well-known/acme-challenge {
default_type "text/plain";
auth_basic "off";
alias /var/www/dehydrated;
}
location = /logout.php {
rewrite ^.*/logout.php?$ https://$host/logout permanent;
}
location = /login.php {
rewrite ^.*/login.php?$ https://$host/login permanent;
}
if ($args ~* "/?domain_uuid=([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})&domain_change=true") {
rewrite ^ https://$host/domains/switch/$arg_domain_uuid? last;
}
location = /core/domains/domain_json.php {
rewrite ^ https://$host/domains/filter/?$args permanent;
}
location = /app/call_center_active/call_center_queue.php {
rewrite ^ https://$host/contact-center permanent;
}
location ~* ^/app/call_center_active/call_center_active.php {
return 301 https://$host/contact-center;
}
client_max_body_size 80M;
client_body_buffer_size 128k;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/fspbx/public$fastcgi_script_name;
}
location = /core/upgrade/index.php {
fastcgi_pass unix:/var/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/fspbx/public$fastcgi_script_name;
fastcgi_read_timeout 15m;
}
location ~ .htaccess { deny all; }
location ~ .htpassword { deny all; }
location ~ /\.git { deny all; }
location ~ /\.lua { deny all; }
location ~ /\. { deny all; }
}
EOF
ln -s /etc/nginx/sites-available/wildcard-tenant.conf /etc/nginx/sites-enabled/
nginx -t
nginx -s reload
*.tenant.example.com and tenant.example.com with your actual wildcard domainphp8.4-fpm.sock to match your PHP version (check with ls /var/run/php/)include snippets/fspbx-reverb.conf; line enables WebSocket support for FS PBX featuresfspbx.conf but uses the wildcard SSL certificateFusionPBX and FS PBX typically install a cron job for dehydrated. Verify it exists:
cat /etc/cron.d/dehydrated 2>/dev/null || crontab -l | grep dehydrated
Expected output:
# Dehydrated SSL certificate renewal
0 3 * * * root /usr/local/sbin/dehydrated -c >> /var/log/dehydrated.log 2>&1
This cron job runs daily at 3:00 AM and automatically renews certificates when they're within 30 days of expiration.
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
Recent FusionPBX installations include a helper script that integrates with dehydrated to issue certificates, including wildcard certificates via DNS-01 challenges.
# Navigate to the FusionPBX scripts directory
cd /var/www/fusionpbx/resources/pki/scripts
# Run the Let's Encrypt helper script
./letsencrypt.sh
When prompted, provide:
*.pbx.example.comThe script will display the exact TXT record value that must be added to your DNS provider:
Add the TXT record in your DNS provider interface:
_acme-challenge.pbx.example.comWait for DNS propagation (typically 30-60 seconds):
dig _acme-challenge.pbx.example.com TXT
Return to the script and press Enter to continue when the record is visible.
Once the challenge is validated, the script will:
fullchain.pem, privkey.pem) to the dehydrated certificate directoryThe certificate path is typically:
/etc/dehydrated/certs/pbx.example.com/
For tenant-specific subdomains (e.g., tenant1.pbx.example.com), configure additional HTTPS virtual hosts in nginx:
server {
listen 443 ssl http2;
server_name tenant1.pbx.example.com;
root /var/www/fusionpbx;
ssl_certificate /etc/dehydrated/certs/pbx.example.com/fullchain.pem;
ssl_certificate_key /etc/dehydrated/certs/pbx.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Include standard FusionPBX location blocks
include /etc/fusionpbx/nginx_location.conf;
}
Ensure DNS A/AAAA records for each tenant subdomain point to the FusionPBX server.
When the certificate expires (every 90 days), repeat the process:
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
Cause: nginx wildcard server block (server_name *.tenant.example.com) matches the main host as a subdomain.
Solution: Ensure the main host has a dedicated server block that comes before the wildcard block in nginx configuration. The FS PBX artisan command should create this automatically. Verify:
grep -n "server_name" /etc/nginx/sites-available/fspbx.conf
The main host server block should appear first in the file or be in a separate config file that loads alphabetically before the wildcard config.
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: June 26, 2026
Applies To: FusionPBX and FS PBX hosts using Cloudflare DNS validation and ictVoIP WHMCS FusionPBX integrations
Ownership: Developed and maintained by ictVoIP Canada