Self-hosting & production deployment

Step-by-step guide: Going live: your own domain and server. This page describes the architecture.

Reference architecture

                 Cloudflare / CDN (TLS, DDoS protection, static caching)
                              │
                      Load balancer (L7)
           ┌──────────────────┼────────────────────┐
           ▼                  ▼                    ▼
   web (Next.js) ×N     api (Fastify) ×N    realtime (ws) ×N   ← WebSocket upgrade, no sticky sessions needed
           │  /api → api      │                    │
           │                  ├──────► PostgreSQL (managed, HA, PITR backups)
           │                  ├──────► Redis 7 (managed, AOF on; Cluster-ready key layout)
           │                  └──────► Object storage (S3 / R2 / GCS-S3 / MinIO)
  • web: stateless; serves the marketing site, docs and dashboard; proxies /api/* to the API (same origin → first-party cookies).
  • api: stateless; scale horizontally. Runs migrations with MIGRATE_ON_START=true on one instance or as a release job (node apps/api/dist/db/migrate.js; guarded by a Postgres advisory lock).
  • realtime: stateless per node — all session state is in Redis — so any node can serve any client, and a client can resume on a different node. Put nodes behind the LB with WebSocket support; no sticky sessions required.
  • Background work (retention purge, idle session cleanup, credential cleanup) runs inside the API under an advisory lock (exactly one instance per cycle). Change persistence runs inside every realtime node (Redis consumer group).

Containers

docker build -f infrastructure/docker/api.Dockerfile -t skein/api .
docker build -f infrastructure/docker/realtime.Dockerfile -t skein/realtime .
docker build -f infrastructure/docker/web.Dockerfile -t skein/web .

All images run as a non-root user and expose /healthz + /readyz. Graceful shutdown: on SIGTERM the realtime server tells clients to reconnect (they resume on another node with their locks), the API drains in-flight requests.

Single-host deployment (small teams, self-hosting)

docker compose --profile app up -d --build runs everything on one machine. Put Caddy in front for TLS — infrastructure/caddy/Caddyfile routes app.example.com to web (and /api through it) and rt.example.com to realtime.

Checklist

  1. Generate secrets: npm run keys -w @skein/api. Store TICKET_PRIVATE_KEY + ACCESS_TOKEN_SECRET only in the API's secrets, TICKET_PUBLIC_KEY in the realtime service's.
  2. NODE_ENV=production, TRUST_PROXY=true, PUBLIC_WEB_URL=https://app.example.com, REALTIME_PUBLIC_URL=wss://rt.example.com.
  3. Postgres 16 with backups; Redis 7 with AOF (appendonly yes) and maxmemory-policy noeviction.
  4. STORAGE_DRIVER=s3 + bucket credentials (private bucket; objects are served only through the API).
  5. SMTP (SMTP_URL) for verification, reset and invitation emails.
  6. Billing: BILLING_MODE=stripe, keys, price ids; add a Stripe webhook to https://app.example.com/api/v1/billing/webhook for checkout.session.completed and customer.subscription.*. Self-hosted without billing: BILLING_MODE=disabled, DEFAULT_PLAN=studio.
  7. Monitoring: scrape /metrics with METRICS_TOKEN; alert on /readyz, skein_rt_persist_errors_total, skein_http_request_duration_seconds p99, Redis memory.
  8. Unity users set Server → API URL to https://app.example.com/api.

Upgrades

  • Database migrations are forward-only and checksummed (an edited migration refuses to run).
  • The realtime protocol is versioned (skein.v1); servers reject unknown versions with a clear "update the package" message. A new protocol version is deployed alongside the old one before clients are updated.