You add a second path to a reverse_proxy line. Caddy reads the second path as a
server to send traffic to. The config validates, the reload succeeds, and a route you rely on is gone.
This assumes you already run Caddy with a Caddyfile.
A path matcher of /rtc/* does not match /rtc.
The wildcard needs the slash. A bare /rtc with nothing after it falls through to whatever
comes next in your site block.
In my case that was the app instead of the media server. It had been that way for months.
Test both forms every time. A request to /thing and a request to /thing/ can
land on different backends.
So list both paths:
reverse_proxy /rtc /rtc/* localhost:7880
That reads fine. It is broken.
reverse_proxy accepts exactly one matcher token. Everything after it is an upstream.
Check what Caddy actually built with command caddy adapt --config /etc/caddy/Caddyfile
match: [{"path": ["/rtc"]}]
upstreams: ["rtc/*:80", "localhost:7880"]
Caddy took /rtc/* as a hostname. It appended port 80. It added it to the pool.
Now half your requests go to a server called rtc/* that does not exist.
And /rtc/v1 lost its route completely, because the matcher is only /rtc now.
That was the path my web clients actually used.
caddy adapt exits 0.
caddy validate passes.
systemctl reload caddy succeeds.
systemctl is-active caddy says active.
There is no warning about a hostname with a slash in it. There is no warning about an upstream that never answers. You get a clean reload and a broken site.
Use a named matcher:
@livekit path /rtc /rtc/*
reverse_proxy @livekit localhost:7880
A named matcher takes as many paths as you want.
Confirm it adapted to one route with one upstream:
match: [{"path": ["/rtc", "/rtc/*"]}]
upstreams: ["localhost:7880"]
One upstream. Both paths. That is what you want to see.
Pipe the adapt output through python and print the pairs you care about:
caddy adapt --config /etc/caddy/Caddyfile 2>/dev/null | python3 -c "
import json,sys
d=json.load(sys.stdin)
for r in d['apps']['http']['servers']['srv0']['routes'][0]['handle'][0]['routes']:
ups=[u.get('dial') for x in r.get('handle',[]) for u in (x.get('upstreams') or [])]
if ups: print(json.dumps(r.get('match')), '->', ups)
"
Run that before every reload. It takes two seconds and it shows you the thing the Caddyfile hides.
Status codes lie here. Two different backends can both return 404 for the same request.
My media server 404s a plain GET because its endpoints are websockets. My app 404s because the route does not exist. Same code, different machine.
Compare the body instead:
curl -s https://example.com/rtc | head -c 200
Empty body was the media server. A JSON error object was the app. That one line told me which upstream I had actually reached.
For a websocket route, do a real upgrade:
curl -s -i --http1.1 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://example.com/rtc
Then run the same request against the backend directly on localhost and compare.
Mine returned 401 Unauthorized with Content-Length: 33 through the proxy and
byte for byte the same thing direct. That is proof, not a guess.
You must pass --http1.1 here. curl cannot perform a websocket upgrade over HTTP/2 and you
will get a 404 that means nothing.
I caught all of this on a staging copy before it reached production.
Not because I was careful about the change. Because I checked a path I was not thinking about.
Curl every path in the site block after a proxy change. Not just the site root. The root was fine the entire time the voice path was broken.
A config language that accepts a variable number of arguments will happily read your typo as an argument.
Validation only proves the syntax parsed. It says nothing about whether the thing it parsed is the thing you meant.
Read back what the parser built, then test the behaviour at the real URL.