# Deploying fjmcp-broker behind Caddy This is the front-end half of the production deploy. Pair with [`docs/deploy-podman.md`](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 `/mcp` endpoint; - a clean place to pin rate limits later (issue `-ttl`). Other reverse proxies (nginx, Traefik) work too — three knobs to mind: 1. **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`. 2. **Forward `X-Forwarded-Proto` for free.** The broker doesn't use it for issuer URL construction (intentional — see below), but downstream tooling and access logs benefit. 3. **Don't strip `/oauth` or `/.well-known` paths.** 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`: ```bash sudo cp deploy/caddy/Caddyfile /etc/caddy/Caddyfile sudo $EDITOR /etc/caddy/Caddyfile # replace mcp.example.com sudo systemctl reload caddy ``` Verify: ```bash 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): ```bash # 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: ```bash xcaddy build --with github.com/mholt/caddy-ratelimit ``` Then in your Caddyfile: ```caddy 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.