Reverse proxy

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.

· 14 min read · How we verify this

Key points

  • The Caddyfile is a front end for Caddy's JSON config; caddy adapt shows exactly what a directive compiles to, which is the fastest way to debug matching.
  • Caddy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host on proxied requests by default and passes the client's Host through unchanged. nginx sets none of those and rewrites Host to $proxy_host.
  • handle_path /api/* strips the prefix, handle /api/* does not. This is Caddy's equivalent of the nginx proxy_pass trailing 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 off disables 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.

caddy
{
	# 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 route block.
  • handle blocks are mutually exclusive and evaluated in written order, first match wins. This is genuinely different from nginx, where the longest matching location prefix wins regardless of position in the file. A catch-all handle must therefore come last in a Caddy config, whereas location / 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:

  1. 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 version against the docs before relying on the fallback).
  2. 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.
  3. Renew unattended, well before expiry, and staple OCSP responses.
  4. Add an implicit HTTP listener on port 80 that redirects to HTTPS.
  5. 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:

GoalDirective
Serve one site over plain HTTPPrefix the site address with http://
Keep certificates, drop the port 80 redirectauto_https disable_redirects (global)
Use your own certificate filestls /etc/ssl/site.pem /etc/ssl/site.key in the site block
Local or internal PKI instead of ACMEtls internal
Disable everything, including redirects and issuanceauto_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.

caddy
{
	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#

caddy
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_policyBehaviourUse when
random (default)Uniform random pick among available upstreamsStateless backends, several Caddy instances in front of one pool
random_choose <n>Picks n at random, then the one with fewest requestsYou want least-connections behaviour without its herd effect across proxies
round_robinStrict rotationUniform, short requests
weighted_round_robin <w>...Rotation honouring per-upstream weightsHeterogeneous instance sizes
least_connFewest active requestsWidely varying request durations, streaming, WebSockets
firstFirst available upstream in the listed orderPrimary and standby pairs
ip_hashHash of the client IPCrude affinity without cookies
uri_hashHash of the request URICache nodes owning distinct objects
header <name> / cookie <name> / query <key>Hash of the named valueApplication-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 value
  • X-Forwarded-Proto, the scheme the client used
  • X-Forwarded-Host, the client's original Host

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:

caddy
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:

caddy
{
	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.

caddy
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:

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

ConfigRequestUpstream 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/* { ... }/apino match, falls through to the next handler
handle_path /api* { ... }/apiempty 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#

caddy
{
	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#

BehaviourCaddy defaultnginx default
TLS certificatesObtained and renewed automatically over ACMEManual, external tooling
HTTP to HTTPS redirectCreated automaticallyWritten by hand
Host sent upstreamThe client's Host, unchanged$proxy_host, the name from proxy_pass
X-Forwarded-ForSet, appending the peer IPNot sent
X-Forwarded-Proto / X-Forwarded-HostBoth setNeither sent
Trusting inbound forwarding headersOnly from trusted_proxiesNo concept; whatever you configure is trusted
Path prefix strippinghandle_path, or uri strip_prefixTrailing slash on the proxy_pass URI part
Route selectionFirst matching handle, in written orderLongest matching location prefix, then regexes in order
Upstream HTTP versionHTTP/1.1, negotiating HTTP/2 with TLS upstreamsHTTP/1.1 since 1.29.7, HTTP/1.0 before it
WebSocket upgradesProxied without configurationRequires Upgrade and Connection headers to be set
Response bufferingStreamed, not bufferedproxy_buffering on, buffers to memory then disk
Passive health checksOff (fail_duration 0)On: max_fails=1 fail_timeout=10s
Load balancing policyrandomWeighted round robin
Upstream read timeoutNone by defaultproxy_read_timeout 60s
Config reloadAtomic swap over the admin API, no dropped connectionsnginx -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#

SymptomCauseFix
GET /api returns the SPA index.html with 200handle_path /api/* does not match the bare prefixAdd an explicit handle /api { redir /api/ 308 }
Upstream sees /api/users when it expected /usershandle used where handle_path was meantSwitch to handle_path, or add uri strip_prefix /api
A directive appears to be ignoredCaddyfile directive order is fixed, not written orderWrap the sequence in a route block, and confirm with caddy adapt
Certificate never issues, ACME challenge failsPort 80 or 443 unreachable from the internet, or DNS not pointing at the hostOpen the ports, or switch to the DNS-01 challenge with a DNS provider module
Certificates issue on a staging box and burn ACME rate limitsReal domain names in a non-production CaddyfileUse tls internal or the ACME staging endpoint via the acme_ca global option
502 with x509: certificate signed by unknown authority in the logHTTPS upstream presenting a certificate from a private CAtls_trusted_ca_certs in the transport http block
gRPC or h2c upstream rejects requests as malformedCaddy defaulted to HTTP/1.1 towards the backendtransport http { versions h2c 2 }
{client_ip} equals the CDN or load balancer addresstrusted_proxies not configuredSet trusted_proxies globally, plus client_ip_headers if the CDN uses its own header
Caddy refuses to start with on-demand TLS enabledNo ask endpoint configured to authorise hostnamesAdd on_demand_tls { ask ... } in global options
A hung backend holds connections open indefinitelyNo upstream timeout by defaultSet 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.

  1. Caddy documentation: reverse_proxy directive
  2. Caddy documentation: Automatic HTTPS
  3. Caddy documentation: Caddyfile concepts and directive order
  4. Caddy documentation: JSON config structure
  5. Caddy documentation: tls directive and on-demand TLS
  6. Caddy source repository
  7. RFC 8555: Automatic Certificate Management Environment (ACME)
  8. 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.

More in reverse proxy configuration#