Caddy
Write a Caddyfile that proxies, serves files and issues its own certificates, run Caddy in a container, drive it through the admin API and fix ACME and upstream failures.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Validate a Caddyfile | caddy validate --config /etc/caddy/Caddyfile |
| Format it in place | caddy fmt --overwrite /etc/caddy/Caddyfile |
| Show the JSON it becomes | caddy adapt --config /etc/caddy/Caddyfile --pretty |
| Reload with zero downtime | caddy reload --config /etc/caddy/Caddyfile or systemctl reload caddy |
| Run in the foreground | caddy run --config /etc/caddy/Caddyfile |
| Running config as JSON | curl -s localhost:2019/config/ | jq |
| Upstream health | curl -s localhost:2019/reverse_proxy/upstreams | jq |
| Trust the internal CA on this machine | caddy trust |
Hash a password for basic_auth | caddy hash-password (prompts) |
| One-off static server | caddy file-server --listen :8080 --root . --browse |
| One-off reverse proxy | caddy reverse-proxy --from :8080 --to localhost:9000 |
| Version and modules | caddy version, caddy list-modules |
| Build with a DNS plugin | xcaddy build --with github.com/caddy-dns/cloudflare |
| Logs of the systemd service | journalctl -u caddy -f |
| Where certificates live (systemd package) | ls /var/lib/caddy/.local/share/caddy/certificates/ |
| Test a site without DNS | curl -sv --resolve app.example.com:443:127.0.0.1 https://app.example.com/ |
| Check which cert is served | openssl s_client -connect 127.0.0.1:443 -servername app.example.com </dev/null | openssl x509 -noout -issuer -dates |
| Stop gracefully | caddy stop |
Syntax below is Caddy 2.10 or later; the basic_auth directive was basicauth before 2.8 and the dns global option arrived in 2.10. Reference: the Caddyfile documentation.
How Caddy is configured#
Caddy’s native configuration is JSON, held in memory and changed through the admin API on localhost:2019. A Caddyfile is a config adapter: caddy run reads it, adapts it to JSON and loads that. caddy adapt shows the result, which is the fastest way to see what a directive really did. Directives in a site block are sorted into a fixed order (handle, redir, reverse_proxy, file_server and so on) regardless of how you wrote them; the directive order matters when two directives could both handle a request, and route {} disables the sorting inside its block.
/etc/caddy/Caddyfile # the packaged systemd service reads this
/var/lib/caddy/.local/share/caddy/ # $XDG_DATA_HOME/caddy: certificates, keys, internal CA, OCSP
/var/lib/caddy/.config/caddy/autosave.json # last loaded config, used by caddy run --resume{
email admin@example.com # ACME account contact; also enables expiry notices
admin localhost:2019 # default; "admin off" disables reloads via API
servers {
trusted_proxies static 192.0.2.0/24 # believe X-Forwarded-* from these addresses only
}
log {
output file /var/log/caddy/caddy.log
format json
}
}
(security) { # snippet: reusable block
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
-Server # remove the header
}
}
app.example.com {
import security
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
static.example.com {
import security
root * /srv/www/static
file_server
}
import /etc/caddy/sites/*.caddy # one file per siteSite addresses decide the listener and the certificate: example.com means HTTPS on 443 with a public certificate, http://example.com disables TLS for that site, :8080 listens on plain HTTP on every interface, localhost and *.local names get the internal CA. Several addresses share a block with commas. Environment variables are read at adapt time with {$NAME} and at request time with {env.NAME}; use {$NAME} for values in site addresses and {env.NAME} inside handlers.
Automatic HTTPS#
For every site address that looks like a public name, Caddy obtains a certificate from Let’s Encrypt (falling back to ZeroSSL), renews it at two thirds of its lifetime, serves OCSP staples where the CA provides them and redirects HTTP to HTTPS. Challenges are HTTP-01 on port 80 and TLS-ALPN-01 on port 443, tried in turn, so both ports must be reachable from the internet at the public address the name resolves to. Wildcards and names that do not resolve publicly need DNS-01, which needs a DNS plugin.
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory # stage while testing to avoid rate limits
# acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} # default DNS challenge for all sites
}
internal.example.com {
tls internal # signed by Caddy's own CA; clients must trust its root
reverse_proxy 127.0.0.1:9090
}
legacy.example.com {
tls /etc/ssl/legacy.pem /etc/ssl/legacy.key # bring your own; Caddy then does not manage it
reverse_proxy 127.0.0.1:9091
}
http://plain.example.com { # HTTP only, no redirect, no certificate
reverse_proxy 127.0.0.1:9092
}The internal CA is created on first use under pki/authorities/local in the data directory. caddy trust installs its root into the system and browser trust stores of the machine Caddy runs on (it calls the admin API for the certificate, so Caddy must be running); copy root.crt to other machines and install it as described on the TLS page. The intermediate rotates every seven days and the root lasts ten years, so trust the root, never the intermediate.
Certificates and the ACME account key live in the data directory. Losing it means re-issuing every certificate on restart, which walks into the Let’s Encrypt rate limits quickly; back it up and, in containers, mount it as a volume.
Wildcard certificates with DNS plugins#
Standard builds have no DNS provider modules. Build one in with xcaddy or use caddy add-package on a packaged install, then use the dns subdirective of tls (per site) or acme_dns (global) with the credentials in environment variables, never in the Caddyfile.
xcaddy build --with github.com/caddy-dns/cloudflare # produces ./caddy
caddy add-package github.com/caddy-dns/cloudflare # replaces the binary in place; the systemd service needs a restart
caddy list-modules | grep dns.providers*.example.com, example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
resolvers 1.1.1.1 8.8.8.8 # query these for propagation checks instead of the local resolver
propagation_timeout 2m
}
@app host app.example.com
handle @app {
reverse_proxy 127.0.0.1:8080
}
@grafana host grafana.example.com
handle @grafana {
reverse_proxy 127.0.0.1:3000
}
handle { # unknown subdomains: close the connection
abort
}
}One site block with a wildcard address serves every subdomain under one certificate, which keeps hostnames out of the public certificate transparency logs. Each handle with a host matcher is one virtual host. For the systemd service, put the token in /etc/caddy/caddy.env with mode 0600 and reference it from a drop-in with EnvironmentFile=, as covered on the systemd page. Split-horizon DNS is the other reason for DNS-01: an internal name that resolves only on the LAN can still get a public certificate.
Reverse proxy#
reverse_proxy keeps the client’s Host header, sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host, and upgrades WebSocket connections without any extra directives. Retrying, buffering and TLS to the upstream are subdirectives.
app.example.com {
reverse_proxy 127.0.0.1:8080 {
header_up X-Real-IP {remote_host} # some apps read this instead of X-Forwarded-For
header_up Host {upstream_hostport} # only if the backend needs its own name in Host
header_down -Server
transport http {
dial_timeout 5s
response_header_timeout 60s # 504 upstream after this
read_buffer 8KiB
}
flush_interval -1 # stream responses immediately (SSE); auto-detected for text/event-stream
}
}
api.example.com {
handle_path /v1/* { # strips /v1 before proxying; "handle" would keep it
reverse_proxy 127.0.0.1:8081
}
handle {
respond 404
}
}
secure.example.com {
reverse_proxy https://backend.internal:8443 { # https:// scheme turns on TLS to the upstream
transport http {
tls_trust_pool file /etc/caddy/internal-ca.pem # 2.8+; or tls_insecure_skip_verify for testing only
tls_server_name backend.internal
}
}
}X-Forwarded-* headers from the client are stripped and replaced unless the connection comes from an address listed under trusted_proxies, so Caddy behind another proxy needs that global option or every log shows the front proxy’s address. Placeholders usable in header_up include {remote_host}, {host}, {uri}, {http.request.tls.client.subject} and any request header as {header.Name}.
Load balancing and health checks#
app.example.com {
reverse_proxy 10.0.0.11:8080 10.0.0.12:8080 10.0.0.13:8080 {
lb_policy least_conn # default random; also round_robin, ip_hash, first, cookie, header, uri_hash
lb_try_duration 5s # keep trying other upstreams for this long on failure
lb_try_interval 250ms
lb_retries 2
health_uri /healthz # active check; without it only passive checks apply
health_interval 10s
health_timeout 3s
health_status 200
health_passes 2 # 2.8+: consecutive passes before marking healthy
health_fails 3
fail_duration 30s # passive: remember a failure this long
max_fails 2 # failures within fail_duration before the upstream is skipped
unhealthy_status 5xx
unhealthy_latency 2s
}
}With first policy and an upstream marked backup you get active/standby. Retries are only safe for idempotent requests; lb_retry_match restricts them, and by default only requests that never reached the upstream are retried. curl -s localhost:2019/reverse_proxy/upstreams | jq shows each upstream’s healthy flag and in-flight request count and is the first thing to check when a proxy returns 502 for one backend.
Static files, redirects and matchers#
www.example.com {
root * /srv/www/example
encode zstd gzip
try_files {path} {path}/ /index.html # single-page app fallback
file_server {
hide .git .env*
precompressed br gzip # serve file.br if it exists
}
@immutable path /assets/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
}
files.example.com {
root * /srv/files
file_server browse # directory listing
}
example.com {
redir https://www.example.com{uri} permanent # 301; "temporary" is 302; default is 302
}
old.example.com {
redir /docs/* /help{uri} 301
handle_path /legacy/* {
rewrite * /new{uri} # internal rewrite, client sees nothing
reverse_proxy 127.0.0.1:8080
}
}A matcher is a token starting with @ that a directive accepts as its first argument, or * for everything, or a path prefix such as /api/*. Named matchers combine conditions with AND; repeat a condition for OR.
@internal remote_ip 192.0.2.0/24 10.0.0.0/8
@api {
path /api/*
method GET POST
not header Authorization ""
}
@bots header_regexp User-Agent (?i)(curl|python-requests)
@websocket {
header Connection *Upgrade*
header Upgrade websocket
}
@expr expression {path}.startsWith("/admin") && {method} != "GET"
respond @bots 403
handle @internal { reverse_proxy 127.0.0.1:9000 }Path matching is exact unless the pattern ends with *, and /api* also matches /apiary. handle blocks are mutually exclusive: the first whose matcher matches wins, and an unmatched handle acts as the fallback. Directives outside any handle apply to every request in the site.
Basic auth and access control#
metrics.example.com {
basic_auth {
alice $2a$14$Zkx19WLrIFnP3cU9ov8mxeP0v9v8rKKvz4kJcT8i0J5WQt3z8I3S6 # from caddy hash-password
}
reverse_proxy 127.0.0.1:9090
}
admin.example.com {
@office remote_ip 192.0.2.0/24
handle @office {
reverse_proxy 127.0.0.1:8080
}
respond "forbidden" 403
}
sso.example.com {
forward_auth 127.0.0.1:9091 { # ask an auth service first; 2xx lets the request through
uri /api/verify
copy_headers Remote-User Remote-Groups
}
reverse_proxy 127.0.0.1:8080
}caddy hash-password produces a bcrypt hash and reads the password from a prompt, so it never appears in shell history. Basic auth over HTTPS only; Caddy will happily serve it on an http:// site. Sites that serve the admin API or metrics to the internet need one of these in front.
Snippets, imports and reuse#
(proxy) {
reverse_proxy {args[0]} {
header_up X-Real-IP {remote_host}
transport http {
dial_timeout 5s
}
}
}
(auth) {
basic_auth {
{args[0]} {args[1]}
}
}
app.example.com {
import proxy 127.0.0.1:8080
}
grafana.example.com {
import auth alice {$GRAFANA_HASH}
import proxy 127.0.0.1:3000
}Snippets are defined at the top level, take positional arguments as {args[0]} (or {args[:]} for all), and are expanded by import before adaptation, so caddy adapt shows the result. import also takes file globs, and a file imported from another directory resolves its own relative paths from the importing file. The caddy fmt output is the canonical style: tabs, one directive per line.
Running in Docker or Podman#
The official image is caddy:2; caddy:2-builder has xcaddy for plugins. Persist /data (certificates, CA, ACME account) and /config (autosave), and publish 443/udp for HTTP/3.
FROM caddy:2-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddyservices:
caddy:
build: .
restart: unless-stopped
ports: ["80:80", "443:443", "443:443/udp"]
environment:
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN} # from .env, not committed
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro,Z # :Z for SELinux hosts; drop it on Docker without SELinux
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:docker compose exec -w /etc/caddy caddy caddy reload # after editing the Caddyfile; no restart needed
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy cat /data/caddy/pki/authorities/local/root.crt > caddy-root.crt # internal CA rootInside a Compose network the upstream is the service name (reverse_proxy my-app:8080), and Caddy resolves it through the embedded DNS on every dial, so restarted containers with new IPs are picked up. Rootless Podman cannot bind 80 and 443 until net.ipv4.ip_unprivileged_port_start is lowered, and needs the :Z label on bind mounts. See Docker Compose for the stack conventions.
Admin API#
The admin endpoint is plain HTTP on localhost:2019 with no authentication; anything that can reach it owns the server. Keep it on loopback or a Unix socket (admin unix//run/caddy/admin.sock).
curl -s localhost:2019/config/ | jq . # whole running config
curl -s localhost:2019/config/apps/http/servers/srv0/routes | jq # a subtree by path
curl -s localhost:2019/config/apps/tls/automation | jq
curl -X POST localhost:2019/load -H 'Content-Type: text/caddyfile' --data-binary @Caddyfile # what caddy reload does
curl -X POST localhost:2019/adapt -H 'Content-Type: text/caddyfile' --data-binary @Caddyfile | jq # adapt without loading
curl -X PATCH localhost:2019/config/apps/http/servers/srv0/listen -H 'Content-Type: application/json' -d '[":8443"]'
curl -s localhost:2019/reverse_proxy/upstreams | jq # health of every upstream
curl -s localhost:2019/pki/ca/local | jq -r .root_certificate # internal CA root PEM
curl -X POST localhost:2019/stop # graceful shutdown
curl -s localhost:2019/metrics # Prometheus format; 2.10+ needs the "metrics" global optionConfig loaded through the API is not written back to the Caddyfile; the next caddy reload from the file replaces it. @id tags in JSON config give stable handles (/id/my-route) for automation that adds and removes routes, which is how ingress controllers and caddy-docker-proxy drive it. Metrics are scraped by Prometheus with caddy_http_requests_total and caddy_http_request_duration_seconds as the main series.
Logs#
Caddy logs structured JSON (or console format on a TTY) to stderr, which journalctl -u caddy captures. Access logs are off until a site has a log directive.
app.example.com {
log {
output file /var/log/caddy/app.access.log {
roll_size 100MiB
roll_keep 10
roll_keep_for 720h
}
format json
level INFO
}
reverse_proxy 127.0.0.1:8080
}Each access line carries request.host, request.uri, request.remote_ip, status, duration (seconds), size, resp_headers and, when proxying, "logger":"http.log.access" plus a separate http.handlers.reverse_proxy entry at DEBUG with the upstream and its latency. The global debug option turns that on and is the right first move for any proxying or ACME problem. Errors from ACME show under the tls.obtain and tls.issuance.acme loggers with the challenge type and the CA’s error text verbatim.
journalctl -u caddy --since -30m | grep -E '"level":"(error|warn)"'
journalctl -u caddy -o cat | jq -r 'select(.logger=="tls.obtain") | "\(.ts|todate) \(.identifier // "") \(.msg) \(.error // "")"'
jq -r '"\(.ts|todate) \(.status) \(.duration*1000|floor)ms \(.request.host)\(.request.uri)"' /var/log/caddy/app.access.log | tail -20
jq -r 'select(.status >= 500) | .request.uri' /var/log/caddy/app.access.log | sort | uniq -c | sort -rn | headOneliners#
# Validate, then reload only if valid
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy
# Diff what the Caddyfile would load against what is running
diff <(caddy adapt --config /etc/caddy/Caddyfile | jq -S .) <(curl -s localhost:2019/config/ | jq -S .)
# Every site address in the running config
curl -s localhost:2019/config/apps/http/servers | jq -r '.[].routes[].match[]?.host[]?' | sort -u
# Certificates Caddy is managing and their expiry
find /var/lib/caddy/.local/share/caddy/certificates -name '*.crt' -exec sh -c 'printf "%s " "$1"; openssl x509 -enddate -noout -in "$1"' _ {} \;
# Unhealthy upstreams only
curl -s localhost:2019/reverse_proxy/upstreams | jq -r '.[] | select(.healthy==false) | .address'
# Watch upstream request counts
watch -n1 'curl -s localhost:2019/reverse_proxy/upstreams | jq -c ".[] | {address, healthy, num_requests}"'
# Which ACME challenge failed and why, from the journal
journalctl -u caddy -o cat --since -1h | jq -r 'select(.logger|test("acme")) | "\(.ts|todate) \(.msg) \(.error // "") \(.challenge_type // "")"'
# Export the internal CA root for another machine
curl -s localhost:2019/pki/ca/local | jq -r .root_certificate > caddy-root.crt
# Confirm HTTP/3 is served (UDP 443 must be open)
curl -sI --http3-only https://app.example.com/ | head -1
# Confirm the redirect from HTTP
curl -sI http://app.example.com/ | grep -i '^location'
# Test a site block before DNS points at the server
curl -sk --resolve app.example.com:443:203.0.113.10 https://app.example.com/ -o /dev/null -w '%{http_code} %{ssl_verify_result}\n'
# Serve the current directory on :8000 with listing, for a quick file transfer
caddy file-server --listen :8000 --browse
# Proxy a local dev server with a trusted local certificate
caddy reverse-proxy --from localhost:8443 --to localhost:3000
# Bcrypt hash without a prompt (the plaintext lands in shell history; prefer the prompt)
caddy hash-password --plaintext "$PASSWORD"
# Environment as Caddy sees it (what {$VAR} placeholders will expand to)
systemctl show caddy -p Environment -p EnvironmentFiles
# Top clients in the last 10k access log lines
tail -n 10000 /var/log/caddy/app.access.log | jq -r .request.remote_ip | sort | uniq -c | sort -rn | head
# p95 latency in ms from the access log
jq -r .duration /var/log/caddy/app.access.log | sort -n | awk '{a[NR]=$1} END {print a[int(NR*0.95)]*1000 "ms"}'
# Restart the service and follow startup
systemctl restart caddy && journalctl -u caddy -f -n 50Scripts#
Report every managed certificate with days until expiry and flag any that Caddy should have renewed already (it renews at two thirds of lifetime, so under 30 days for a 90-day certificate means renewal is failing).
#!/usr/bin/env bash
set -euo pipefail
dir=${CADDY_DATA:-/var/lib/caddy/.local/share/caddy}/certificates
rc=0
while IFS= read -r crt; do
name=$(openssl x509 -noout -subject -in "$crt" | sed 's/.*CN *= *//')
end=$(date -d "$(openssl x509 -noout -enddate -in "$crt" | cut -d= -f2)" +%s)
days=$(( (end - $(date +%s)) / 86400 ))
if (( days < 30 )); then
printf 'RENEWAL OVERDUE %-40s %3d days\n' "$name" "$days"; rc=1
else
printf 'ok %-40s %3d days\n' "$name" "$days"
fi
done < <(find "$dir" -name '*.crt' | sort)
exit "$rc"Add a site to a running Caddy through the admin API without touching the Caddyfile, for short-lived environments. The route is tagged with an @id so the matching removal is one DELETE.
#!/usr/bin/env bash
set -euo pipefail
host=${1:?hostname}; upstream=${2:?upstream host:port}
admin=${CADDY_ADMIN:-localhost:2019}
route=$(jq -n --arg h "$host" --arg u "$upstream" '{
"@id": ("route-" + $h),
match: [{host: [$h]}],
handle: [{handler: "reverse_proxy", upstreams: [{dial: $u}]}],
terminal: true }')
curl -fsS -X POST "http://$admin/config/apps/http/servers/srv0/routes" \
-H 'Content-Type: application/json' -d "$route"
printf 'added %s -> %s; remove with: curl -X DELETE http://%s/id/route-%s\n' "$host" "$upstream" "$admin" "$host"Check every upstream from the admin API and exit non-zero if any is unhealthy, suitable for a monitoring probe or a deploy gate.
#!/usr/bin/env python3
import json, sys, urllib.request
admin = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:2019"
with urllib.request.urlopen(f"{admin}/reverse_proxy/upstreams", timeout=5) as r:
upstreams = json.load(r)
bad = [u for u in upstreams if not u["healthy"]]
for u in upstreams:
state = "ok" if u["healthy"] else "UNHEALTHY"
print(f"{state:10} {u['address']:30} inflight={u['num_requests']} fails={u['fails']}")
sys.exit(1 if bad else 0)Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
Startup log: could not get certificate from issuer, then retries with backoff | ACME challenge failed; the error text names the reason | journalctl -u caddy | grep acme; read the CA’s message, fix, and Caddy retries on its own |
no solvers available for remaining challenges | Port 80 and 443 both unreachable from the internet, or the name resolves elsewhere | dig +short app.example.com from outside; open 80/443 on the firewall and the router; check nothing else holds port 80 (ss -ltnp 'sport = :80') |
Timeout during connect (likely firewall problem) from Let’s Encrypt | HTTP-01 blocked, common on home connections where the ISP filters 80 | Use DNS-01 with a DNS plugin |
too many certificates already issued | Let’s Encrypt rate limit after repeated fresh starts | Persist /data; use acme_ca staging while testing |
DNS problem: NXDOMAIN | Name has no public record, or a private/split-horizon name | Add the record, or use DNS-01 and resolvers pointing at public DNS |
Wildcard site: acme: error presenting token or propagation timeout | DNS plugin token lacks zone edit rights, or the wrong zone | Test the token with the provider’s API; raise propagation_timeout; set resolvers |
| Browser shows the internal CA certificate for a public name | Caddy fell back to internal issuance after ACME failures, or tls internal is inherited via a snippet | Check the issuer with openssl s_client; fix the ACME error; remove tls internal |
x509: certificate signed by unknown authority when proxying to an https:// upstream | The upstream’s certificate is self-signed or private-CA | tls_trust_pool file /path/ca.pem in the transport, or tls_insecure_skip_verify for a test only |
Upstream gets http in X-Forwarded-Proto or a wrong client IP | Caddy sits behind another proxy that is not in trusted_proxies | Global servers { trusted_proxies static <cidr> } |
502 for every request, upstream is up | Upstream listens on 127.0.0.1 inside its container, or Caddy is in a different Compose network | docker compose exec caddy wget -qO- my-app:8080/ ; put both services on the same network |
502 for one backend in a pool | Active check failing or max_fails reached | curl -s localhost:2019/reverse_proxy/upstreams | jq; hit health_uri yourself |
Reload says connection refused on 2019 | admin off, or Caddy runs in a container and the command runs on the host | Run caddy reload inside the container; do not disable admin if you need reloads |
| Reloading from the Caddyfile reverts API changes | The file is the source on reload; API edits are not persisted to it | Keep one source of truth, or run with --resume and JSON |
handle_path upstream returns 404 | The stripped path is not what the app expects, or the wrong handle matched | caddy adapt and read the route order; use handle instead of handle_path |
bind: permission denied on 80/443 as non-root | Missing capability | The package sets CAP_NET_BIND_SERVICE; for containers see Podman for unprivileged ports |
| HTTP/3 never negotiated | UDP 443 not published or filtered | Publish 443/udp; check with curl --http3-only |