Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
9.0 KiB
ACME Wildcard DNS-01 Example
What this does: Issues wildcard certificates (e.g., *.example.com) from Let's Encrypt using DNS-01 challenge validation.
This example is ideal for:
- Issuing wildcard certificates (
*.example.com) - Services behind NAT, firewalls, or non-public networks
- Batch issuance of multiple domains in parallel
- Internal PKI with public DNS names
- Scenarios where you have programmatic access to your DNS provider's API
Prerequisites
Before running this example, you need:
- A domain name (e.g.,
example.com) that you control and can manage DNS records for - DNS provider credentials:
- Cloudflare (example included): API token with DNS:write permission + Zone ID
- Route53 (AWS): AWS access key + secret key
- Azure DNS: Azure subscription ID + credentials
- Other providers: See "Adapting for Other DNS Providers" below
- Docker and Docker Compose installed
Quick Start (Cloudflare)
Step 1: Get Cloudflare Credentials
- Log in to Cloudflare Dashboard
- Select your domain (e.g.,
example.com) - In the sidebar, find Zone ID (copy this)
- Go to Account Settings > API Tokens
- Create a new token with these scopes:
- Zone > Zone:Read (to list DNS records)
- Zone > DNS:Write (to create/delete challenge records)
- Copy the API token
Step 2: Set Environment Variables
Create a .env file in this directory:
# .env
CLOUDFLARE_API_TOKEN=your-api-token-here
CLOUDFLARE_ZONE_ID=your-zone-id-here
ACME_EMAIL=admin@example.com
DB_PASSWORD=your-secure-db-password
Or export them in your shell:
export CLOUDFLARE_API_TOKEN="your-api-token-here"
export CLOUDFLARE_ZONE_ID="your-zone-id-here"
export ACME_EMAIL="admin@example.com"
export DB_PASSWORD="your-secure-db-password"
Step 3: Make DNS Scripts Executable
chmod +x dns-hooks/*.sh
Step 4: Start the Stack
docker compose up -d
This starts:
- certctl-server (port 8443): Control plane and ACME orchestrator
- postgres: Certificate metadata database
- certctl-agent: Certificate deployment agent
Step 5: Access the Dashboard
Open your browser to http://localhost:8443
Step 6: Create a Wildcard Certificate
- Go to Issuers page
- Verify the ACME issuer is registered
- Go to Certificates > New Certificate
- Fill in:
- Issuer: ACME (Let's Encrypt)
- Common Name:
*.example.com - Subject Alt Names:
example.com(to also cover the root domain)
- Click Request
The renewal job will:
- Send a request to Let's Encrypt
- Run
dns-hooks/cloudflare-present.shto create_acme-challenge.example.comTXT record - Wait for Let's Encrypt to verify the TXT record
- Issue the certificate
- Run
dns-hooks/cloudflare-cleanup.shto delete the temporary TXT record
Step 7: Monitor the Job
Go to Jobs page to see the renewal progress:
- AwaitingCSR: Agent is generating the CSR
- Running: ACME challenge in progress (DNS record being validated)
- Completed: Certificate issued and stored
- Failed: Check logs for errors (e.g., DNS provider API issues)
How DNS-01 Works
The DNS-01 challenge proves you own a domain by creating a DNS TXT record:
_acme-challenge.example.com TXT "acme-validation-token-xxxxx"
Let's Encrypt then queries this TXT record. Once verified, it issues the certificate and certctl cleans up the TXT record.
Why DNS-01 is better than HTTP-01 for wildcards:
- HTTP-01 requires a public web server; DNS-01 works anywhere
- Wildcard certificates require DNS proof (not HTTP)
- DNS challenges can be solved for multiple domains in parallel
- No need for public IP or inbound port 80/443
Adapting for Other DNS Providers
The example uses Cloudflare, but certctl supports any DNS provider via pluggable shell scripts.
AWS Route53
Replace the CERTCTL_ACME_DNS_PRESENT_SCRIPT and CERTCTL_ACME_DNS_CLEANUP_SCRIPT in docker-compose.yml with:
./dns-hooks/route53-present.sh./dns-hooks/route53-cleanup.sh
Example script outline (using AWS CLI):
#!/bin/bash
DOMAIN="$1"
VALIDATION_TOKEN="$2"
# Get Route53 hosted zone ID for the domain
ZONE_ID=$(aws route53 list-hosted-zones --query \
"HostedZones[?Name=='$DOMAIN.'].Id" --output text | cut -d'/' -f3)
# Create TXT record
aws route53 change-resource-record-sets \
--hosted-zone-id "$ZONE_ID" \
--change-batch "{
\"Changes\": [{
\"Action\": \"CREATE\",
\"ResourceRecordSet\": {
\"Name\": \"_acme-challenge.$DOMAIN\",
\"Type\": \"TXT\",
\"TTL\": 120,
\"ResourceRecords\": [{\"Value\": \"\\\"$VALIDATION_TOKEN\\\"\"}]
}
}]
}"
Azure DNS
#!/bin/bash
DOMAIN="$1"
VALIDATION_TOKEN="$2"
# Set Azure credentials via environment variables
# AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, AZURE_TENANT_ID, etc.
az network dns record-set txt create \
--resource-group "$AZURE_RESOURCE_GROUP" \
--zone-name "$DOMAIN" \
--name "_acme-challenge" \
--ttl 120 \
--txt-value "$VALIDATION_TOKEN"
Generic DNS Provider (using dig + TSIG)
If your DNS provider supports NSUPDATE (RFC 2136):
#!/bin/bash
DOMAIN="$1"
VALIDATION_TOKEN="$2"
nsupdate <<EOF
zone $DOMAIN
update add _acme-challenge.$DOMAIN 120 TXT "$VALIDATION_TOKEN"
send
EOF
Manual DNS (for testing)
Replace scripts with no-ops during testing:
#!/bin/bash
echo "Please create: _acme-challenge.$1 TXT $2"
sleep 60 # Manual wait for you to create the record
Alternative: DNS-PERSIST-01 (Standing Records)
If your DNS provider supports it, use DNS-PERSIST-01 for zero-maintenance renewals.
Instead of creating a new TXT record for each renewal, you create one standing record once:
_validation-persist.example.com TXT "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/12345678"
Then every renewal uses the same record — no cleanup scripts needed!
To enable in docker-compose.yml:
CERTCTL_ACME_CHALLENGE_TYPE: dns-persist-01
CERTCTL_ACME_DNS_PERSIST_ISSUER_DOMAIN: letsencrypt.org
Certctl will:
- Fetch your ACME account URI
- Create the standing
_validation-persistrecord once - Reuse it for all future renewals (no per-renewal DNS updates)
Security Notes
- API Token Scope: Restrict Cloudflare/AWS tokens to DNS:write only (not full account access)
- Key Generation: This example uses agent-side key generation (
CERTCTL_KEYGEN_MODE=agent), which is production-standard. Private keys never leave the agent. - Script Safety: The DNS scripts run in the certctl-server container. For production:
- Validate script inputs (already done in certctl code)
- Log all API calls
- Monitor for failed DNS operations
- Use a separate proxy agent for DNS operations if needed
Troubleshooting
DNS record not created
Check the server logs:
docker logs certctl-server-dns01
Look for lines like:
[certctl DNS-01] Creating DNS record: _acme-challenge.example.comError: Cloudflare API failed: ...
Common issues:
- Missing or invalid
CLOUDFLARE_API_TOKEN - Invalid
CLOUDFLARE_ZONE_ID - API token doesn't have DNS:write permission
- Domain not in your Cloudflare account
DNS propagation timeout
If the TLS negotiation fails, it might be DNS caching. Increase the wait time in the script:
sleep 30 # Increase from 10 to 30 seconds
Let's Encrypt rate limits
Let's Encrypt has strict rate limits:
- 50 certificates per registered domain per week
- 5 duplicate certificates per domain per week
For testing, use the staging directory:
CERTCTL_ACME_DIRECTORY_URL: https://acme-staging-v02.api.letsencrypt.org/directory
(Staging certs won't be trusted by browsers, but don't count against rate limits.)
Job fails with "CSR generation timeout"
If your DNS provider is very slow, increase the timeout in the cleanup script or add a longer wait time:
sleep 60 # Wait 1 minute for DNS propagation
Next Steps
- Monitor renewals: Set up notifications (email, Slack, PagerDuty) for renewal events
- Deploy certificates: Configure target connectors (NGINX, HAProxy, Traefik) to automatically deploy issued certs
- Multi-domain: Use certificate profiles to group wildcard + subdomain certs
- Backup DNS scripts: Version control your DNS provider scripts in git
Files in This Example
- docker-compose.yml — Container stack definition with ACME DNS-01 configuration
- dns-hooks/cloudflare-present.sh — Creates
_acme-challengeTXT record (Cloudflare) - dns-hooks/cloudflare-cleanup.sh — Deletes
_acme-challengeTXT record (Cloudflare) - README.md — This file