Back to tutorials
Tutorial

Add Let’s Encrypt Wildcard SSL With Caddy and DNS

Add Let’s Encrypt wildcard SSL in Caddy using DNS challenge, automate renewals, and verify zero-downtime HTTPS on your VPS.

By Anurag Singh
Updated on Nov 13, 2026
Category: Tutorial
Share article
Add Let’s Encrypt Wildcard SSL With Caddy and DNS

A wildcard certificate solves a common problem: you keep adding subdomains (app, api, status, staging) and you don’t want to revisit TLS each time.

This walkthrough shows how to add Let’s Encrypt wildcard SSL to Caddy using a DNS challenge on a real VPS. You’ll also verify renewals, so a token issue doesn’t wake you up at 03:00.

This setup aims to stay “operator-friendly.” It keeps secrets out of config, keeps the parts count low, and uses logs you can follow.

It also includes an explicit renewal confidence check.

The same steps work on Ubuntu 24.04/26.04 LTS, Debian 12/13, AlmaLinux 9/10, and Rocky Linux 9/10.

What you’ll build (and why wildcard needs DNS)

You’ll end up with:

  • A wildcard certificate for *.example.com (and the apex example.com), issued by Let’s Encrypt.
  • Caddy configured to solve the ACME dns-01 challenge through your DNS provider’s API.
  • Automatic renewals, plus a repeatable way to confirm renewals won’t surprise you later.

Wildcard certificates require DNS validation. No CA can reliably prove control of “every possible subdomain” over HTTP.

With dns-01, you prove control by publishing a temporary _acme-challenge TXT record.

Caddy usually creates and removes this record via your DNS provider’s API during issuance and renewal.

Prerequisites checklist (VPS + DNS access)

  • A VPS or dedicated server with root or sudo access. If you want a clean host for multiple apps and subdomains, a HostMyCode VPS is a straightforward fit.
  • A domain name you control, and API access for its DNS provider (Cloudflare, Route 53, DigitalOcean DNS, etc.).
  • Ports 80/tcp and 443/tcp reachable from the internet.
  • Caddy installed (install commands below), plus one or more sites/apps you want to serve.

On DNS challenges: API automation is the sane option.

Manual TXT records work once, then become a renewal trap.

Step 1: Point DNS for your wildcard and apex

Before you touch certificates, make sure your domain resolves to the VPS.

For wildcard + apex you typically want:

  • A example.com → YOUR_SERVER_IP
  • A *.example.com → YOUR_SERVER_IP

If you’re using a CDN/proxy (for example, Cloudflare “proxied” records), DNS-based wildcard issuance still works.

Just make sure:

  • Your _acme-challenge TXT records can be created via API.
  • Caddy can reach Let’s Encrypt, and your origin is actually serving traffic on 80/443.

Quick verification from your laptop:

dig +short example.com A
dig +short test-subdomain.example.com A

Both should return your server IP (or your proxy/CDN IPs if you’re proxying; DNS-based issuance is still fine).

Step 2: Install Caddy from the official repository

Stick to the official Caddy packages. You get a proper service unit and clean updates.

Ubuntu / Debian

sudo apt update
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
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
sudo systemctl enable --now caddy

AlmaLinux / Rocky / RHEL

sudo dnf install -y 'dnf-command(copr)'
sudo dnf copr enable -y @caddy/caddy
sudo dnf install -y caddy
sudo systemctl enable --now caddy

Verify Caddy is running:

systemctl status caddy --no-pager
caddy version

If you’re migrating from another reverse proxy, plan the cutover.

If you’re coming from Nginx, keep this troubleshooting guide nearby: Nginx 502 Bad Gateway fix on Ubuntu VPS.

The usual causes carry over: upstream ports, sockets, and permissions.

Step 3: Decide your DNS provider plugin approach

To create TXT records automatically, Caddy needs a DNS provider module.

You generally have two options:

  • xcaddy build: compile Caddy with the DNS plugin you need. This is the common path, and it’s stable in production.
  • Use a distribution build that already includes your module (rare in standard repos).

In 2026, most teams use xcaddy for DNS modules.

The example below uses Cloudflare, then you can swap in your provider’s module.

Step 4: Build Caddy with the Cloudflare DNS module (example)

If your DNS is Cloudflare, you’ll build a Caddy binary with caddy-dns/cloudflare.

This keeps issuance inside Caddy. You don’t need a separate ACME client.

Install Go and xcaddy:

Ubuntu / Debian

sudo apt update
sudo apt install -y golang-go

AlmaLinux / Rocky

sudo dnf install -y golang

Build a custom Caddy:

sudo -i

# Install xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# Build Caddy with Cloudflare DNS module
/root/go/bin/xcaddy build --with github.com/caddy-dns/cloudflare@latest

# Replace the system Caddy binary (backup first)
mv /usr/bin/caddy /usr/bin/caddy.dist
mv ./caddy /usr/bin/caddy
chown root:root /usr/bin/caddy
chmod 755 /usr/bin/caddy

