Self-hosting
Deployment
eli.ai runs as two long-lived Node processes over one Postgres — a web process (the UI + API) and a worker process (pg-boss jobs: extraction, background/scheduled runs, the reaper). Both come from the same package with two entrypoints. It is deliberately not serverless. The topology is described in Architecture.
Prerequisites
- Node 26+.
- Postgres 17+ (18 preferred) with the
pgvectorandpg_trgmextensions. - A writable
DATA_DIRfor vault files and exports.
Local development
The bundled docker-compose.yml starts a local Postgres (pgvector on port 54329) that mirrors Supabase by creating a non-superuser app role — load-bearing, because a superuser bypasses RLS and would make every isolation test pass vacuously.
With AUTH_MODE=local (the default) eli.ai auto-signs-in a local user with a default workspace — zero friction on a laptop.
Production (VPS or container host)
Provision Postgres
A Postgres 17/18 with pgvector + pg_trgm. For a hosted DB, use Supabase with its direct session-mode connection (port 5432 — not the 6543 transaction pooler; pg-boss and RLSset_configneed session semantics). Connect as a dedicated application role with bothrolsuper=falseandrolbypassrls=false; do not use a managed administrative role. Web and worker synchronously verify both catalog attributes and refuse to serve if the check is unsafe or unreadable.Set the environment
Provide the variables below. Crucially setAUTH_MODE=multiand a strongAPP_SECRET— a public host refuses to boot in local mode.Build and run the production topology
The repository'sdocker-compose.production.ymlbuilds one image, starts separate web and worker services, Postgres/pgvector, and Caddy with TLS, and mounts shared persistent application data. If you use another supervisor, runyarn start:productionfor web andyarn workerfor the resident worker.
External Postgres
app role only for a fresh Compose database volume. When DATABASE_URL points at hosted Postgres, create an equivalent non-superuser, non-BYPASSRLS role there first and omit or replace the bundled database service in your deployment definition.AUTH_MODE=local is guarded in production
NODE_ENV=production and a non-local APP_URL, booting in AUTH_MODE=local is refused — it would expose the whole app unauthenticated. Set AUTH_MODE=multi, or, only for a genuinely private host, set ALLOW_LOCAL_AUTH_IN_PROD=yes-i-understand.Environment variables
| DATABASE_URL | required | postgres:// connection string. Supabase: use the direct 5432 session connection. |
| APP_SECRET | required | ≥32 characters (openssl rand -base64 32). Must be changed from the dev default in production. |
| AUTH_MODE | optional | local (auto-sign-in, laptop) or multi (shared/remote). Default local. |
| SSO_ALLOWED_ISSUER_ORIGINS | optional | Comma-separated exact HTTPS origins workspace OIDC issuers may use. Recommended in production; retain network egress filtering. |
| APP_URL | optional | Public base URL. Default http://localhost:3000. Drives the production local-mode guard. |
| DATA_DIR | optional | Vault + exports directory. Default ./data. |
| OPENAI_API_KEY | optional | Seeds an OpenAI provider on first boot (workspace keys override). |
| ANTHROPIC_API_KEY | optional | Seeds an Anthropic provider on first boot. |
| GOOGLE_GENERATIVE_AI_API_KEY | optional | Seeds a Google provider on first boot. |
| MAX_AGENT_STEPS | optional | Hard ceiling clamping any agent's maxSteps. Default 50. |
| MAX_AGENT_COST_USD | optional | Hard ceiling clamping any agent's maxCostUsd. Default 5. |
| WORKER_INSTANCE_ID | optional | Stable id used by the worker heartbeat and its container health check. Compose uses primary. |
| WORKER_HEARTBEAT_INTERVAL_MS | optional | Heartbeat write interval. Default 10000. |
| WORKER_HEARTBEAT_STALE_MS | optional | Age after which the worker health command fails. Default 45000. |
| ELI_ALLOW_STDIO_MCP | optional | Set "true" to permit stdio MCP transports (spawns local processes). Off by default. |
The environment is zod-validated at boot and fails fast with a readable message rather than propagating undefined at request time.
Migrations
Migrations are an explicit boot gate. The production image runs yarn db:migrate, then the idempotent legacy webhook-secret migration, before next start; the worker also runs schema migrations before it polls a queue. A PostgreSQL advisory lock serializes simultaneous web/worker starts, with lock_timeout and statement_timeout bounding DDL waits. RLS policies live in the Drizzle schema, with a catalog assertion that every table containing workspace_id is FORCE-RLS.
Do not use plain yarn start as a release migration
yarn start is only next start. Use yarn start:production or run yarn db:migrate as a required release job before sending traffic.Health & upgrades
GET /api/healthis process liveness only: it makes no database or filesystem call and returns uncached uptime/version metadata.GET /api/health/readyreturns 200 only when Postgres is reachable, the runtime role is safe, andDATA_DIRis readable and writable; otherwise it returns 503. The JSON includes the latest worker heartbeat as an informational field.- The worker writes a named PostgreSQL heartbeat and
yarn health:workerfails once that instance is stale. Compose uses that command for worker health. Worker staleness deliberately does not fail web readiness, so worker and request-serving incidents can be routed independently. - Upgrade by pulling the new build and restarting both processes. Operational rule: never upgrade day-of-demo.
- Data residency: a workspace pinned to a local provider never sends content off-box — enforced in the routing layer. See Providers & keys.
Next
- Providers & keys — wire up models.
- Backup & data — protect the database and vault.