Skip to content

Scaling Horizontally

Claude edited this page Sep 14, 2026 · 2 revisions

Scaling Horizontally

A single api + single worker on one well-specced VM is plenty for one operator or a handful of orgs, and is the default. When you need more, both tiers scale horizontally without a message broker — just Postgres:

  • Scaling the worker. Background work runs off a Postgres work queue (background_jobs): the leader enqueues due jobs, and any number of workers claim them with SELECT … FOR UPDATE SKIP LOCKED, so they never double-process. Run more with docker compose up -d --scale worker=3 — heavy report ingestion then parallelizes across replicas. Exactly one replica auto-elects itself leader (a Postgres advisory lock) to run the schedule; if it dies another takes over automatically. Each worker also serves a liveness endpoint (:8080/health) that reports unhealthy if its loop stalls, so an orchestrator recycles a wedged — not just crashed — replica.
  • Scaling the api. It's stateless (sessions live in Postgres), so it scales out freely — with one caveat: the auth rate limiter defaults to in-memory per-process. Running multiple api replicas? Set RATE_LIMIT_BACKEND=postgres so the limit is shared and correct across them; it's dependency-free (reuses Postgres) and the auth-endpoint volume is tiny.
  • No Redis, no Celery — on purpose. Reusing the datastore you already run and have hardened (RLS, encryption-at-rest, backups) keeps the attack surface and the ops burden down versus adding a broker.
  • Connection pooling (PgBouncer). Point DATABASE_URL at a PgBouncer instance once replica count makes raw connection count matter. Set LEADER_DATABASE_URL to a direct, unpooled connection, since the leader's advisory lock is session-scoped and would misbehave under transaction pooling.
  • Read replicas for report/analytics queries. Set DATABASE_READ_URL to offload report/trend/posture dashboard endpoints there — unset by default, entirely opt-in. Known limitation: three of these endpoints can write to a small sender-identity cache on a miss, so don't point this at a read-only replica yet — what IS safe today is a second connection to the same primary.
  • Still single by default: Postgres and the resolver. Use a managed, backed-up Postgres instance in production, or add a read replica per above. Postgres sharding is a deferred future option — the RLS-by-organization_id data model already gives it a natural shard key if a single primary's vertical ceiling ever becomes the bottleneck.

Full env var reference → Configuration Reference.


← Production & Scaling · Home

Clone this wiki locally