Reverse Proxy
The site address gets HTTPS automatically; does the rest. Caddy sets and passes the header through on its own.
| Directive | What it does |
|---|---|
| Send every request to one backend | |
| Proxy only requests matching a path | |
| Round-robin across several backends | |
| Proxy to an upstream that speaks HTTPS | |
| Pick the backend with the fewest active requests | |
| Take a backend out of rotation when its health check fails | |
| Send the upstream's hostname instead of the site's |
The whole Caddyfile for a single app:
example.com {
reverse_proxy localhost:3000
}Split an API off from the frontend:
example.com {
handle_path /api/* {
reverse_proxy localhost:8080
}
handle {
reverse_proxy localhost:3000
}
}Load balancing with health checks:
example.com {
reverse_proxy node1:3000 node2:3000 {
lb_policy least_conn
health_uri /healthz
health_interval 10s
}
}Caddy already sends , , and on every proxied request, so you rarely need to set them. Backends that expect do need a line, since Caddy does not send it:
example.com {
reverse_proxy localhost:3000 {
header_up X-Real-IP {remote_host}
}
}When Caddy itself sits behind another proxy or a CDN, tell it which hops to trust so the client IP is read from the incoming header instead of the connection:
{
servers {
trusted_proxies static 10.0.0.0/8 172.16.0.0/12
}
}An internal upstream with a self-signed certificate:
example.com {
reverse_proxy https://10.0.0.5:8443 {
transport http {
tls_insecure_skip_verify
}
}
}disables certificate checks, so keep it for backends you control on a private network.
Syntax and Comments
A Caddyfile is one optional global options block followed by site blocks. Comments start with and run to the end of the line; there is no block comment form.
# Global options come first and have no site address
{
email [email protected]
admin off
}
# One site block per address
example.com {
reverse_proxy localhost:3000 # trailing comments work too
}| Element | What it means |
|---|---|
| at the top, no address | Global options, allowed once and only as the first block |
| One block serving several addresses | |
| Comment to end of line; must start a token | |
| Snippet definition, pulled in later with | |
| Named matcher, used by and friends | |
| Runtime value such as or | |
| Environment variable substituted when the file is parsed |
Useful global options:
| Option | What it does |
|---|---|
| ACME account address for every site | |
| Verbose logs, including ACME failures | |
| Turn off the admin API on port 2019 | |
| Move or expose the admin API | |
| No certificates and no HTTP to HTTPS redirect | |
| Keep certificates, drop the port 80 redirect | |
| Listen somewhere other than 80 | |
| Change where a plugin directive runs |
Directives run in Caddy's own fixed order, not the order you wrote them. normalizes indentation to tabs, and checks the config without starting the server.
Docker
The official image reads . Mount your config and a volume; holds the certificates, so losing it means re-requesting them.
docker run -d --name caddy \
-p 80:80 -p 443:443 -p 443:443/udp \
-v ./Caddyfile:/etc/caddy/Caddyfile \
-v caddy_data:/data \
caddy:2Apply config changes without restarting the container:
docker exec -w /etc/caddy caddy caddy reloadA custom image with a DNS plugin baked in:
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/caddyThe flags themselves are covered in the Docker cheat sheet.
Docker Compose
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:Other services in the same Compose file are reachable by service name, so the Caddyfile proxies to them directly:
example.com {
reverse_proxy app:3000
}The mapping is for HTTP/3; Caddy enables it by default. Compose commands themselves are in the Docker Compose cheat sheet.
HTTPS and TLS
Any site address with a domain gets a Let's Encrypt or ZeroSSL certificate automatically, plus the HTTP to HTTPS redirect. The directive only exists for the exceptions.
| Directive | What it does |
|---|---|
| Automatic public certificate, renewed for you | |
| Set the ACME account email for expiry notices | |
| Self-signed certificate from Caddy's local CA | |
| Use a certificate you already have | |
| Opt this one site out of HTTPS | |
| Global option: no certificates, no redirects |
Set the email once globally instead of per site:
{
email [email protected]
}
example.com {
reverse_proxy localhost:3000
}Use the Let's Encrypt staging CA while testing, so failures do not hit rate limits:
example.com {
tls {
ca https://acme-staging-v02.api.letsencrypt.org/directory
}
reverse_proxy localhost:3000
}On your own machine, as the site address gets a locally trusted certificate. Run once so browsers accept it.
localhost {
reverse_proxy localhost:3000
}Check what a live domain is actually serving with the SSL checker.
No Domain or HTTP Only
Caddy only goes looking for a public certificate when the site address is a domain name. Give it a port or an IP and it serves plain HTTP without touching Let's Encrypt.
| Site address | What you get |
|---|---|
| Plain HTTP on every hostname that reaches the server | |
| Plain HTTP on a non-standard port | |
| Plain HTTP on one IP | |
| HTTPS on an IP, with a certificate from Caddy's local CA | |
| HTTPS with a locally trusted certificate | |
| Needs ; the name is not publicly resolvable |
A home server reachable only on the LAN, with no port forwarding and no public DNS:
http://192.168.1.10 {
reverse_proxy localhost:8096
}The same host over HTTPS, using Caddy's own CA:
home.lan {
tls internal
reverse_proxy localhost:8096
}Run on the server to install that CA locally. Other machines need the root certificate from added to their own trust store, or they will show a warning.
To turn off certificates and the port 80 redirect across the whole config at once:
{
auto_https off
}Wildcard and Cloudflare DNS
A wildcard certificate requires the DNS challenge, which requires a DNS provider module the stock binary does not ship. Install one with (or build with xcaddy, see the Docker section), then give it an API token with Zone Read and DNS Edit permissions.
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@app host app.example.com
handle @app {
reverse_proxy localhost:3001
}
@blog host blog.example.com
handle @blog {
reverse_proxy localhost:3002
}
handle {
abort
}
}One certificate covers every subdomain, and new subdomains never appear in certificate transparency logs. The blocks route by hostname inside the single wildcard site; drops requests for subdomains you have not defined.
Subdomains
Several addresses can share one block when they serve the same thing. Caddy gets a certificate covering all of them:
example.com, www.example.com, example.net {
reverse_proxy localhost:3000
}Without a wildcard, each subdomain that serves something different is its own site block with its own certificate. Caddy fetches them all.
app.example.com {
reverse_proxy localhost:3001
}
api.example.com {
reverse_proxy localhost:8080
}Share settings between blocks with a snippet:
(common) {
encode zstd gzip
header -Server
}
app.example.com {
import common
reverse_proxy localhost:3001
}
api.example.com {
import common
reverse_proxy localhost:8080
}File Server
is off by default; sets where it serves from. The after is a path matcher meaning all requests.
example.com {
root * /var/www/site
encode zstd gzip
file_server
}A directory listing for sharing files:
files.example.com {
root * /srv/files
file_server browse
}A single-page app, where every unknown path falls back to :
app.example.com {
root * /var/www/app
encode zstd gzip
try_files {path} /index.html
file_server
}PHP FastCGI
bundles the try_files rewrite to , the FastCGI transport, and the file server hand-off, so WordPress, Laravel, and Nextcloud work with one line.
example.com {
root * /var/www/site
encode zstd gzip
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
}For PHP-FPM listening on TCP instead of a socket:
php_fastcgi 127.0.0.1:9000The socket path must match your PHP version; check it with .
Basic Auth
The directive is since Caddy 2.8 (the old spelling still works but is deprecated). Passwords are bcrypt hashes; generate one with .
example.com {
basic_auth {
alice $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG
}
reverse_proxy localhost:3000
}That hash is the Caddy docs example for the password ; replace it with your own. Protect only part of a site with a path matcher:
example.com {
basic_auth /admin/* {
alice $2a$14$...
}
reverse_proxy localhost:3000
}Rewrites and Redirects
sends the browser somewhere else; changes the path internally without the URL bar moving.
| Directive | What it does |
|---|---|
| 301 one path to another | |
| Redirect a whole host, keeping the path | |
| Serve different content, URL unchanged | |
| Drop a prefix before later directives see the path | |
| Rewrite to a fallback when the file does not exist |
Redirect www to the apex domain:
www.example.com {
redir https://example.com{uri} permanent
}
example.com {
reverse_proxy localhost:3000
}Handle and Matchers
Named matchers start with and can combine host, path, header, and method conditions. blocks are mutually exclusive: the first matching block wins, so put specific routes before the catch-all.
example.com {
@static path *.css *.js *.svg *.woff2
handle @static {
root * /var/www/site
header Cache-Control "public, max-age=31536000, immutable"
file_server
}
handle_path /api/* {
reverse_proxy localhost:8080
}
handle {
reverse_proxy localhost:3000
}
}behaves like but strips the matched prefix, so the backend above sees , not .
Caddy runs directives in a fixed order regardless of how you wrote them. When two directives must run in a specific sequence, wrap them in , which keeps your order and, unlike , is not mutually exclusive with its siblings:
example.com {
route {
uri strip_prefix /app
rewrite * /index.html
reverse_proxy localhost:3000
}
}Placeholders
Placeholders are values Caddy fills in per request. They work in most directive arguments, including , , , , and header lines.
| Placeholder | Value |
|---|---|
| Hostname from the request, no port | |
| Hostname with port | |
| Path plus query string | |
| Path only | |
| First path segment | |
| Query string without the | |
| One query parameter | |
| GET, POST, and so on | |
| http or https | |
| IP of the direct client | |
| Real client IP, read through | |
| Any request header | |
| Any cookie | |
| Status inside | |
| Environment variable, read at runtime | |
| Environment variable, substituted when the file is parsed | |
| Capture group from a named regex matcher |
and are not the same. is textual substitution done while the Caddyfile is parsed, so it can appear anywhere, even inside a site address. is looked up when the request or the certificate is handled, so it only works where Caddy supports placeholders.
{$SITE_ADDRESS} {
reverse_proxy localhost:{$APP_PORT}
}Error Pages
returns a body straight from Caddy with no backend, raises a status that can then catch, and renders whatever the routes failed with.
example.com {
handle /health {
respond "OK" 200
}
handle /admin* {
error 403
}
handle {
reverse_proxy localhost:3000
}
handle_errors {
rewrite * /{err.status_code}.html
root * /var/www/errors
file_server
}
}A single page for every error, instead of one file per status code:
handle_errors {
respond "{err.status_code} {err.status_text}" {err.status_code}
}The difference matters: sends the status straight to the client and never sees it, while hands control to so your styled page renders. drops the connection without answering at all.
WebSockets
handles WebSocket upgrades automatically; a plain proxy needs nothing extra. A matcher is only needed when socket traffic goes to a different backend than normal requests:
example.com {
@websockets {
header Connection *Upgrade*
header Upgrade websocket
}
reverse_proxy @websockets localhost:6001
reverse_proxy localhost:8080
}Logging
Caddy logs its own runtime messages to stderr, but per-site access logs are off until you add . The default format is JSON; is easier to read by eye.
example.com {
log {
output file /var/log/caddy/example.log {
roll_size 10MiB
roll_keep 5
}
}
reverse_proxy localhost:3000
}Verbose output for debugging certificate problems goes in the global options:
{
debug
}Caddy Commands
| Command | What it does |
|---|---|
| Run in the foreground with the Caddyfile in this directory | |
| Run with an explicit config path | |
| / | Run in the background, and stop it |
| Apply Caddyfile changes with zero downtime | |
| Same, on Debian and Ubuntu package installs | |
| Format the Caddyfile in place | |
| Check the config without running it | |
| Print the JSON config the Caddyfile becomes | |
| Bcrypt hash for | |
| Trust Caddy's local CA for HTTPS | |
| Add a plugin to the installed binary | |
| Show which plugins the binary was built with | |
| Replace the binary with the latest build, plugins kept | |
| Instant proxy, no Caddyfile needed | |
| Instant file server | |
| Show the installed version |
Gotchas
- became in Caddy 2.8. The old name still parses, but new configs should use the underscore form.
- In Docker, always mount a volume at . Without it, every container recreate re-requests certificates and can hit Let's Encrypt rate limits within a day.
- run from the wrong directory silently reloads nothing useful. Pass when in doubt.
- Behind Cloudflare's orange-cloud proxy, the HTTP ACME challenge never reaches your server. Use the DNS challenge or disable the proxy while issuing.
- A site address with no scheme serves HTTPS and redirects port 80. Prefix for a plain HTTP site instead of fighting the redirect.
- HTTP/3 is on by default. If browsers report flaky connections, check UDP 443 through the firewall, not just TCP.
- Migrating an existing nginx config? The nginx to Caddy converter translates server blocks to Caddyfile syntax.