Skip to documentation

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 pgvector and pg_trgm extensions.
  • A writable DATA_DIR for 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.

from a fresh clonebash
cp .env.example .env        # edit DATABASE_URL / APP_SECRET
docker compose up -d db     # local Postgres 18 + pgvector
yarn install
yarn db:migrate             # apply schema (Drizzle)
yarn dev                    # web app on :3000
yarn worker                 # background jobs (separate terminal)

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)

  1. 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 RLS set_config need session semantics). Connect as a dedicated application role with both rolsuper=false and rolbypassrls=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.
  2. Set the environment

    Provide the variables below. Crucially set AUTH_MODE=multi and a strong APP_SECRET — a public host refuses to boot in local mode.
  3. Build and run the production topology

    The repository's docker-compose.production.yml builds 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, run yarn start:production for web and yarn worker for the resident worker.
production Composebash
# Put these in an operator-owned environment file or secret store.
export POSTGRES_PASSWORD="$(openssl rand -base64 32)"
export APP_DB_PASSWORD="$(openssl rand -base64 32)"
export DATABASE_URL="postgres://app:<url-encoded-APP_DB_PASSWORD>@db:5432/personal_knowledge"
export APP_SECRET="$(openssl rand -base64 32)"
export AUTH_MODE=multi
export APP_URL="https://eli.example.com"
export APP_DOMAIN="eli.example.com"

docker compose -f docker-compose.production.yml up -d --build
docker compose -f docker-compose.production.yml ps

External Postgres

The bundled database initializer creates the dedicated 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

With 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_URLrequiredpostgres:// connection string. Supabase: use the direct 5432 session connection.
APP_SECRETrequired≥32 characters (openssl rand -base64 32). Must be changed from the dev default in production.
AUTH_MODEoptionallocal (auto-sign-in, laptop) or multi (shared/remote). Default local.
SSO_ALLOWED_ISSUER_ORIGINSoptionalComma-separated exact HTTPS origins workspace OIDC issuers may use. Recommended in production; retain network egress filtering.
APP_URLoptionalPublic base URL. Default http://localhost:3000. Drives the production local-mode guard.
DATA_DIRoptionalVault + exports directory. Default ./data.
OPENAI_API_KEYoptionalSeeds an OpenAI provider on first boot (workspace keys override).
ANTHROPIC_API_KEYoptionalSeeds an Anthropic provider on first boot.
GOOGLE_GENERATIVE_AI_API_KEYoptionalSeeds a Google provider on first boot.
MAX_AGENT_STEPSoptionalHard ceiling clamping any agent's maxSteps. Default 50.
MAX_AGENT_COST_USDoptionalHard ceiling clamping any agent's maxCostUsd. Default 5.
WORKER_INSTANCE_IDoptionalStable id used by the worker heartbeat and its container health check. Compose uses primary.
WORKER_HEARTBEAT_INTERVAL_MSoptionalHeartbeat write interval. Default 10000.
WORKER_HEARTBEAT_STALE_MSoptionalAge after which the worker health command fails. Default 45000.
ELI_ALLOW_STDIO_MCPoptionalSet "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/health is process liveness only: it makes no database or filesystem call and returns uncached uptime/version metadata.
  • GET /api/health/ready returns 200 only when Postgres is reachable, the runtime role is safe, and DATA_DIR is 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:worker fails 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