169 lines
5.4 KiB
Markdown
169 lines
5.4 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 `docs/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.
|