# Confirm module exists
caddy list-modules | grep -E 'dns.providers.cloudflare'
exit

Common mistake: building as a normal user, then losing track of where the compiled binary landed.

The commands above build as root so ./caddy is easy to install.

Building as a user is fine too. Just copy the resulting binary to /usr/bin/caddy.

Step 5: Create a DNS API token with least privilege

For Cloudflare, create an API token (skip the global API key).

Give it only what Caddy needs:

  • Permissions: Zone:DNS:Edit and Zone:Zone:Read
  • Scope: only the zone(s) you’ll issue certs for (example.com)

Store it on the server with tight permissions.

A practical option is an environment file that systemd loads for the Caddy service:

sudo install -d -m 0750 /etc/caddy
sudo bash -c 'cat > /etc/caddy/caddy.env <<EOF
CLOUDFLARE_API_TOKEN=put-your-token-here
EOF'
sudo chmod 0640 /etc/caddy/caddy.env
sudo chown root:caddy /etc/caddy/caddy.env

Why this matters: putting tokens directly in your Caddyfile increases leak risk (backups, screenshots, Git history).

An env file keeps secrets separate from config.

Step 6: Configure wildcard TLS in the Caddyfile

Edit /etc/caddy/Caddyfile.

This example serves two apps on different subdomains. It uses one wildcard certificate for all of them.

# /etc/caddy/Caddyfile
{
	# Optional: email shown to Let's Encrypt
	email admin@example.com

	# Helpful during setup; remove once stable
	# debug
}

# Wildcard + apex in one site block
example.com, *.example.com {
	# Use DNS challenge for wildcard issuance
	tls {
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
		# Avoid edge-case propagation races
		propagation_delay 30s
	}

	# Security headers (keep them boring and predictable)
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
	}

	# Route specific subdomains to different backends
	@api host api.example.com
	handle @api {
		reverse_proxy 127.0.0.1:3000
	}

	@app host app.example.com
	handle @app {
		reverse_proxy 127.0.0.1:8080
	}

	# Default: static landing page for the apex or any other subdomain
	root * /var/www/default
	file_server
}

Create the static root directory:

sudo mkdir -p /var/www/default
sudo bash -c 'echo "ok" > /var/www/default/health.txt'
sudo chown -R caddy:caddy /var/www/default

Now teach systemd to load your env file. Create an override:

sudo systemctl edit caddy

Paste:

[Service]
EnvironmentFile=/etc/caddy/caddy.env

Reload and validate config:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl daemon-reload
sudo systemctl restart caddy

Watch logs while Caddy obtains the certificate:

sudo journalctl -u caddy -f --no-pager

You should see ACME messages about creating TXT records and completing the order.

Step 7: Verify the wildcard certificate is actually in use

Test from your workstation (or from the server).

You’re looking for a clean handshake and the right names on the certificate.

Check with curl

curl -I https://example.com/health.txt
curl -I https://app.example.com/health.txt

You want HTTP/2 200 or HTTP/1.1 200, and no certificate warnings.

Inspect the cert with openssl

echo | openssl s_client -servername app.example.com -connect app.example.com:443 2>/dev/null | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

Confirm the SAN list includes DNS:*.example.com and DNS:example.com.

Common mistake: requesting only *.example.com and forgetting the apex example.com.

Wildcards don’t cover the root domain.

Step 8: Make renewals boring (and prove it now)

Caddy renews automatically. The real risk is a renewal that fails quietly until you hit expiry.

Treat the first rollout as your chance to make renewal failures obvious.

First, confirm where Caddy stores certificates:

sudo ls -la /var/lib/caddy/.local/share/caddy/certificates/

Then run a controlled reload (safe; active connections keep flowing):

sudo caddy reload --config /etc/caddy/Caddyfile

If you want an end-to-end failure test, temporarily restrict the DNS token in the provider UI (or rotate it).

Confirm Caddy complains loudly in logs. Then restore permissions.

The point is to verify your monitoring would catch the issue.

Also check Caddy’s timers and next run times:

systemctl list-timers --all | grep -i caddy || true

Caddy often handles renewals internally rather than through a visible systemd timer. That’s normal.

Your job is to make sure logs and alerts cover the renewal path.

Step 9: Add basic monitoring for TLS failures

A simple check goes a long way.

Use an HTTPS probe that warns you when the cert is close to expiry or the handshake fails.

On a small VPS, start with something you can read quickly. Wire it into alerting later.

Create a script:

sudo install -d /opt/healthchecks -m 0755
sudo nano /opt/healthchecks/check-tls.sh

Script content:

#!/usr/bin/env bash
set -euo pipefail

HOST="app.example.com"
PORT=443
DAYS_WARN=14

end_date=$(echo | openssl s_client -servername "$HOST" -connect "$HOST:$PORT" 2>/dev/null \
  | openssl x509 -noout -enddate | cut -d= -f2)

end_epoch=$(date -d "$end_date" +%s)
now_epoch=$(date +%s)

remaining_days=$(( (end_epoch - now_epoch) / 86400 ))

