Issue Wildcard TLS Certificates for Self-Hosted Apps with Caddy and DNS Challenge
Use one wildcard certificate for many subdomains by giving Caddy DNS challenge access on purpose, keeping provider credentials scoped tightly, and validating certificate issuance before you point more apps at it.
Before you begin
- Own a public DNS zone such as
example.com. - Know which subdomains should exist and which ones should stay private or not exist at all.
- Have one DNS provider in mind. This draft assumes Cloudflare as the concrete example.
- Be ready to run a custom Caddy build or package that includes the Cloudflare DNS module.
Caddy can manage ordinary certificates automatically, but wildcard certificates are different. Caddy's docs note that the DNS challenge requires provider credentials so the server can create and clear the validation TXT record. That is powerful, so treat the credential as an infrastructure secret, not a convenience token.
*.example.com by DNS challenge, plus a validation routine and a rollback path to hostname-specific certificates.
Step 1: Decide whether a wildcard certificate is the right move
A wildcard helps when you operate many web apps like app1.example.com, app2.example.com, and
grafana.example.com. It does not remove the need to manage which subdomains should exist, and it is not automatically
safer than ordinary per-host certificates.
- Use a wildcard when you already know multiple subdomains are part of the design.
- Skip it if you only run one or two hostnames. Ordinary Caddy automation is simpler.
- Do not use it as an excuse to expose private admin names publicly.
Step 2: Install a Caddy build with the DNS provider module
The DNS challenge is not enabled by magic. Caddy needs a DNS provider module. One practical way to build that on Ubuntu is
xcaddy.
sudo apt update
sudo apt install -y curl debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy golang-go
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
~/go/bin/xcaddy build --with github.com/caddy-dns/cloudflare
sudo systemctl stop caddy
sudo cp ./caddy /usr/bin/caddy
sudo setcap cap_net_bind_service=+ep /usr/bin/caddy
sudo systemctl start caddy
If you use another provider, swap the module. Keep the build method documented locally so future upgrades do not accidentally remove DNS challenge support.
Step 3: Store provider credentials safely
Create a token with the smallest DNS scope your provider allows. For Cloudflare, this usually means zone-scoped DNS edit access for the specific zone.
sudo mkdir -p /etc/caddy/env
sudo nano /etc/caddy/env/wildcard.env
Add:
CLOUDFLARE_API_TOKEN=replace-with-zone-scoped-token
Lock it down:
sudo chown root:caddy /etc/caddy/env/wildcard.env
sudo chmod 640 /etc/caddy/env/wildcard.env
If you run Caddy as a systemd service, add the environment file to the unit override:
sudo systemctl edit caddy
[Service]
EnvironmentFile=/etc/caddy/env/wildcard.env
sudo systemctl daemon-reload
Step 4: Configure the Caddyfile for wildcard issuance
This example uses one wildcard for the apps.example.com branch rather than for the entire root zone. Narrower scope is usually cleaner.
{
email admin@example.com
}
*.apps.example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
@grafana host grafana.apps.example.com
handle @grafana {
reverse_proxy 127.0.0.1:3000
}
@uptime host uptime.apps.example.com
handle @uptime {
reverse_proxy 127.0.0.1:3001
}
handle {
respond "No app mapped for this hostname" 404
}
}
Validate before reload:
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy
Keep the host routing explicit. The wildcard certificate should not become a wildcard application router with accidental default behavior.
Step 5: Validate issuance, logs, and renewal assumptions
Watch the logs during the first request or reload:
sudo journalctl -u caddy -f
Then verify the certificate returned by one mapped hostname:
echo | openssl s_client -connect grafana.apps.example.com:443 -servername grafana.apps.example.com 2>/dev/null | openssl x509 -noout -subject -issuer -dates
What you want to see:
- The certificate subject includes a wildcard SAN for the expected branch.
- Caddy logs show a successful certificate obtain flow instead of repeated challenge failures.
- Your apps respond normally after the certificate is in place.
Troubleshooting and rollback
Caddy says the DNS challenge failed.
Check that the DNS provider module is actually present, the token is loaded into the service environment, and the token has the right zone scope.
Validation is slow or flaky.
Some providers have propagation delay. Wait for TXT visibility, then retry instead of repeatedly reloading the service.
The wrong app answers for a hostname.
That is a routing mistake, not a certificate mistake. Tighten the host matchers and default handler.
You want to back out cleanly.
Remove the wildcard site block, restore ordinary hostname-specific site blocks, validate the Caddyfile, then reload Caddy.