forgejo-mcp-broker/docs/deploy-caddy.md
Ole-Morten Duesund c18120c470 docs(deploy): Caddy front-end example + walkthrough (forgejo-mcp-broker-r2c)
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>
2026-04-27 17:49:25 +02:00

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 /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:

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.

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.