Adds deploy/caddy/Caddyfile and docs/deploy-caddy.md, the front-end
half of the production deployment that pairs with deploy-podman.md.
Caddyfile:
- reverse_proxy with flush_interval -1 (mandatory for /mcp SSE)
- structured JSON access log to a separate file
- validated with `caddy validate` and formatted with `caddy fmt`
- omits explicit X-Forwarded-{For,Proto,Host} since Caddy forwards
them by default (caddy validate flags them as redundant)
deploy-caddy.md walks operators through:
- why a reverse proxy at all (TLS, SSE, future rate limits)
- the host-header trap and why FJMCP_BROKER_PUBLIC_URL is the
trusted source of issuer URLs (cross-references the existing
TestDiscovery_IssuerIgnoresHostHeader regression)
- SSE buffering as the most common deployment foot-gun
- optional rate-limit recipe via caddy-ratelimit (defers to backlog
issue -ttl)
- troubleshooting for the four failure modes the broker has actually
seen during dev: wrong issuer, buffered SSE, unreachable upstream,
TLS conflict
README updated to link both deploy guides and the deploy/ subtree.
Closes forgejo-mcp-broker-r2c.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4.6 KiB
Deploying fjmcp-broker behind Caddy
This is the front-end half of the production deploy. Pair with
docs/deploy-podman.md for the broker container
itself.
Why Caddy
The broker terminates plain HTTP on 127.0.0.1:8080. Caddy adds:
- automatic Let's Encrypt TLS;
- HTTP→HTTPS redirect;
- SSE-friendly proxying for the
/mcpendpoint; - a clean place to pin rate limits later (issue
-ttl).
Other reverse proxies (nginx, Traefik) work too — three knobs to mind:
- Disable response buffering on
/mcp. SSE is useless behind a proxy that buffers the response. In Caddy,flush_interval -1. In nginx,proxy_buffering off. - Forward
X-Forwarded-Protofor free. The broker doesn't use it for issuer URL construction (intentional — see below), but downstream tooling and access logs benefit. - Don't strip
/oauthor/.well-knownpaths. The broker mounts every public endpoint at the root.
The host-header trap
Discovery documents at /.well-known/oauth-authorization-server and
/.well-known/oauth-protected-resource carry the issuer URL —
where clients should send Bearer tokens, where they should land for
authorization, etc. If these URLs are constructed from the inbound
Host header, an attacker can craft a request whose response advertises
attacker-controlled endpoints. A naive client follows them and leaks
credentials.
The broker defends against this by deriving every URL from
FJMCP_BROKER_PUBLIC_URL at startup, ignoring the request's Host
entirely. Set that env var to exactly the hostname Caddy serves —
mismatch shows up as redirects to the wrong host.
There's an explicit regression test for this attack
(TestDiscovery_IssuerIgnoresHostHeader).
Quick start
Assuming the broker is already running per deploy-podman.md:
sudo cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile
sudo $EDITOR /etc/caddy/Caddyfile # replace mcp.example.com
sudo systemctl reload caddy
Verify:
curl -fsS https://mcp.example.com/healthz | jq .
curl -fsS https://mcp.example.com/.well-known/oauth-authorization-server | jq .issuer
# → "https://mcp.example.com" (matches FJMCP_BROKER_PUBLIC_URL)
SSE through Caddy
The /mcp endpoint streams Server-Sent Events for tool responses.
Default reverse-proxy buffering breaks this — the client sees nothing
until the response completes, defeating the whole streaming model.
flush_interval -1 in the example Caddyfile tells Caddy to flush every
write through to the client immediately.
Quick sanity check (after wiring the OAuth flow end-to-end):
# Use a long-running tool call as the test. Authorization elided.
curl --no-buffer -N \
-H "Authorization: Bearer $TOK" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",...}' \
https://mcp.example.com/mcp
You should see event: message\ndata: ...\n\n arrive incrementally,
not all at once.
Rate limits (optional, recommended)
Caddy doesn't ship with a rate-limit module by default. The
caddy-ratelimit plugin adds one:
xcaddy build --with github.com/mholt/caddy-ratelimit
Then in your Caddyfile:
mcp.example.com {
@oauth_register path /oauth/register
@oauth_token path /oauth/token
rate_limit @oauth_register {
zone register {
key {remote_host}
events 5
window 1m
}
}
rate_limit @oauth_token {
zone token {
key {remote_host}
events 30
window 1m
}
}
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
# ... rest as above
}
}
This is tracked as backlog issue -ttl. Skip it for first deployment;
add when the audit log shows abuse worth blocking.
Troubleshooting
/.well-known returns the wrong issuer. FJMCP_BROKER_PUBLIC_URL
in the broker's env doesn't match the Caddy hostname. Fix it in
~/.config/fjmcp-broker.env and systemctl --user restart fjmcp-broker.
SSE responses arrive only when complete. Buffering is on
somewhere in the chain. Check flush_interval -1. If you have another
proxy in front of Caddy (a CDN, a load balancer), it may be buffering
too — disable response buffering there as well.
Caddy can't reach 127.0.0.1:8080. The Quadlet unit publishes
the broker on loopback only. Confirm the container is up
(systemctl --user status fjmcp-broker) and that the port is
actually published (ss -tlnp | grep 8080).
TLS issues a wrong cert. Caddy needs ports 80 and 443 open. Make sure no other process is bound to them; Caddy listens by default.