← Back to Wiki
Caddy / Reverse Proxy

Caddy reverse_proxy Takes One Matcher, Not Two

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.

Share on X

This assumes you already run Caddy with a Caddyfile.

Start with the path matcher that surprised me

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.

The fix that looks obvious and is wrong

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.

BE WARNED: nothing tells you

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.

The correct form

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.

Read the adapted config, not the exit code

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.

How to prove which backend answered

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.

Test on a staging host first

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.

The general lesson

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.