Deployment
Compose files
Local (loads docker-compose.override.yml, which publishes Postgres on 55432):
docker compose up -d --build
Production — base file only, so the Postgres host port is not published:
docker compose -f docker-compose.yml up -d --build
| Service | Port |
|---|---|
| Portal | 3000 |
| Gateway | 8080 |
| Docs | 3002 |
| Database | internal only in production |
| Redis | internal only |
Hard requirements
AUTH_DEV_BYPASSmust be unset orfalsein production.env. Production portal builds also ignore the flag if it is leftovertrue, so/loginnever shows the any-password demo form. After changing auth, rebuild portal (docker compose -f docker-compose.prod.yml up -d --build portal)- TLS: gateway and portal speak plain HTTP and must sit behind a TLS-terminating reverse proxy. The gateway streams SSE — disable proxy response buffering and allow long response times for
/v1/chat/completions - Redis has no authentication in compose. Keep it on the internal network, or set a password/TLS via
REDIS_URL - Do not reuse any values from a developer's local
.env
OpenAPI artifact (gateway image)
Before building the zeagate image, generate the embedded OpenAPI JSON from the YAML source (or pull a commit that already includes gateway/openapi/openapi.json):
node scripts/generate-api-contract.mjs
Portal production builds run this automatically via npm run prebuild. The portal image build uses the repository root as Docker context (portal/Dockerfile) so scripts/ and openapi/ are available inside the builder.
Database migrations
The portal applies pending database migrations automatically on startup. A normal deploy needs no manual database step.
If you see a missing-column (or missing-table) error after a deploy, pull the latest release — it must include the matching migration — and redeploy. Never patch the database by hand. Migrations are forward-only; rollbacks ship as new migrations.
Probes
| Endpoint | Use |
|---|---|
GET /health/liveliness | Liveness — process is up |
GET /health/readiness | Readiness — Postgres, and Redis when REDIS_URL is set |
Invite emails (SMTP)
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_USER=zeallm@zealogics.com
SMTP_PASS=<app password>
SMTP_FROM=zeallm@zealogics.com
Without SMTP_HOST, invites still create the account.
Weekly digest email
Every Monday the portal can email each user a short summary of their week: developers get their keys' spend and alerts, approvers what is waiting on them, platform admins platform spend, top teams and health. Users with nothing to report are skipped, and anyone can turn it off in Settings → Profile.
The digest is sent by POST /api/cron/weekly-digest. The endpoint only accepts
Authorization: Bearer $CRON_SECRET (401 otherwise, 503 while
CRON_SECRET is unset) and needs SMTP. It is safe to call more than once a
week: each user's last-sent ISO week is recorded, so a retry never sends twice.
-
Set
CRON_SECRET(32+ random characters) and theSMTP_*variables in.env. -
Start the scheduler service, which calls the endpoint every Monday at 08:00 in
ZEALLM_TIMEZONE:docker compose -f docker-compose.yml --profile weekly-digest up -d --build
To use another scheduler instead (system cron, Kubernetes CronJob, Azure Logic Apps…), call the endpoint yourself:
# Mondays 08:00
0 8 * * 1 curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" https://<portal-host>/api/cron/weekly-digest
Add ?dryRun=1 to build every digest and return the counts without sending
anything. The response is { sent, skipped, failed, ... }; one failed email
never stops the rest, and failed users are retried on the next call.
Microsoft Entra ID SSO
- Azure portal → App registration (single tenant)
- Web redirect URI:
https://<host>/api/auth/callback/microsoft-entra-id(local:http://localhost:3000/api/auth/callback/microsoft-entra-id) - Set
AZURE_AD_CLIENT_ID,AZURE_AD_CLIENT_SECRET,AZURE_AD_TENANT_ID,ZEALLM_ALLOWED_DOMAIN,ZEALLM_ADMIN_EMAILS AUTH_DEV_BYPASS=falsein the production.env, then rebuild portal
The “Continue with Microsoft” button appears once all three AZURE_AD_* vars are set. Default openid profile email scopes are enough. Production never registers the local demo credentials provider.
Load testing
A load-test script lives in loadtest/. Point it at the gateway URL, a revealed virtual key, and a model the key can access.