Caddy + Cloudflare TLS Configuration
Guide the user through setting up Caddy to automatically manage wildcard TLS certificates via Cloudflare DNS challenge. Do not configure reverse proxying or backend handlers unless explicitly asked.
1. Install Caddy
Offer two options and let the user choose.
Option A (recommended): Pre-built binary with cloudflare-dns plugin
Download a single binary already compiled with the Cloudflare DNS provider module:
curl -sL "https://caddyserver.com/api/download?os=linux&arch=amd64&p=github.com%2Fcaddy-dns%2Fcloudflare" -o /tmp/caddy
sudo mv /tmp/caddy /usr/bin/caddy
sudo chmod +x /usr/bin/caddy
caddy version
caddy list-modules | grep cloudflare
Verify the output shows dns.providers.cloudflare.
Optionally, also include the Cloudflare IP source module so the user can write trusted_proxies cloudflare instead of a long IP list:
curl -sL "https://caddyserver.com/api/download?os=linux&arch=amd64&p=github.com%2Fcaddy-dns%2Fcloudflare&p=github.com%2FWeidiDeng%2Fcaddy-cloudflare-ip" -o /tmp/caddy
Option B: apt install caddy from Ubuntu/Debian repos
sudo apt install -y caddy
caddy version
This version does not include the cloudflare-dns plugin, so you must use Caddy's static IP range source for trusted_proxies. The DNS challenge must rely on an environment variable (see Token section).
2. Secure Token Storage
Ask the user for their Cloudflare API Token. It needs Zone:DNS:Edit permission at minimum.
Give the user this command to run themselves - the token will not appear in terminal output or history:
read -s TOKEN && echo "$TOKEN" | sudo tee /etc/caddy/cloudflare.token > /dev/null && \
sudo chmod 600 /etc/caddy/cloudflare.token && \
sudo chown caddy:caddy /etc/caddy/cloudflare.token && \
unset TOKEN
Then check the token is valid:
TOKEN="$(sudo cat /etc/caddy/cloudflare.token)"
curl -s "https://api.cloudflare.com/client/v4/user/tokens/verify" -H "Authorization: Bearer $TOKEN"
For Option A (pre-built binary with plugin), the Caddyfile can reference the token file directly with:
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
But since Caddy's systemd service needs the env var, create an env file from the token:
sudo sh -c 'echo "CLOUDFLARE_API_TOKEN=$(cat /etc/caddy/cloudflare.token)" > /etc/caddy/cloudflare.env'
sudo chown caddy:caddy /etc/caddy/cloudflare.env
sudo chmod 600 /etc/caddy/cloudflare.env
Then load it in systemd:
sudo mkdir -p /etc/systemd/system/caddy.service.d
sudo tee /etc/systemd/system/caddy.service.d/override.conf > /dev/null <<'UNIT'
[Service]
EnvironmentFile=/etc/caddy/cloudflare.env
UNIT
sudo systemctl daemon-reload
For Option B (apt install without plugin), the environment variable approach is the only way since there's no cloudflare-dns module to parse a file argument.
3. Write the Caddyfile
Write /etc/caddy/Caddyfile. Always use sudo tee since the file is root-owned.
Global Options Block
The first block must be a global options block { ... }.
trusted_proxies goes inside a servers sub-block, not at the top level:
{
servers {
trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22
trusted_proxies_strict
}
}
If Option A was chosen and the WeidiDeng/caddy-cloudflare-ip module was included, use the shorthand:
{
servers {
trusted_proxies cloudflare
trusted_proxies_strict
}
}
trusted_proxies_strict enables right-to-left parsing of X-Forwarded-For, which is correct for Cloudflare's proxy behavior.
Site Blocks
Always include the wildcard with every subdomain. Putting a subdomain in a separate site block from its wildcard causes Caddy TLS routing conflicts.
Domain name pattern:
When the user says something like "ss.example.com" where the zone apex is example.com, the Caddy site block names must be:
ss.example.com, *.example.com, example.com { ... }
When the user says something like "kk.sub.example.com" where the parent is sub.example.com, use:
kk.sub.example.com, *.sub.example.com, sub.example.com { ... }
The wildcard is always *.<parent_domain>, not *.*.<apex>.
TLS configuration:
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
This uses the DNS-01 challenge via Cloudflare. The {env.VAR} placeholder reads the environment variable set up in step 2.
No reverse_proxy by default. Only add reverse_proxy <target> if the user explicitly asks for it. If asked, put the target on localhost:<port>.
Routing specific subdomains to specific backends
When the user wants a particular subdomain to go to a specific backend, use named matchers and handle blocks within the same site block — never split into separate blocks:
kk.sub.example.com, *.sub.example.com, sub.example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
@kk host kk.sub.example.com
handle @kk {
reverse_proxy localhost:23456
}
}
Subdomains without a matching handle block will have no backend (only TLS).
4. Cloudflare DNS Configuration
Tell the user to ensure these DNS records exist in Cloudflare:
*.<parent_domain>— A record pointing to the server IP<parent_domain>— A record pointing to the server IP
The proxy status (orange/grey cloud) depends on the domain depth:
- One-level subdomains (e.g.,
ss.example.com) — can use Proxied (orange cloud). Cloudflare's free Universal SSL covers*.example.com. - Multi-level subdomains (e.g.,
kk.sub.example.com) — must use DNS Only (grey cloud). Cloudflare's free Universal SSL only matches*.example.com, not*.sub.example.com. Using Proxied causes ERR_SSL_VERSION_OR_CIPHER_MISMATCH because Cloudflare's edge has no certificate covering that depth.
Explain this limitation clearly. The user can work around it by upgrading to Cloudflare Pro or Advanced Certificate Manager.
5. Reload and Verify
sudo systemctl reload caddy
If reload hangs (common when a new certificate is being issued), wait. Wildcard DNS challenges can take a few seconds.
Verify certificates:
sudo journalctl -u caddy --since "1 min ago" --no-hostname -o short | grep -E "obtained|error"
Verify the site is serving:
curl -k --max-time 5 --resolve '<domain>:443:127.0.0.1' https://<domain>/
Common Pitfalls
trusted_proxies at top level: Must be inside
servers {}block, not a bare global option. Using it directly under{ }gives "unrecognized global option".Separate site blocks for overlapping hostnames: If
kk.example.commatches bothkk.example.com { ... }AND*.example.com { ... }, Caddy TLS routing breaks. Always merge overlapping names into one block.Token in a separate file: When using
dns cloudflare /path/to/token, the token file must have no trailing newline and no quotes. The env-var approach ({env.VAR}) is more reliable.Cloudflare plan limits: Free plans do not support
trusted_proxies cloudflare(needs extra plugin) or uploading custom edge certificates (needs Pro plan).reload vs restart:
systemctl reload caddyapplies config changes gracefully without dropping connections. Only userestartwhen the binary has been replaced.