Self-hosting
Operator guide: run Plat5 (and optionally Plat5 Auth) on one machine with published images + Compose.
This is not the CLI. plat5 start is laptop-only. On a server you pull GHCR images, fill .env, and apply routes yourself.
Local contracts stay in this repo (idp-contract.md, routes.md, route-registry.md, gateway-contract.md). Auth env/OIDC live in the Auth repo. This page is the cross-product operator path those READMEs already link.
What you run
Section titled “What you run”Internet https://api.example.com → TLS edge → gateway :5001 https://auth.example.com → TLS edge → issuer :5000 (or any OIDC IdP)
Docker (two stacks, two networks): plat5 postgres, etcd, valkey, gateway, identity, audit, route-registry auth postgres, issuer (skip if you already have an IdP)
Your app: API process on the plat5 compose network (gateway `url` = `http://api:3000`) SPA anywhere (Pages, nginx, …) — talks to public api + auth URLs| Piece | Image pin | Compose |
|---|---|---|
| Runtime (gateway, identity, audit, route-registry) | PLAT5_VERSION |
compose/docker-compose.prod.yml |
| Auth (optional IdP) | AUTH_VERSION (independent) |
auth compose/docker-compose.prod.yml |
| Your API | your image | your compose; join plat5’s network |
| TLS | — | Cloudflare tunnel, Caddy, … — not in the gateway |
Do not join the Auth Docker network from Plat5. The gateway fetches JWKS over the public issuer URL (same iss clients see).
1. Auth (or BYO IdP)
Section titled “1. Auth (or BYO IdP)”Plat5 requires AUTH_ISSUER + AUTH_JWKS_URI to become ready. API keys are an alternative credential.
Plat5 Auth
Section titled “Plat5 Auth”git clone https://github.com/plat5dev/auth.gitcd auth/composecp .env.template .env # set secrets + AUTH_VERSIONdocker compose -f docker-compose.prod.yml --env-file .env up -dRequired: POSTGRES_PASSWORD, SMTP_HOST, SMTP_USER, SMTP_PASS. Without all three SMTP vars the issuer still starts, but sending a login code fails (in every environment). Never set AUTH_DEV_MODE here: it logs login codes and enables POST /dev/token. Mail is not bundled — Resend/SES, or a host MTA.
If the issuer reaches SMTP on the Docker host, Auth’s prod compose already maps host.docker.internal on the issuer (extra_hosts: host.docker.internal:host-gateway):
SMTP_HOST=host.docker.internalSMTP_PORT=587SMTP_TLS_INSECURE=true # snakeoil / private CAIssuer always sends SMTP_USER / SMTP_PASS (nodemailer). Use submission :587 + SASL, not an open :25 relay.
Also set:
| Variable | Role |
|---|---|
PUBLIC_ISSUER_URL |
Token iss / links (e.g. https://auth.example.com) |
AUTH_ALLOWED_CLIENTS |
OAuth client_id — must match gateway AUTH_ALLOWED_AUDIENCES and the SPA client id |
AUTH_ALLOWED_REDIRECT_URIS |
Exact callback URLs (https://app.example.com/callback) |
AUTH_ALLOWED_ORIGINS |
Browser CORS for the SPA origin |
AUTH_DISPLAY_NAME |
Login title + verification email copy |
AUTH_USER_ID_CLAIM on Plat5 |
properties.user_id for this issuer |
Env reference: auth docs/env.md.
Other IdP
Section titled “Other IdP”Point Plat5 at it. Typical AUTH_USER_ID_CLAIM is sub. See idp-contract.md.
2. Plat5 runtime
Section titled “2. Plat5 runtime”git clone https://github.com/plat5dev/plat5.gitcd plat5/composecp .env.template .envdocker compose -f docker-compose.prod.yml --env-file .env up -dRequired: POSTGRES_PASSWORD, ADMIN_TOKEN, INTERNAL_AUTH_TOKEN, AUTH_ISSUER, AUTH_JWKS_URI.
Pin PLAT5_VERSION to a released tag (see .env.template).
AUTH_ISSUER=https://auth.example.comAUTH_JWKS_URI=https://auth.example.com/.well-known/jwks.jsonAUTH_ALLOWED_AUDIENCES=<same as AUTH_ALLOWED_CLIENTS>AUTH_USER_ID_CLAIM=properties.user_id # Plat5 Auth; use sub for many OIDC IdPs# APIKEY_BRAND=plat5 # optional; identity only; keys {brand}-k-2-…, sessions {brand}-s-2-…# ROLES_FILE=/etc/plat5/roles.yml # the default; identity; compose/roles.yml is mounted there. Empty = roles offWith ROLES_FILE, every member holds one of its roles, and the identity catalog’s labels decide who manages the org. Compose mounts compose/roles.yml at /etc/plat5/roles.yml and points ROLES_FILE there; edit it before first boot. Format and rules: roles.md. Identity reads it at boot; restart identity after editing it.
ROLES_FILE= (empty) turns roles off: no member has a role, every member holds every label, and route labels block no one. Use it when something else decides authorization. Identity logs a warning at boot.
Audit is on by default: the gateway records each audited organization and member request in the org’s audit log, through the audit service. While audit is down, audited requests are 503. AUDIT_ENABLED=false on the gateway turns it off for the whole deployment; then you may stop the audit service and skip its catalog. Model and guarantees: audit.md.
POSTGRES_PASSWORD is interpolated into DATABASE_URL. Use a URL-safe value (hex). + / / from raw base64 break the URL (invalid port).
Compose network name is plat5_plat5 (name: plat5 + network plat5). Attach apps to that name.
Route-registry :5002 is not published. Apply from a container on plat5_plat5, or an SSH tunnel. Do not put the admin API on the public internet.
Gateway ALLOWED_ORIGINS is unset in prod compose → CORS *. Set it on the gateway service when you want an allowlist.
JWKS must be a URL the gateway container can fetch. Public HTTPS is the usual case — no host.docker.internal on the gateway.
3. TLS
Section titled “3. TLS”Gateway and issuer speak HTTP. Terminate TLS at the edge:
- Cloudflare tunnel:
auth.example.com→http://127.0.0.1:5000,api.example.com→http://127.0.0.1:5001 - Or Caddy / nginx on the host
Do not put SMTP submission on a tunnel.
AUTH_ISSUER / PUBLIC_ISSUER_URL / SPA VITE_AUTH_ISSUER must be the same public string. http://localhost:5000 ≠ https://auth.example.com.
4. Attach your API
Section titled “4. Attach your API”- Run the process on
plat5_plat5(external network). Hostname = the service name inroutes.yml(e.g.api→url: http://api:3000). - Trust the path the gateway wrote. Do not validate JWTs.
gateway-contract.md. - Apply identity catalog + your routes (prod does not seed):
# your-api/routes.yml — set url to what the gateway can reachservices: api: url: http://api:3000 organization: route_prefix: /api routes: - path: /widgets upstream: /organizations/{subject.organization_id}/widgets methods: [GET, POST]# from a throwaway client on plat5_plat5curl -sS -X POST http://route-registry:5002/apply \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/yaml" \ --data-binary @services/identity/routes.yml
curl -sS -X POST http://route-registry:5002/apply \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/yaml" \ --data-binary @services/audit/routes.yml # skip with AUDIT_ENABLED=false
curl -sS -X POST http://route-registry:5002/apply \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/yaml" \ --data-binary @routes.ymlIdentity public paths are operator-owned. Catalog: services/identity/routes.yml. Omitting a path hides it from the gateway; the identity process still runs. The audit catalog (services/audit/routes.yml) publishes GET /org/audit-events, labeled org:audit:read.
Your organization and member writes are in the org’s audit log by default. Set audit: false on a write nobody needs there, and audit: true on a read worth recording (routes.md).
Apply is upsert. Format: routes.md, route-registry.md.
Local CLI upstreams: (bare port → host.docker.internal) is for laptop Docker. On a shared compose network, put url: in the file.
5. SPA
Section titled “5. SPA”Build-time env (Vite example):
VITE_GATEWAY_URL=https://api.example.comVITE_AUTH_ISSUER=https://auth.example.comVITE_AUTH_CLIENT_ID=<AUTH_ALLOWED_CLIENTS>VITE_AUTH_REDIRECT_URI=https://app.example.com/callbackThe SPA does not need the brand. A credential’s kind comes back in the API response that issued it.
SPA fallback so /callback serves index.html (Pages _redirects: /* /index.html 200).
Match Auth allowlists to that origin + redirect. Empty gateway ALLOWED_ORIGINS is *.
Static hosting (Cloudflare Pages, object storage, nginx) is fine. The console is not part of the Plat5 runtime.
Health
Section titled “Health”curl -sS -o /dev/null -w '%{http_code}\n' https://auth.example.com/.well-known/jwks.json# 200
curl -sS https://api.example.com/org# 401 UNAUTHORIZED — route exists, no member credentialEmpty route map → 404. After apply, missing JWT → 401.
Gateway ready requires JWKS loaded and Valkey reachable (/health/ready on internal :8000). While Valkey is unreachable, limited requests are 503; they resume when it answers again. Valkey restart resets open rate-limit windows.
Footguns
Section titled “Footguns”- Choose
AUTH_USER_ID_CLAIMbefore the first sign-in. Identity links each person by issuer and that claim’s value, so changing the claim later signs everyone in as a new person. With Plat5 Auth useproperties.user_id(the template default); usesubfor generic OIDC IdPs. plat5.yml/plat5 startdo not operate this stack.docker compose down -vwipes identity orgs/keys and Auth users.- Recreating etcd without Postgres is ok (projection rebuilds). Wiping Postgres loses desired routes — re-apply.
- Audience and SPA client id are one string if you customize them. The brand is identity’s alone.
Images: ghcr.io/plat5dev/{gateway,identity,route-registry}:$PLAT5_VERSION and ghcr.io/plat5dev/auth:$AUTH_VERSION. Tags are independent. Use released v* tags, not latest.