if [ "$remaining_days" -lt "$DAYS_WARN" ]; then
  echo "TLS for $HOST expires in $remaining_days days ($end_date)" 
  exit 2
fi

echo "TLS for $HOST OK: $remaining_days days remaining"

Make it executable:

sudo chmod +x /opt/healthchecks/check-tls.sh
/opt/healthchecks/check-tls.sh

Then add a cron job (example runs daily at 07:12). Adapt to your alerting method:

sudo crontab -e
12 7 * * * /opt/healthchecks/check-tls.sh | /usr/bin/logger -t tls-check

Now you can view results:

sudo journalctl -t tls-check --since "7 days ago" --no-pager

If your sites are mission-critical, move past basic probes and set up real monitoring.

A dedicated stack also fits nicely on a multi-site VPS; see Deploy Grafana on a VPS with Docker, TLS, and backups.

Step 10: Troubleshoot the real-world failure cases

Wildcard DNS challenges tend to fail in a few predictable ways.

These are the ones you can usually diagnose in minutes.

TXT record never shows up (propagation problems)

  • Increase propagation_delay in the tls block (try 60s).
  • Verify your DNS provider API token can edit the correct zone.
  • Check for split-horizon DNS (internal resolvers returning different results).

Check the TXT record directly during issuance:

dig +short _acme-challenge.example.com TXT

Wrong zone selected by provider module

This usually shows up with nested zones.

For example, dev.example.com might be its own zone.

Scope the token to the right zone, or issue for the specific zone you’re targeting.

Port 80/443 blocked (certificate issues that look like app issues)

Even with DNS challenges, clients still need your origin on :443.

Caddy also commonly binds to :80 for redirects and related ACME behavior.

Confirm listening sockets:

sudo ss -ltnp | grep -E '(:80|:443)'

On RHEL-family systems, also check firewalld:

sudo firewall-cmd --list-services
sudo firewall-cmd --add-service=http --add-service=https --permanent
sudo firewall-cmd --reload

“too many certificates already issued” / rate limits

If you iterate quickly, you can hit Let’s Encrypt rate limits. Don’t keep retrying against production.

Use staging while you’re still adjusting config:

{
	acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

Switch back to production once the flow is clean.

Step 11: Deploy multiple apps cleanly behind the wildcard

Once the wildcard cert is working, adding new apps becomes mostly “routing and ports.”

A pattern that stays readable:

  • One wildcard site block for example.com, *.example.com.
  • One handle per hostname/subdomain.
  • Each app runs on localhost (systemd service or Docker) and Caddy reverse-proxies to it.

If your backends run in Docker, keep them on a private bridge network.

Then proxy to 127.0.0.1 via published ports, or proxy directly via the Docker network name.

For a full example of VPS apps behind a proxy, the flow is similar to Run Umami Analytics on a VPS behind Nginx, except Caddy replaces Nginx and handles certificates.

Step 12: Back up what matters (and test restores)

The certificate itself isn’t precious. You can re-issue it.

What you don’t want to lose is your configuration and your DNS credentials handling.

Back up:

  • /etc/caddy/Caddyfile
  • /etc/caddy/caddy.env (or your secret store references)
  • Any app configs and database dumps

For databases, don’t stop at “we have backups.” Add restore tests.

If you run MySQL/MariaDB, follow Set up automatic MySQL backups with tested restores and schedule a monthly restore drill.

Summary: a repeatable wildcard setup for a multi-subdomain VPS

You now have Caddy issuing a wildcard certificate via DNS automation. It can serve multiple subdomains on one host.

You also have a practical way to validate renewals before they turn into downtime.

Keep the DNS token least-privileged. Keep secrets out of the Caddyfile.

Watch the logs during first issuance.

If you want this setup on infrastructure sized for multiple sites (with room for staging), start with a HostMyCode VPS.

If you’d rather offload OS updates and baseline hardening, managed VPS hosting is the low-drama option.

Running several subdomains on one server gets simpler once TLS is standardized. HostMyCode offers VPS plans that suit this setup, plus managed VPS hosting if you want Caddy, updates, and renewals monitored for you. If you’re moving from an existing host, use HostMyCode migrations to cut over with less downtime risk.

FAQ

Do I need a wildcard certificate if I only have 2–3 subdomains?

No. A normal SAN certificate is fine.

A wildcard is worth it when subdomains change often, or you’re running lots of small apps on one VPS.

Does *.example.com cover example.com?

No. Request both example.com and *.example.com in the same Caddy site block (as shown).

Can I use Cloudflare “proxied” records with DNS challenge?

Yes. The DNS challenge relies on TXT records, not HTTP reachability of the origin.

Your origin still needs to serve real user traffic on 80/443.

Where does Caddy store Let’s Encrypt certificates on Linux?

Typically under /var/lib/caddy/.local/share/caddy/.

The exact path can vary by distro packaging, but that directory is a good first check.

What’s the safest way to store the DNS API token?

Use a least-privilege token and load it via an environment file readable only by root and the Caddy user (or a secret manager).

Avoid committing tokens to Git.