Environment reference
Full environment variable reference for ChatAI self-hosting and development.
Copy .env.example to .env and fill in values. Docker Compose loads .env for the app service and overrides DATABASE_URL / BETTER_AUTH_URL / UPLOAD_DIR as needed. For a production VPS start from .env.production.example and work through the production checklist.
Variables marked secret must never be committed, logged, or exposed through NEXT_PUBLIC_*. The app validates its environment at startup and refuses to boot when a value is malformed.
Core
| Variable | Required | Default / example | Purpose |
|---|---|---|---|
NODE_ENV | No | development (Docker image: production) | Runtime mode |
DATABASE_URL | Yes (runtime) | postgresql://chatai:chatai@localhost:5432/chatai | Pooled or direct Postgres URL for the app. Secret (contains the password) |
DATABASE_URL_UNPOOLED | Neon migrations | — | Direct (non-pooler) URL of the same Neon endpoint as DATABASE_URL; used by migrations and the ingest/eval workers. Migrations refuse a different endpoint unless ALLOW_DATABASE_URL_MISMATCH=1. |
BETTER_AUTH_SECRET | Yes | — | Auth signing secret (min 16 chars; use openssl rand -base64 32). Secret |
BETTER_AUTH_URL | Yes | http://localhost:3000 | Public origin browsers use for this instance |
UPLOAD_DIR | No | ./uploads (Docker: /app/uploads) | File upload storage |
RUN_MIGRATIONS | No | 1 | Docker entrypoint runs migrations before starting. 0 skips them (the VPS deploy script runs them as a separate step) |
NEXT_PUBLIC_APP_URL / NEXT_PUBLIC_DOCS_URL | No (build time) | http://localhost:3000 / http://localhost:3001 | Public app and docs origins baked into the image at build (docker build --build-arg). Never put secrets here |
SEED_DEMO_ON_START | No | — | When 1, Docker entrypoint seeds demo user after health (dev/demo only; never in production) |
SEED_DEMO_EMAIL / SEED_DEMO_PASSWORD | No | demo@chatai.local / DemoPass123! | Overrides for demo seed |
Auth (optional)
| Variable | Purpose |
|---|---|
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | Enable GitHub OAuth; leave blank to disable |
RESEND_API_KEY | Send password-reset emails via Resend. Without it, reset links are logged to the server console |
EMAIL_FROM | From address for Resend (e.g. ChatAI <noreply@yourdomain.com>; defaults to Resend sandbox from-address) |
Chat providers
| Variable | Purpose |
|---|---|
AI_PROVIDER | openai, openai-compatible, anthropic, google, openrouter, azure, ollama, groq |
AI_API_KEY | Default OpenAI-compatible key; Ollama accepts a placeholder like ollama |
AI_BASE_URL | OpenAI-compatible base URL (default https://api.openai.com/v1) |
AI_MODEL | Default chat model (e.g. gpt-4o-mini, llama3.2) |
OLLAMA_BASE_URL | Ollama OpenAI-compatible endpoint (Compose: http://ollama:11434/v1) |
ANTHROPIC_API_KEY | Anthropic |
GOOGLE_GENERATIVE_AI_API_KEY | Google Gemini |
OPENROUTER_API_KEY | OpenRouter |
GROQ_API_KEY | Groq |
AZURE_OPENAI_API_KEY / AZURE_OPENAI_RESOURCE | Azure OpenAI |
Missing credentials for the selected provider return 503 (provider X is not configured on this instance).
Embeddings
| Variable | Default | Purpose |
|---|---|---|
EMBEDDING_PROVIDER | openai | openai, openai-compatible, cohere, voyage |
EMBEDDING_MODEL | text-embedding-3-small | Embedding model id |
EMBEDDING_DIMENSIONS | 1536 | Must match chunks.embedding (vector(1536)) |
VOYAGE_API_KEY | — | Voyage embeddings |
COHERE_API_KEY | — | Cohere embeddings and optional rerank |
Changing embedding width on an existing database requires a full reprocess strategy; the column width is fixed in schema migrations today.
Rate limits and quality
| Variable | Default | Purpose |
|---|---|---|
API_RATE_LIMIT_PER_MINUTE | 60 | Per-API-key REST limit (widget publicId chat is not limited here) |
COHERE_API_KEY | — | Prefer Cohere rerank when set |
OUTPUT_SCOPE_CHECK | true | Selective output scope check: risky turns (partial, classifier fallback, injection signal, weak grounding, flexible mode, recent redirect) are buffered and checked against the assistant's Purpose before delivery. false disables it |
ASSISTANT_PROFILE | true | Assistant Profile page (Purpose and owner-reviewed Key facts) and the background Key facts refresh worker. Requires migration 0020 |
PROFILE_ANSWER_ROUTE | false | Answer basic identity, contact and hours questions from published Key facts without retrieval. Keep off until its evaluation gate passes for your model |
See Scope and Profile.
Security and widget protection
| Variable | Required | Default | Purpose |
|---|---|---|---|
ENCRYPTION_KEY | When any assistant stores its own provider key | — | AES-256-GCM key (32 bytes) for per-assistant provider secrets. Generate with openssl rand -base64 32 or openssl rand -hex 32; optional base64: / hex: prefix. Secret. Back it up: losing it makes stored provider keys unreadable |
ENCRYPTION_KEY_VERSION | No | 1 | Version label written into new ciphertext. Bump when rotating |
ENCRYPTION_KEY_PREVIOUS / ENCRYPTION_KEY_PREVIOUS_VERSION | During rotation | — | Prior key kept for decryption only until secrets are re-encrypted. Secret |
WIDGET_RATE_LIMIT_PER_VISITOR_PER_MINUTE | No | 20 | Public widget chat limit per visitor. Owners can override per assistant in Settings → Security |
WIDGET_RATE_LIMIT_PER_ASSISTANT_PER_MINUTE | No | 120 | Public widget chat limit per assistant |
WIDGET_SIGNING_MAX_SKEW_SECONDS | No | 300 | Allowed clock skew, in seconds, for signed widget requests when the assistant requires signing |
Hosted usage, billing and admin
Details: Billing and usage.
| Variable | Required | Default | Purpose |
|---|---|---|---|
HOSTED_USAGE_ENFORCEMENT | No | shadow | shadow meters without blocking, enforce applies plan limits, off disables metering writes |
HOSTED_USAGE_DEFAULT_LIMIT_MICROS | No | 1000000 | Fallback monthly provider-cost ceiling in micro-dollars ($1 = 1,000,000) when an account has no plan row |
HOSTED_USAGE_MAX_OUTPUT_TOKENS | No | 4096 | Output-token ceiling used to estimate hosted chat cost |
HOSTED_USAGE_RECONCILE_STALE_MINUTES | No | 15 | Age after which abandoned usage reservations are released |
HOSTED_USAGE_EXEMPT_PLAYGROUND | No | false | true skips hard-limit reservation for owner playground chat (still metered) |
ADMIN_USER_IDS | No | empty | Comma-separated user IDs allowed to call /api/admin/*. Empty means no admins (403 for every signed-in user) |
POLAR_ACCESS_TOKEN | For paid plans | — | Polar organization token. Without it billing endpoints return 503. Secret |
POLAR_WEBHOOK_SECRET | For paid plans | — | Verifies /api/webhooks/polar. Secret |
POLAR_SERVER | No | sandbox | sandbox or production |
POLAR_PRODUCT_ID_STARTER / _PRO / _BUSINESS | For paid plans | — | Allowlisted Polar Product IDs per plan |
POLAR_PRODUCT_ID_TEAM | No | — | Deprecated alias for Business, used only when POLAR_PRODUCT_ID_BUSINESS is unset |
Voice (preview)
Overview and owner settings: Voice (preview).
| Variable | Default | Purpose |
|---|---|---|
VOICE_PROVIDER | gpt-live | gpt-live for real Voice; mock runs without credentials (tests and CI only) |
VOICE_OPENAI_API_KEY | — | GPT-Live key, server-side only. Independent of the text chat provider. Without it Voice is unavailable and text chat is unaffected. Secret |
VOICE_OPENAI_BASE_URL | https://api.openai.com | GPT-Live API origin |
VOICE_QUOTA_EXEMPT_PLAYGROUND | true | Owner playground Voice is always measured and reported; when true it is not counted toward the account's Voice-minute quota. Independent of HOSTED_USAGE_EXEMPT_PLAYGROUND (text chat) |
VOICE_MAX_CONCURRENT_SESSIONS | — | Instance-wide cap on concurrent Voice sessions, to stay under the provider organization's concurrency limit. Unset: only per-plan limits apply |
VOICE_IDLE_TIMEOUT_SECONDS | 180 | Seconds without visitor speech before the assistant checks in; the call ends 30 s later if the visitor stays silent. 0 disables idle ending (the 60-minute cap still applies) |
VOICE_SHUTDOWN_GRACE_MS | 8000 | Graceful-drain budget on SIGTERM/SIGINT (1000–60000). Live calls are hung up, settled and their recordings finalized within it; then the process exits. Keep the platform stop grace at least 7 s longer |
NEXT_MANUAL_SIG_HANDLE | — (Docker image: 1) | Required for the Voice drain. Without it Next.js exits within ~50 ms of SIGTERM and live calls fall back to crash recovery |
Voice runtime state is process-local: run one web instance (or sticky sessions by Voice session id). ChatAI supervises every call server-side: if its provider control link drops it reconnects for up to 15 s before ending the call, stalled lookups get a neutral apology after 15 s, and calls end at 60 minutes (the assistant warns about 30 s before). Voice needs a long-lived Node.js server; see Voice in production.
Voice minutes
Voice usage is measured in seconds per session and follows HOSTED_USAGE_ENFORCEMENT:
off: seconds are recorded on each session; no quota and no provider-cost ledger.shadow: seconds and provider cost are recorded; nothing is refused (exceptVOICE_MAX_CONCURRENT_SESSIONS).enforce: each session reserves up to 5 minutes at a time from the account's Voice-minute allowance for the current period and extends while talking. A session that reaches the end of its allowance is told once, about 30 seconds before, and then closed. New sessions are refused with402when fewer than 30 seconds remain, and with429when the plan's concurrent-session limit is reached.
A session is billed from the provider's final usage when available, otherwise from ChatAI's last durable checkpoint (marked estimated). Sessions left open by a crashed or restarted process are recovered and settled by the maintenance sweep. Recording and Voice minutes are metered independently.
Voice audio recording (optional)
Recording needs S3-compatible object storage. Without a bucket and credentials, the Record Voice audio setting is disabled and Voice works normally. Recordings stay private: the bucket is never exposed to browsers, and owners play recordings back through an authenticated ChatAI proxy with short-lived links.
| Variable | Default | Purpose |
|---|---|---|
OBJECT_STORAGE_BUCKET | — | Bucket for recordings (keys: voice/<assistantId>/<recordingId>.webm) |
OBJECT_STORAGE_REGION | auto | Region (auto for R2; e.g. us-east-1 for S3/MinIO) |
OBJECT_STORAGE_ENDPOINT | — | Custom endpoint for R2 (https://<account-id>.r2.cloudflarestorage.com) or MinIO (http://minio:9000). Omit for AWS S3 |
OBJECT_STORAGE_ACCESS_KEY_ID | — | Access key with put/get/head/delete on the bucket |
OBJECT_STORAGE_SECRET_ACCESS_KEY | — | Secret key. Secret |
OBJECT_STORAGE_FORCE_PATH_STYLE | false | true for MinIO and most self-hosted S3 servers |
VOICE_RECORDING_SPOOL_DIR | ./.voice-recording-spool (Docker: /app/uploads/voice-spool) | Local compressed spool for recordings in progress. Put it on persistent disk so a partial recording can be recovered after a crash or restart |
VOICE_ALIGNMENT_DIAGNOSTICS | off | 1: log one numbers-only [voice.alignment] line per recorded call (clock offsets, transcript-vs-audio onset bias). Never logs audio or transcript content |
Each recorded Voice session produces one stereo WebM/Opus file (visitor on the left channel, assistant on the right), about 0.36 MB per minute. Only compressed audio touches the spool; raw audio is never written to disk.
Self-hosted MinIO example (add to your Compose file, then create the bucket once):
minio:
image: minio/minio
command: server /data
environment:
MINIO_ROOT_USER: chatai
MINIO_ROOT_PASSWORD: change-me-long-secret
volumes:
- minio-data:/dataOBJECT_STORAGE_BUCKET=chatai-recordings
OBJECT_STORAGE_REGION=us-east-1
OBJECT_STORAGE_ENDPOINT=http://minio:9000
OBJECT_STORAGE_ACCESS_KEY_ID=chatai
OBJECT_STORAGE_SECRET_ACCESS_KEY=change-me-long-secret
OBJECT_STORAGE_FORCE_PATH_STYLE=true
VOICE_RECORDING_SPOOL_DIR=/app/uploads/voice-spoolKeep the bucket private (no public-read policy). Recording deletion follows the assistant's privacy settings: recordings are deleted with their conversation, by the optional recording retention, and when the assistant is deleted.
Ollama Compose profile
See Local models (Ollama). Summary:
- Profile:
docker compose --profile local-models up - Prefer hybrid: Ollama for chat, 1536-d embeddings elsewhere
- Fully local
nomic-embed-text(768-d) is not compatible withvector(1536)yet
Playwright e2e (local / CI)
| Variable | Purpose |
|---|---|
E2E_EMAIL / E2E_PASSWORD | Seeded e2e user (pnpm e2e:seed) |
E2E_ASSISTANT_ID | Seeded assistant public id |
E2E_BASE_URL | App origin under test |
Do not commit .env.e2e.