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:
-
handle_path /b/*— strips the prefix of its own pattern. The prefix removed is the pattern with the*taken off and then the trailing/taken off too, so/b/*strips/b, not/b/. -
uri strip_prefix /c— strips exactly the string you wrote, nothing adjusted. It is independent of thehandlepattern, so it can strip the wrong thing or nothing at all.
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
}
}
| Block | Transform | Request | Backend 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 /k | none | /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 …
}
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}
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.