Operations & observability
Running the platform day to day: health, logs, backups, and a short runbook. Pairs with Deployment.
Health checks
GET /api/v1/health is public (for load balancers / uptime monitors) and returns:
{ "status": "ok", "version": "1.0.0", "service": "playlogiq-api",
"checks": { "database": { "status": "ok", "latency_ms": 3 } },
"timestamp": "…" }200 when healthy, 503 when a check fails (status: "degraded"). It verifies
Supabase/Postgres connectivity. Point your uptime monitor at it per environment.
Logs
- App/worker/service logs:
docker compose logs -f <service>(e.g.web,api,worker,rest,db). The worker logs each queue’s activity and delivery outcomes; webhook attempts are also persisted inwebhook_deliveries. - Error tracking (optional): Sentry is wired via
NEXT_PUBLIC_SENTRY_DSN/SENTRY_*. Product analytics (optional): PostHog viaNEXT_PUBLIC_POSTHOG_KEY/_HOST. Both are off if the env vars are unset.
Backups & restore
- A daily job dumps the whole database and uploads it off-site to a Hetzner
Storage Box, pruning beyond the retention window (
infra/backup/backup.sh, scheduled viainfra/backup/crontab.example). Config comes frominfra/.env(STORAGEBOX_*,BACKUP_RETENTION_DAYS). - Restore with
infra/backup/restore.sh. Test a restore periodically — an untested backup is not a backup.
Runbook
Roll an app to a new image
cd /opt/playlogiq/infra
docker compose pull <web|api|worker|docs>
docker compose up -d --force-recreate <service>
docker inspect playlogiq-<service>-1 --format '{{.Image}}' # confirm digest changedWait for CI “Build & push images” to be green for the commit before pulling.
Apply a database migration
docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d postgres \
< ../supabase/migrations/NNN_description.sql
docker compose restart rest # if it added a table/RPC PostgREST must exposeApply only the new files in production (older migrations aren’t all idempotent). See Deployment.
PostgREST returns 404 for a new table/RPC
It hasn’t reloaded its schema cache: docker compose restart rest.
Inspect background jobs
The worker drains BullMQ queues over Redis. Check worker health with
docker compose logs -f worker; inspect Redis with docker compose exec redis redis-cli. Queues are documented in Background jobs.
A service is unhealthy
docker compose ps shows status; docker compose restart <service>; check
GET /health and the logs. db must be healthy before web/api will serve.
Ingestion rejects / rate limits
429 responses mean a tenant is over its per-minute budget
(RATE_LIMIT_INGEST_PER_MIN, default 6000). The limiter fails open if Redis is
down. See Event ingestion API → Rate limits.
Environments (staging + prod)
Run staging on a separate, dedicated server — never share a database or box with production. Staging runs the identical full stack (Caddy + the four apps + worker + the whole self-hosted Supabase), so RAM is the binding constraint.
- Production (reference): Hetzner CPX42 — 8 vCPU / 16 GB / 320 GB.
- Staging (recommended): Hetzner CPX32 — 4 vCPU / 8 GB / 160 GB. 8 GB comfortably runs the full stack at staging load; 4 GB risks OOM with all of Supabase + four Next.js apps + the worker.
Images are environment-baked
The NEXT_PUBLIC_* values (Supabase URL + anon key, and the app/API/player/docs
URLs) are inlined into the browser bundle at build time. Production CI
(images.yml) bakes the production URLs and tags images :latest on push to
main. Staging CI (images-staging.yml) bakes the staging URLs and tags
:staging on push to develop.
Consequence: a staging server cannot reuse :latest — its bundle would call
production. It pulls :staging (built with crm.staging.logiqdesk.com, etc.).
Bring up a staging server
- Provision the server (Ubuntu) and add DNS A records for the staging
hosts → the staging IP. The Caddyfile is
{$DOMAIN}-parametrised, so withDOMAIN=staging.logiqdesk.comthe hosts arecrm.staging.logiqdesk.com,play.staging.logiqdesk.com,crm-api.staging.logiqdesk.com,crm-docs.staging.logiqdesk.com,crm-db.staging.logiqdesk.com. - Clone the repo to
/opt/playlogiqandcd infra. - Generate fresh secrets:
bash gen-secrets.sh— a staging environment must have its own secrets (never copy production’s). - In
infra/.envsetDOMAIN=staging.logiqdesk.comandIMAGE_TAG=staging(so compose pulls the:stagingimages), plus any external creds you want staging to exercise. - Add a repo variable
NEXT_PUBLIC_SUPABASE_ANON_KEY_STAGING(the stagingANON_KEYfrom step 3) soimages-staging.ymlbakes the right anon key, then run that workflow (push todevelopor dispatch it). docker login ghcr.io(if the images are private),docker compose up -d, apply migrations to the fresh DB (bash db-migrate.sh --seed), and bootstrap an admin (bash bootstrap-admin.sh).
Promotion model
develop ──images-staging.yml──► :staging ──► staging server (staging URLs, staging DB)
│ validate (health, smoke test, dashboard, migrations)
▼
merge to main ──images.yml──► :latest ──► production server- Migrations are plain SQL (environment-agnostic): apply the new files to staging first, verify, then production — same files, same order.
- Keep the two servers on separate databases and secrets; never point staging at production data.
Production builds are incremental — a commit rebuilds only the app images that changed (a shared
packages/**/lockfile/Dockerfile change rebuilds all). A docs-only commit rebuilds justdocs.