Caddy reverse proxy
Caddy's reverse_proxy directive, automatic HTTPS, load balancing and health checks, the handle vs handle_path trap, and Caddy versus nginx defaults.
Key points
- The Caddyfile is a front end for Caddy's JSON config;
caddy adaptshows exactly what a directive compiles to, which is the fastest way to debug matching. - Caddy sets
X-Forwarded-For,X-Forwarded-ProtoandX-Forwarded-Hoston proxied requests by default and passes the client'sHostthrough unchanged. nginx sets none of those and rewritesHostto$proxy_host. handle_path /api/*strips the prefix,handle /api/*does not. This is Caddy's equivalent of the nginxproxy_passtrailing slash, and/api/*does not match a bare/api.- Automatic HTTPS covers ACME issuance, renewal, OCSP stapling, an HTTP to HTTPS redirect, and an internal CA for local names.
auto_https offdisables the lot.
Caddy is a reverse proxy whose defaults are the opposite of nginx's: HTTPS is on unless you turn it off, forwarding headers are set unless you override them, the client's Host is passed through untouched, and responses stream rather than buffer. The Caddyfile you write is not the real configuration; it is adapted into a JSON document that Caddy actually runs, and caddy adapt --config Caddyfile --pretty prints that document. If a route is not matching, read the adapted JSON before reading anything else.
Caddyfile and the JSON underneath#
A Caddyfile has three levels: an optional global options block in braces at the very top, site blocks addressed by scheme, host and port, and directives inside them. Directives take an optional matcher token as their first argument.
{
# global options
email ops@example.com
admin localhost:2019
}
example.com {
encode zstd gzip
reverse_proxy /api/* 10.0.1.11:8080
file_server
}Two structural rules cause most confusion:
- Directives do not execute in the order you write them. Caddy sorts them into a fixed built-in order (redirects before rewrites before proxying before file serving) so that a Caddyfile behaves predictably regardless of layout. When you need written order, wrap the directives in a
routeblock. handleblocks are mutually exclusive and evaluated in written order, first match wins. This is genuinely different from nginx, where the longest matchinglocationprefix wins regardless of position in the file. A catch-allhandlemust therefore come last in a Caddy config, whereaslocation /can appear anywhere in an nginx config.
The JSON config is the API surface. POST /load to the admin endpoint (default localhost:2019) replaces the running configuration atomically with zero dropped connections, and caddy reload is a wrapper around that. There is no signal-based reload and no separate config test step: an invalid config is rejected by the API and the old one keeps running.
What automatic HTTPS actually does#
For every site address that names a host and is not explicitly http:// or port 80, Caddy will:
- Obtain a certificate over ACME, using Let's Encrypt with ZeroSSL as a fallback issuer (the precise default issuer set has changed across 2.x releases, so check
caddy versionagainst the docs before relying on the fallback). - Solve the challenge itself: HTTP-01 on port 80, TLS-ALPN-01 on port 443, or DNS-01 if you build Caddy with a DNS provider module and configure it.
- Renew unattended, well before expiry, and staple OCSP responses.
- Add an implicit HTTP listener on port 80 that redirects to HTTPS.
- Use an internal CA instead of ACME for addresses that cannot be publicly validated, such as
localhost,*.localhost, IP addresses and names in private TLDs, and attempt to install that root into the local trust store.
Point 5 is why caddy run on a laptop gives you a working https://localhost with no configuration and no certificate warning, and also why the first run may prompt for a password: installing a root CA into the system trust store needs privileges. The same mechanism is available explicitly as tls internal for internal services, which is a reasonable answer for east-west traffic that would otherwise be plaintext.
Turning it off, from least to most drastic:
| Goal | Directive |
|---|---|
| Serve one site over plain HTTP | Prefix the site address with http:// |
| Keep certificates, drop the port 80 redirect | auto_https disable_redirects (global) |
| Use your own certificate files | tls /etc/ssl/site.pem /etc/ssl/site.key in the site block |
| Local or internal PKI instead of ACME | tls internal |
| Disable everything, including redirects and issuance | auto_https off (global) |
On-demand TLS obtains a certificate during the TLS handshake for a hostname Caddy has never seen, which is how multi-tenant platforms support customer domains. It is gated deliberately: you configure an ask endpoint that Caddy queries with the requested hostname and which must answer 200 for issuance to proceed. Enabling on-demand issuance without that gate lets anyone who points a DNS record at your IP consume your ACME rate limits, so Caddy refuses to run it unmanaged.
{
on_demand_tls {
ask http://127.0.0.1:9000/check-domain
}
}
https:// {
tls {
on_demand
}
reverse_proxy app:8080
}reverse_proxy: upstreams, balancing and health#
reverse_proxy 10.0.1.11:8080 10.0.1.12:8080 {
lb_policy least_conn
lb_try_duration 5s
lb_try_interval 250ms
health_uri /healthz
health_interval 5s
health_timeout 2s
health_status 2xx
fail_duration 30s
max_fails 3
unhealthy_status 5xx
unhealthy_latency 10s
}Upstream addresses may be host:port, unix//run/app.sock, or carry a scheme. https:// implies TLS to the upstream; h2c:// (available in recent 2.x) implies cleartext HTTP/2.
Load balancing policies, with the default first:
lb_policy | Behaviour | Use when |
|---|---|---|
random (default) | Uniform random pick among available upstreams | Stateless backends, several Caddy instances in front of one pool |
random_choose <n> | Picks n at random, then the one with fewest requests | You want least-connections behaviour without its herd effect across proxies |
round_robin | Strict rotation | Uniform, short requests |
weighted_round_robin <w>... | Rotation honouring per-upstream weights | Heterogeneous instance sizes |
least_conn | Fewest active requests | Widely varying request durations, streaming, WebSockets |
first | First available upstream in the listed order | Primary and standby pairs |
ip_hash | Hash of the client IP | Crude affinity without cookies |
uri_hash | Hash of the request URI | Cache nodes owning distinct objects |
header <name> / cookie <name> / query <key> | Hash of the named value | Application-level affinity by tenant or session |
Caddy distinguishes active health checks (Caddy makes its own request to health_uri every health_interval, default 30s, with health_timeout default 5s) from passive health checks (Caddy watches real traffic and counts failures). Passive checking is off until you set fail_duration to a non-zero value: fail_duration 30s with max_fails 3 means three failures inside a rolling 30 second window take the upstream out for the rest of that window. Active and passive can be combined, and usually should be, because active checks catch a dead instance no client happened to hit while passive checks catch an instance that accepts connections and then errors. The general trade-off is worked through in health checks and upstream failover.
One subtlety: upstream health state in Caddy is keyed by address and shared process-wide. Two reverse_proxy directives in different site blocks that point at the same host:port share failure counts and active check results.
Headers, and why Caddy needs less configuration than nginx#
On every proxied request Caddy sets, without being asked:
X-Forwarded-For, appending the immediate peer's IP to any existing valueX-Forwarded-Proto, the scheme the client usedX-Forwarded-Host, the client's originalHost
and it leaves the Host header as the client sent it. Hop-by-hop headers are stripped per RFC 9110, and connection upgrades including WebSockets are proxied without any extra directive.
Overrides use header_up (towards the upstream) and header_down (towards the client), where a bare name and value sets, + adds, and - deletes:
reverse_proxy app:8080 {
header_up Host {upstream_hostport} # some PaaS upstreams require this
header_up X-Real-IP {remote_host}
header_up -Cookie # strip before an untrusted upstream
header_down -Server
}trusted_proxies tells Caddy which peers are allowed to speak for the client. It matters because Caddy resolves {client_ip} from forwarding headers only when the connection came from a trusted address, and falls back to {remote_host}, the actual TCP peer, otherwise. Configure it once globally rather than per handler:
{
servers {
trusted_proxies static private_ranges
client_ip_headers X-Forwarded-For
}
}private_ranges is a built-in shorthand for the RFC 1918 and loopback ranges, added in Caddy 2.7, and client_ip_headers lets you nominate a different header such as a CDN's own (CF-Connecting-IP). Without a trusted proxy list, any client can send X-Forwarded-For: 1.2.3.4 and anything reading {client_ip} believes it. The reasoning and the failure cases are the same for every proxy: see configuring trusted proxies and check your chain against the client IP resolver.
Transport: TLS and HTTP/2 to the upstream#
The transport block configures the connection Caddy makes to the backend.
reverse_proxy https://backend.internal:8443 {
transport http {
tls
tls_server_name backend.internal
tls_trusted_ca_certs /etc/ssl/internal-ca.pem
dial_timeout 3s
response_header_timeout 30s
keepalive_idle_conns 32
}
}For cleartext HTTP/2, which gRPC backends without TLS require, declare the versions explicitly:
reverse_proxy grpc-backend:9000 {
transport http {
versions h2c 2
}
}Without that, Caddy speaks HTTP/1.1 to the backend and a gRPC server logs a protocol error rather than serving the call. The end-to-end requirements are covered in gRPC through a reverse proxy.
tls_insecure_skip_verify exists and is occasionally the pragmatic answer for a self-signed internal endpoint, but tls_trusted_ca_certs costs one line more and keeps verification on.
The handle vs handle_path trap#
handle matches and routes. handle_path matches, strips the matched prefix, and then routes. That single difference is Caddy's version of the nginx trailing slash problem described in nginx proxy_pass and the trailing slash.
| Config | Request | Upstream receives |
|---|---|---|
handle /api/* { reverse_proxy app:8080 } | /api/users | /api/users |
handle_path /api/* { reverse_proxy app:8080 } | /api/users | /users |
handle_path /api/* { ... } | /api/ | / |
handle_path /api/* { ... } | /api | no match, falls through to the next handler |
handle_path /api* { ... } | /api | empty path, usually normalised to / |
handle /api/* { uri strip_prefix /api ... } | /api/users | /users (equivalent to handle_path) |
Worked example: static site plus an API prefix#
{
email ops@example.com
servers {
trusted_proxies static private_ranges
}
}
example.com {
encode zstd gzip
log {
output file /var/log/caddy/access.log
}
# Bare prefix, handled explicitly so it cannot fall through to the SPA.
handle /api {
redir /api/ 308
}
# Prefix stripped: /api/users reaches the upstream as /users.
handle_path /api/* {
reverse_proxy 10.0.1.11:8080 10.0.1.12:8080 {
lb_policy least_conn
lb_try_duration 3s
health_uri /healthz
health_interval 5s
fail_duration 30s
max_fails 3
header_up X-Request-Id {http.request.uuid}
}
}
# Everything else: static SPA with client-side routing.
handle {
root * /srv/www
try_files {path} /index.html
file_server
}
header /assets/* Cache-Control "public, max-age=31536000, immutable"
}Observable behaviour: GET https://example.com/api/users/7 reaches the backend as GET /users/7 with Host: example.com, X-Forwarded-Proto: https, X-Forwarded-Host: example.com and an X-Forwarded-For containing the client address. GET /api returns a 308 to /api/. GET /profile/42 returns index.html with status 200. Run caddy adapt --config Caddyfile --pretty and you will see the three handle blocks compiled into a subroute list with path matchers, the middle one preceded by a rewrite handler that performs the strip.
Caddy defaults versus nginx defaults#
| Behaviour | Caddy default | nginx default |
|---|---|---|
| TLS certificates | Obtained and renewed automatically over ACME | Manual, external tooling |
| HTTP to HTTPS redirect | Created automatically | Written by hand |
Host sent upstream | The client's Host, unchanged | $proxy_host, the name from proxy_pass |
X-Forwarded-For | Set, appending the peer IP | Not sent |
X-Forwarded-Proto / X-Forwarded-Host | Both set | Neither sent |
| Trusting inbound forwarding headers | Only from trusted_proxies | No concept; whatever you configure is trusted |
| Path prefix stripping | handle_path, or uri strip_prefix | Trailing slash on the proxy_pass URI part |
| Route selection | First matching handle, in written order | Longest matching location prefix, then regexes in order |
| Upstream HTTP version | HTTP/1.1, negotiating HTTP/2 with TLS upstreams | HTTP/1.1 since 1.29.7, HTTP/1.0 before it |
| WebSocket upgrades | Proxied without configuration | Requires Upgrade and Connection headers to be set |
| Response buffering | Streamed, not buffered | proxy_buffering on, buffers to memory then disk |
| Passive health checks | Off (fail_duration 0) | On: max_fails=1 fail_timeout=10s |
| Load balancing policy | random | Weighted round robin |
| Upstream read timeout | None by default | proxy_read_timeout 60s |
| Config reload | Atomic swap over the admin API, no dropped connections | nginx -s reload, new workers, old workers drain |
The last four rows are the ones that surprise people migrating. Caddy having no upstream read timeout means a wedged backend holds a Caddy connection indefinitely unless you set response_header_timeout. nginx buffering by default means a slow client cannot hold an upstream worker, which Caddy's streaming default does not give you for free; the trade-off is explained in proxy buffering and streaming responses. And a full comparison across five proxies lives in nginx vs HAProxy vs Envoy vs Caddy vs Traefik.
Failure modes#
| Symptom | Cause | Fix |
|---|---|---|
GET /api returns the SPA index.html with 200 | handle_path /api/* does not match the bare prefix | Add an explicit handle /api { redir /api/ 308 } |
Upstream sees /api/users when it expected /users | handle used where handle_path was meant | Switch to handle_path, or add uri strip_prefix /api |
| A directive appears to be ignored | Caddyfile directive order is fixed, not written order | Wrap the sequence in a route block, and confirm with caddy adapt |
| Certificate never issues, ACME challenge fails | Port 80 or 443 unreachable from the internet, or DNS not pointing at the host | Open the ports, or switch to the DNS-01 challenge with a DNS provider module |
| Certificates issue on a staging box and burn ACME rate limits | Real domain names in a non-production Caddyfile | Use tls internal or the ACME staging endpoint via the acme_ca global option |
502 with x509: certificate signed by unknown authority in the log | HTTPS upstream presenting a certificate from a private CA | tls_trusted_ca_certs in the transport http block |
| gRPC or h2c upstream rejects requests as malformed | Caddy defaulted to HTTP/1.1 towards the backend | transport http { versions h2c 2 } |
{client_ip} equals the CDN or load balancer address | trusted_proxies not configured | Set trusted_proxies globally, plus client_ip_headers if the CDN uses its own header |
| Caddy refuses to start with on-demand TLS enabled | No ask endpoint configured to authorise hostnames | Add on_demand_tls { ask ... } in global options |
| A hung backend holds connections open indefinitely | No upstream timeout by default | Set response_header_timeout and dial_timeout in transport http |
Frequently asked questions#
Does Caddy set X-Forwarded-For automatically?#
Yes. Caddy's reverse_proxy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host on every proxied request without configuration, appending to any existing X-Forwarded-For value. nginx sends none of these unless you write proxy_set_header directives.
What is the difference between handle and handle_path in Caddy?#
handle_path strips the matched path prefix before passing the request on; handle leaves the path intact. handle_path /api/* turns /api/users into /users for the upstream, which is the same effect as nginx's trailing slash on proxy_pass. Both forms are mutually exclusive with other handle blocks and match in written order.
How do I turn off automatic HTTPS in Caddy?#
Set auto_https off in the global options block to disable issuance and the HTTP to HTTPS redirect entirely, or prefix an individual site address with http:// to opt just that site out. To keep certificates but drop the redirect, use auto_https disable_redirects.
Does Caddy change the Host header sent to the backend?#
No. Caddy forwards the client's Host header unchanged, which is the opposite of nginx's default of $proxy_host. If a backend requires its own hostname, for example a managed platform that routes on Host, set it explicitly with header_up Host {upstream_hostport}.
What load balancing policy does Caddy use by default?#
random, a uniform random choice among healthy upstreams. It behaves well when several Caddy instances share one backend pool. Use least_conn when request durations vary widely, or random_choose 2 to approximate least-connections without the synchronised behaviour that independent proxies exhibit.
How do I proxy to an HTTPS backend with an internal certificate?#
Use an https:// upstream address and point Caddy at the issuing CA with transport http { tls_trusted_ca_certs /path/ca.pem }. Add tls_server_name when the address you dial does not match the certificate's names. tls_insecure_skip_verify works but removes verification entirely.
Is the Caddyfile the real configuration?#
No. The Caddyfile is adapted into a JSON document, which is what Caddy runs and what the admin API accepts. caddy adapt --config Caddyfile --pretty prints the result, and reading it resolves nearly every question about which matcher won or where a rewrite came from.
Why does Caddy have no upstream read timeout by default?#
Because Caddy's proxy streams responses and does not assume anything about how long a backend legitimately takes, which suits server-sent events and long polling. The cost is that an unresponsive backend can hold connections indefinitely, so production configurations should set response_header_timeout and dial_timeout in the transport block.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- Caddy documentation: reverse_proxy directive
- Caddy documentation: Automatic HTTPS
- Caddy documentation: Caddyfile concepts and directive order
- Caddy documentation: JSON config structure
- Caddy documentation: tls directive and on-demand TLS
- Caddy source repository
- RFC 8555: Automatic Certificate Management Environment (ACME)
- RFC 7239: Forwarded HTTP Extension
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.