Adds a multi-stage Containerfile, Quadlet unit, and operator walkthrough for a production deploy. The broker spawns forgejo-mcp per session, so the image bundles both binaries — broker built from this repo, forgejo-mcp pinned via FORGEJO_MCP_VERSION build-arg (default 2.18.0). Image stages: 1. golang:alpine compiles the broker with ldflags-stamped buildinfo 2. golang:alpine clones forgejo-mcp at the pinned tag and compiles it 3. distroless static-nonroot copies both binaries; uid 65532 Persistent state via the named volume `fjmcp-state` mounted at /data. SQLite WAL + SHM sidecars live alongside broker.db on the same volume, so a container swap or image upgrade preserves all OAuth clients, issued tokens, and refresh-token history. Verified end-to-end: podman run --rm -d -v fjmcp-test-state:/data ... fjmcp-broker:test curl /healthz # store: ok, broker.db created podman stop fjmcp-test podman run --rm -d -v fjmcp-test-state:/data ... fjmcp-broker:test curl /healthz # store: ok, same broker.db ls volume → broker.db, broker.db-shm, broker.db-wal all present Quadlet unit (deploy/podman/fjmcp-broker.container) drops into ~/.config/containers/systemd/, reads secrets from a 0600 env file outside the unit, publishes :8080 on loopback for Caddy to front. Makefile gains `image` and `image-run` targets. README links to the new docs/deploy-podman.md walkthrough. Closes forgejo-mcp-broker-8yd. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.4 KiB
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.mdfor 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.
- Sign in to Forgejo as the operator who should own the integration.
- Settings → Applications → OAuth2 Applications → Create application.
- Redirect URI:
https://<your-broker-hostname>/oauth/callback. - Save the issued
client_idandclient_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.
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
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
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:
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:
# 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:
# 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:
# 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:
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:
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.