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.

Caddy Wildcard TLS DNS challenge
Illustrated guide cover for Issue Wildcard TLS Certificates for Self-Hosted Apps with Caddy and DNS Challenge
Caddy • Wildcard TLS • DNS challenge

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.

Expected outcome: You will finish with a working Caddy configuration that can request *.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.
Warning: A wildcard certificate proves control of the DNS zone. It does not decide which applications should exist or which names are safe to publish.

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.