Caddy is the only major web server that enables HTTPS by default. When you give it a domain name, it automatically obtains and renews a Let’s Encrypt certificate without any additional configuration. This makes it operationally simple, but there are important configuration options for production deployments: wildcard certificates, custom CA integration, internal hostnames, and multi-server deployments that need centralized certificate storage.
How Caddy handles TLS automatically
When Caddy sees a site address with a domain name (like example.com), it:
- Starts an HTTPS listener on port 443.
- Starts an HTTP listener on port 80 that redirects to HTTPS.
- Requests a certificate from Let’s Encrypt using the ACME HTTP-01 or TLS-ALPN-01 challenge.
- Stores the certificate in its data directory.
- Renews the certificate automatically before it expires.
No configuration is required for this to work — just provide a domain name.
Basic Caddyfile for a web application
example.com {
reverse_proxy localhost:8080
}
That is the complete configuration for an HTTPS reverse proxy. Caddy handles certificate issuance, renewal, HTTP-to-HTTPS redirect, and HTTP/2 automatically.
Multiple domains with different backends
api.example.com {
reverse_proxy localhost:3000
}
app.example.com {
reverse_proxy localhost:8080
}
static.example.com {
root * /var/www/static
file_server
}
Each domain gets its own certificate automatically.
Wildcard certificates with DNS challenge
Wildcard certificates (*.example.com) require the DNS-01 challenge. Install the DNS provider plugin for Caddy:
# Install Caddy with the Cloudflare plugin
# Using xcaddy to build a custom Caddy binary:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy build --with github.com/caddy-dns/cloudflare
# Or install from the official package with DNS plugins
Configure the Caddyfile:
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@api host api.example.com
handle @api {
reverse_proxy localhost:3000
}
@app host app.example.com
handle @app {
reverse_proxy localhost:8080
}
handle {
respond "Unknown subdomain" 404
}
}
Set the Cloudflare API token:
export CF_API_TOKEN=your-cloudflare-api-token
Or in a systemd service:
[Service]
Environment=CF_API_TOKEN=your-cloudflare-api-token
Using a custom certificate (internal CA or commercial)
When you have your own certificate from an internal CA or commercial provider:
example.com {
tls /etc/caddy/certs/example.com.crt /etc/caddy/certs/example.com.key
reverse_proxy localhost:8080
}
The certificate file should contain the full chain (leaf + intermediates). The key file should be the matching private key.
Caddy does not automatically renew custom certificates — you are responsible for updating the files and reloading Caddy.
Using Caddy’s ZeroSSL integration
Caddy supports ZeroSSL as an alternative to Let’s Encrypt:
{
acme_ca https://acme.zerossl.com/v2/DV90
acme_eab {
key_id your-eab-kid
mac_key your-eab-hmac-key
}
email admin@example.com
}
example.com {
reverse_proxy localhost:8080
}
Configuring Caddy for internal/private domains
For internal domains that are not publicly reachable, Caddy can act as its own CA:
{
# Use Caddy's internal CA for development
local_certs
}
internal.example.local {
reverse_proxy localhost:8080
}
With local_certs, Caddy issues certificates signed by a locally generated CA. Install the CA root in browsers/devices:
# Find the Caddy root CA
ls $(caddy environ | grep XDG_DATA_HOME | cut -d= -f2)/caddy/pki/authorities/local/
# Files: root.crt root.key
# Install in the system trust store (Debian/Ubuntu)
sudo cp root.crt /usr/local/share/ca-certificates/caddy-local-ca.crt
sudo update-ca-certificates
Setting minimum TLS version and cipher suites
example.com {
tls {
protocols tls1.2 tls1.3
ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_AES_128_GCM_SHA256 TLS_AES_256_GCM_SHA384
}
reverse_proxy localhost:8080
}
Note: Caddy’s defaults (TLS 1.2+ with modern ciphers) already pass most security scanner requirements. Only restrict cipher suites if your compliance policy explicitly requires it — Caddy’s defaults are good.
Adding security headers
example.com {
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server # Remove Server header
}
reverse_proxy localhost:8080
}
HTTPS with mutual TLS (client certificate authentication)
api.example.com {
tls {
client_auth {
mode require_and_verify
trusted_ca_cert_file /etc/caddy/certs/client-ca.crt
}
}
reverse_proxy localhost:3000
}
Clients must present a certificate signed by the CA in client-ca.crt. Connections without a valid certificate are rejected at the TLS level.
Centralizing ACME storage for clustered deployments
When running multiple Caddy instances behind a load balancer, all instances need access to the same TLS certificates. Use a centralized storage backend:
Redis-based certificate storage
Install the Redis storage module:
xcaddy build --with github.com/gamalan/caddy-tlsredis
{
storage redis {
host redis.example.com
port 6380
password "redis-password"
db 0
tls true
tls_insecure false
}
}
example.com {
reverse_proxy localhost:8080
}
Shared filesystem (NFS/EFS)
{
storage file_system {
root /mnt/shared/caddy
}
}
Whichever instance in the cluster performs the ACME challenge writes the certificate to shared storage. All other instances read from the same location.
Caddy JSON configuration (alternative to Caddyfile)
For programmatic configuration or CI/CD deployments:
{
"apps": {
"tls": {
"automation": {
"policies": [
{
"subjects": ["example.com"],
"issuers": [
{
"module": "acme",
"email": "admin@example.com",
"ca": "https://acme-v02.api.letsencrypt.org/directory"
}
]
}
]
}
},
"http": {
"servers": {
"myserver": {
"listen": [":443"],
"routes": [
{
"match": [{"host": ["example.com"]}],
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [{"dial": "localhost:8080"}]
}
]
}
],
"tls_connection_policies": [{}]
}
}
}
}
}
Apply the JSON config via the API:
curl -X POST -H "Content-Type: application/json" \
http://localhost:2019/load \
-d @caddy.json
Reloading configuration without restart
Caddy supports hot configuration reload:
# Via systemd
sudo systemctl reload caddy
# Or via the admin API
caddy reload --config /etc/caddy/Caddyfile
When certificates in custom tls blocks are renewed externally, update the files and reload Caddy. Let’s Encrypt certificates managed by Caddy never need manual reload — Caddy handles renewal internally.
Let’s Encrypt staging for testing
Use the staging server to avoid rate limits during testing:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
example.com {
reverse_proxy localhost:8080
}
Staging certificates are not trusted by browsers. Switch to the production URL when you are ready.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
Obtaining certificate... stuck | Port 80 blocked or domain DNS not pointing to server | Check firewall; verify DNS; use DNS challenge |
no certificate available for 'example.com' | Domain name mismatch or certificate not yet issued | Check Caddy logs with journalctl -u caddy -n 100 |
| Rate limit hit | Too many certificate requests | Use staging server; wait for rate limit window |
| Custom cert not updating after renewal | Caddy not reloaded after file update | Run systemctl reload caddy in renewal hook |
bind: permission denied on port 443 | Caddy running without cap_net_bind_service | On Linux: setcap cap_net_bind_service=+ep /usr/bin/caddy |
| Cluster instances getting different certs | No shared storage configured | Use Redis or NFS storage module |
Certificate renewal hook for custom certificates
sudo nano /etc/letsencrypt/renewal-hooks/deploy/caddy.sh
#!/bin/bash
DOMAIN=example.com
CADDY_CERT_DIR=/etc/caddy/certs
cp /etc/letsencrypt/live/${DOMAIN}/fullchain.pem ${CADDY_CERT_DIR}/${DOMAIN}.crt
cp /etc/letsencrypt/live/${DOMAIN}/privkey.pem ${CADDY_CERT_DIR}/${DOMAIN}.key
chown caddy:caddy ${CADDY_CERT_DIR}/${DOMAIN}.crt ${CADDY_CERT_DIR}/${DOMAIN}.key
chmod 640 ${CADDY_CERT_DIR}/${DOMAIN}.key
systemctl reload caddy
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/caddy.sh
Note: This hook is only needed for custom certificates managed outside Caddy. Certificates obtained by Caddy through ACME do not need a renewal hook.
Summary
Caddy’s automatic HTTPS requires no TLS configuration for public domains — add a domain name to the Caddyfile and Caddy handles everything. For wildcard certificates, add the DNS challenge provider and set the API credentials. For internal domains, use local_certs to have Caddy act as a private CA. For custom certificates from an external CA, provide the tls cert key paths and reload Caddy after renewal. For clustered deployments, configure shared certificate storage via Redis or a shared filesystem.