forgejo-mcp-broker/docs/deploy-podman.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

169 lines
5.5 KiB
Markdown

# Deploying fjmcp-broker with rootless podman
End-to-end walkthrough for a production deployment: build the image,
configure secrets, start the container, persist state across restarts,
and upgrade to a newer image without losing data.
## Prerequisites
- Podman 4.4 or later (Quadlet support).
- A reachable hostname with TLS (Caddy handles this — see
`docs/deploy-caddy.md` for the front-end half).
- A Forgejo instance you control, with permission to register an OAuth2
application.
## 1. Register the Forgejo OAuth application
The broker authenticates users by delegating to your Forgejo instance's
OAuth2 provider.
1. Sign in to Forgejo as the operator who should own the integration.
2. **Settings → Applications → OAuth2 Applications → Create
application**.
3. Redirect URI: `https://<your-broker-hostname>/oauth/callback`.
4. Save the issued `client_id` and `client_secret`.
## 2. Build the image
The Containerfile bundles two binaries — `fjmcp-broker` (this repo) and
a pinned version of `forgejo-mcp` — into a distroless static image.
```bash
podman build \
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--build-arg GIT_REVISION="$(git describe --always --dirty)" \
--build-arg FORGEJO_MCP_VERSION="2.18.0" \
-t ghcr.io/olemd/fjmcp-broker:latest \
.
```
Pin `FORGEJO_MCP_VERSION` to a tag — `latest` would mean image
reproducibility depends on the upstream HEAD at build time. The broker
spawns this binary per session, so version drift is the operator's
problem to track.
## 3. Configure the environment
```bash
mkdir -p ~/.config
cp deploy/podman/fjmcp-broker.env.example ~/.config/fjmcp-broker.env
chmod 0600 ~/.config/fjmcp-broker.env
$EDITOR ~/.config/fjmcp-broker.env
```
Required values:
| Variable | Purpose |
|---|---|
| `FJMCP_BROKER_PUBLIC_URL` | What clients see (e.g. `https://mcp.example.com`) |
| `FORGEJO_URL` | Upstream Forgejo instance |
| `FORGEJO_OAUTH_CLIENT_ID` | From step 1 |
| `FORGEJO_OAUTH_CLIENT_SECRET` | From step 1 |
The broker derives discovery URLs (`/.well-known/...`) from
`FJMCP_BROKER_PUBLIC_URL`, never from the inbound `Host` header — set
this to exactly the hostname Caddy serves.
## 4. Start under systemd via Quadlet
```bash
mkdir -p ~/.config/containers/systemd
cp deploy/podman/fjmcp-broker.container ~/.config/containers/systemd/
systemctl --user daemon-reload
systemctl --user start fjmcp-broker
systemctl --user status fjmcp-broker
```
Quadlet creates the `fjmcp-state` named volume on first start. The
SQLite store and its WAL/SHM sidecars all live there.
Smoke-test:
```bash
curl -fsS http://127.0.0.1:8080/healthz | jq .
```
A healthy response includes `{"status":"ok","store":"ok",…}`. Wire
Caddy in front next; see [`deploy-caddy.md`](deploy-caddy.md).
## 5. Persist state across image upgrades
The point of the named volume is that you can replace the container
entirely and keep your data. To upgrade:
```bash
# Pull / build the new image.
podman pull ghcr.io/olemd/fjmcp-broker:latest
# Restart — Quadlet recreates the container against the new image.
systemctl --user restart fjmcp-broker
```
The `fjmcp-state` volume is detached from container lifecycle. Any
registered OAuth clients (`/oauth/register` results), issued tokens,
and refresh-token history survive.
To verify mid-upgrade:
```bash
# Before upgrade
podman exec fjmcp-broker /usr/local/bin/fjmcp-broker --version
# Inspect the SQLite store directly (via a temporary container).
podman run --rm -v fjmcp-state:/data:ro \
docker.io/library/alpine ls -l /data/
# After upgrade, confirm the same broker.db is in place.
```
The migration runner is idempotent — re-opening the same database is a
no-op for already-applied migrations, so a downgrade-then-upgrade
across compatible schema versions works too.
## 6. Backups
`fjmcp-state` contains user-mapped credentials. Treat it as sensitive:
```bash
# Online backup using SQLite's .backup command.
podman exec fjmcp-broker /usr/local/bin/sqlite3 \
/data/broker.db ".backup /data/backup.db"
podman cp fjmcp-broker:/data/backup.db ./broker-$(date -u +%Y%m%d).db
```
Encrypt backups at rest. The broker stores Forgejo access tokens in
cleartext (it has to, to spawn subprocesses with them in env); a leaked
backup gives an attacker every user's upstream token until each
expires.
## Troubleshooting
**Healthz returns 503 with `store: "error: ..."`.** The SQLite file
isn't reachable. Check volume mount and permissions:
```bash
podman exec fjmcp-broker ls -l /data
```
The container runs as uid 65532. If the volume was created with
different ownership (e.g. you bind-mounted a host directory), `podman
unshare chown -R 65532:65532 /path/to/host/dir`.
**`/oauth/authorize` redirects to a wrong host.** The broker echoes
`FJMCP_BROKER_PUBLIC_URL` verbatim in OAuth metadata. Mismatched values
between the broker and Caddy show up as redirects to the wrong domain.
**Container exits immediately with `config error`.** A required env
var is missing or empty. The broker lists every missing field in its
stderr:
```bash
journalctl --user -u fjmcp-broker --since "5 minutes ago"
```
**Forgejo callback fails with 404.** The Forgejo OAuth application's
registered redirect URI doesn't match
`${FJMCP_BROKER_PUBLIC_URL}/oauth/callback`. Trailing slashes and
`http` vs `https` matter here.