Cheat Sheet

Caddyfile Examples

Caddy is a web server with HTTPS built in: point it at a domain and it obtains and renews the certificate on its own. This sheet collects working Caddyfile examples, from a one-line reverse proxy to Docker, wildcard certificates, and PHP, plus the syntax rules the examples rely on.

Last updated September 11, 2026

Reverse Proxy

The site address gets HTTPS automatically; does the rest. Caddy sets and passes the header through on its own.

DirectiveWhat 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
}
ElementWhat it means
at the top, no addressGlobal 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:

OptionWhat 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:2

Apply config changes without restarting the container:

docker exec -w /etc/caddy caddy caddy reload

A 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/caddy

The 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.

DirectiveWhat 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 addressWhat 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:9000

The 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.

DirectiveWhat 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.

PlaceholderValue
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

CommandWhat 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.

Caddy FAQ

What format is a Caddyfile?

It is Caddy's own plain-text format, not JSON, YAML, or TOML, and it needs no file extension. Caddy's native configuration language is actually JSON; the Caddyfile is a convenience layer that gets adapted to that JSON every time Caddy starts or reloads. caddy adapt --pretty prints the JSON your Caddyfile turns into, which is the fastest way to see what a directive really did. Anything expressible in the Caddyfile is expressible in JSON, but not the reverse, so a few advanced options are JSON-only.

Where is the Caddyfile located?

There is no fixed location. caddy run and caddy reload look for a file named Caddyfile in the current directory unless you pass --config. The Debian, Ubuntu, and Fedora packages, and the official Docker image, all use /etc/caddy/Caddyfile, which is why that path shows up in most examples. Homebrew puts it in /opt/homebrew/etc/Caddyfile on Apple Silicon. To see which config a running instance actually loaded, query the admin API with curl localhost:2019/config/ rather than guessing from the file on disk.

How do I comment in a Caddyfile?

Use #, which comments out the rest of the line. The # has to start a token, meaning it needs whitespace or a line break in front of it, so reverse_proxy localhost:3000 # main app works but localhost:3000#app does not. There is no block comment syntax, so commenting out a whole site block means prefixing every line. Inside a quoted string # is a literal character, which matters for header values like "public, max-age=31536000". caddy fmt preserves comments.

How do I format a Caddyfile?

caddy fmt --overwrite Caddyfile rewrites the file in place with Caddy's own conventions: tabs for indentation, one directive per line, consistent brace placement. The warning 'Caddyfile input is not formatted' on run or reload means the file does not match that output. It is only a warning, the config still loads, so nothing is broken, but running caddy fmt clears it. Formatting is not validation: caddy fmt happily formats a config with unknown directives, so follow it with caddy validate --config Caddyfile.

How do I reload the Caddyfile without downtime?

Run caddy reload from the directory that holds the Caddyfile, or caddy reload --config /etc/caddy/Caddyfile from anywhere. Caddy applies the new config with zero downtime, and if the file has an error it keeps running on the old config instead of dying. On Debian and Ubuntu package installs, systemctl reload caddy does the same thing. In Docker, run docker exec -w /etc/caddy caddy caddy reload after editing the mounted file; restarting the container also works but drops open connections.

Related cheat sheets