Lesson 2

reverse_proxy and the path prefix

In nginx a single slash inside proxy_pass decides what path the backend receives. Caddy drops that mechanism entirely: reverse_proxy forwards the request URI as it stands, and stripping a prefix is something you have to ask for by name. This lesson measures seven combinations, two of which do not answer 200.

Caddy strips nothing

By default reverse_proxy forwards the request URI unchanged. There are two ways to ask for a prefix to be removed, and they remove different things:

There is also rewrite * <pattern>, which replaces the path outright, with {uri} standing for its value immediately beforehand — so a rewrite inside a handle_path block sees the stripped path, while inside handle it sees the original one.

Seven combinations, measured

The rig runs two servers inside one caddy:2-alpine container. The one on port 8081 plays the backend and echoes back exactly the {http.request.uri} it receives.

:8081 {
	handle {
		respond "{http.request.uri}" 200
	}
}

:80 {
	handle /a/* {
		reverse_proxy localhost:8081
	}
	handle_path /b/* {
		reverse_proxy localhost:8081
	}
	handle /c/* {
		uri strip_prefix /c
		reverse_proxy localhost:8081
	}
	handle /d/* {
		rewrite * /goc{uri}
		reverse_proxy localhost:8081
	}
	handle_path /e/* {
		rewrite * /goc{uri}
		reverse_proxy localhost:8081
	}
}
BlockTransformRequestBackend received
handle /a/*none/a/x/y/a/x/y
handle_path /b/*none/b/x/y/x/y
handle /c/*uri strip_prefix /c/c/x/y/x/y
handle /d/*rewrite * /goc{uri}/d/x/y/goc/d/x/y
handle_path /e/*rewrite * /goc{uri}/e/x/y/goc/x/y
handle_path /j/sub/*none/j/sub/a/a
handle_path /knone/k/

The last two rows show two small details: handle_path can strip a multi-segment prefix, and when the strip consumes the whole path Caddy resets it to / rather than leaving it empty.

Rows four and five are the pair worth staring at. The same rewrite * /goc{uri} produces /goc/d/x/y inside handle and /goc/x/y inside handle_path. {uri} is read when the directive runs, and handle_path has already done its stripping by then.

Lose the slash and you get a 400

Both spellings below strip one character too many, and both produce the same outcome:

handle_path /g* { reverse_proxy … }   request /gx/y   → left with "x/y"
handle /h/* {                         request /h/x/y  → left with "x/y"
	uri strip_prefix /h/
	reverse_proxy …
}
Caddy answers 400 Bad Request and logs nothing After stripping, what remains is x/y — no leading /, so it is no longer a valid path. Measured: status 400, the body is the literal string 400 Bad Request, the response carries Via: 1.1 Caddy, and docker logs shows no error line at all. The backend never sees the request.

This is where Caddy and nginx punish the same slip differently. In nginx, location /d/ with proxy_pass http://be:8080/goc produces /gocx/y — a valid but wrong path, so the backend answers 404 and nobody can tell why. In Caddy the request stops at the proxy with a 400. Caddy's failure is more visible but harder to trace, because there is no log line.

A path in the upstream address gets you a 502

People coming from nginx tend to write the path straight into the backend address. Caddy does not understand that spelling, and it does not reject it either:

handle /f/* {
	reverse_proxy localhost:8081/goc
}
$ caddy validate --config /c --adapter caddyfile
Valid configuration

The server starts normally. Only the JSON shows what happened — the path is folded straight into the dial string:

"upstreams": [ { "dial": "localhost:8081/goc:80" } ]

Every request through that block answers 502, and the log records:

{"level":"error","logger":"http.log.error",
 "msg":"dial localhost:8081: unknown network localhost:8081",
 "status":502}
The spelling that works The address in reverse_proxy is a host and a port, nothing else. To give the backend a prefix, use rewrite * /goc{uri} in the same block — rows four and five of the table above are the two variants of that.

The lab

Change any field. The algorithm running in your browser is a reimplementation of the rules above, checked against Caddy v2.11.4 on exactly 13 combinations — all 13 agree, including the 400 and the 502.

The path sent upstream

Change any field.

The seven measured combinations:

Step by step

    Check yourself

    The backend serves under /api/ and you want to expose it at /api/ too. What do you write? handle /api/* { reverse_proxy be:3000 }. Nothing needs stripping, because Caddy does not strip. This is the case where someone used to nginx tends to add a superfluous handle_path.
    The backend serves at the root / but you expose it at /api/. What do you write? handle_path /api/* { reverse_proxy be:3000 }. handle_path strips /api, so /api/users reaches the backend as /users. Run it in the lab to see each step.
    Why is uri strip_prefix /api/ (with the trailing slash) a suspicious configuration? Because it takes the slash with it, leaving users instead of /users, and Caddy answers 400 Bad Request without logging anything. That is exactly the case measured in the 400 section above.
    You need the backend to receive /v1/users when a user requests /api/users. What do you write? handle_path /api/* { rewrite * /v1{uri} … }. handle_path strips /api first, leaving /users; then rewrite sees {uri} as /users and builds /v1/users. That is row five of the table with /goc replaced by /v1.