All configuration is via environment variables. Copy .env.example to .env; the
defaults match infra/docker-compose.dev.yml.
| Var | Default | Notes |
|---|
DATABASE_URL | postgresql://memoturn:memoturn@localhost:5433/memoturn?schema=public | Host port is 5433 in dev to avoid clashing with other local Postgres |
PRISMA_POOL_SIZE | 10 | Prisma (metadata) connection pool size per replica. |
PRISMA_CONNECT_TIMEOUT_MS | 10000 | How long to wait for a pooled Postgres connection before failing the query. |
| Var | Default | Notes |
|---|
TELEMETRY_ENGINE | doris | doris (the scale engine, default) or postgres — the small-install tier that keeps telemetry in the DATABASE_URL Postgres, schema telemetry, with no Doris containers at all. Needs a pgvector-enabled image (the shipped compose files use pgvector/pgvector:pg16). Sizing + how to switch engines: Deployment → Telemetry engine. |
TELEMETRY_DATABASE_URL | DATABASE_URL | Optional separate Postgres for the postgres engine’s telemetry tables. |
TELEMETRY_PG_SCHEMA | telemetry | Schema holding the postgres engine’s tables + migration ledger. |
Applies when TELEMETRY_ENGINE=doris (the default).
| Var | Default | Notes |
|---|
DORIS_HOST | localhost | Doris FE host |
DORIS_PORT | 9030 | FE MySQL-protocol port |
DORIS_HTTP_PORT | 8030 | FE HTTP port |
DORIS_USER | root | |
DORIS_PASSWORD | empty | Empty in dev; required in production (the prod compose files include a one-shot doris-setup service that sets the root password) |
DORIS_DB | memoturn | |
DORIS_FE_XMX | 4096m | FE JVM heap cap, applied by the prod compose file. Raise on hosts with memory to spare (see Deployment → Doris sizing). |
DORIS_BE_MEM_LIMIT | 6G dev / 40% prod | BE process memory cap (absolute like 8G, or a % of host memory). Raise if analytical queries fail with MEM_ALLOC_FAILED. |
TELEMETRY_STREAM_LOAD | false | Set true/1 to switch worker inserts to Doris Stream Load (HTTP) for higher throughput. In-network deploys just work (the FE 307-redirects to a BE). |
DORIS_STREAM_LOAD_HOST | DORIS_HOST | Override for a host-run worker that should load a BE directly instead of going through the FE redirect. |
DORIS_STREAM_LOAD_PORT | 8030 | FE HTTP port by default; point at a BE webserver (8040) to load it directly. |
DORIS_STREAM_LOAD_TIMEOUT_MS | 60000 | Per-call Stream Load timeout so a wedged BE can’t pin an ingest worker slot forever. |
DORIS_REPLICATION_NUM | 1 | Replicas per tablet for NEW tables/partitions (${REPLICATION_NUM} in DDL, the migrations ledger). Raise on multi-BE clusters; existing tables are changed with bun run telemetry:repartition -- --set-replication N. |
DORIS_POOL_SIZE | 10 | Doris FE connection pool size per replica. |
DORIS_CONNECT_TIMEOUT_MS | 10000 | Connect timeout to the FE. |
DORIS_QUERY_TIMEOUT_S | 60 | Server-side query_timeout set on every pooled session — a runaway scan is killed on the FE instead of pinning a pool slot. |
TELEMETRY_PG_POOL_SIZE | 10 | Postgres-tier telemetry pool size per replica (TELEMETRY_ENGINE=postgres). |
TELEMETRY_PG_CONNECT_TIMEOUT_MS | 10000 | Connect timeout for that pool. |
TELEMETRY_PG_STATEMENT_TIMEOUT_MS | 60000 | statement_timeout on every telemetry session. |
| Var | Default | Notes |
|---|
REDIS_URL | redis://localhost:6380 | Host port 6380 in dev |
| Var | Default | Notes |
|---|
WORKER_CONCURRENCY | 10 | Ingest worker concurrency |
INGEST_LOCK_DURATION_MS | 60000 | BullMQ lock for an ingest job; a job that outlives it is marked stalled (2 stalls → failed → DLQ). Raise if inserts routinely exceed a minute. |
LONG_JOB_LOCK_DURATION_MS | 600000 | BullMQ lock for experiment / evaluator-backfill jobs (minutes-long, LLM-bound). |
DLQ_ALERT_DEPTH | 1000 | When the dead-letter queue reaches this depth the worker logs an error-level line (“failing systemically”). 0 disables. |
TELEMETRY_MAX_RETENTION_DAYS | 0 (off) | Instance-wide retention ceiling: every project — with or without a policy — is swept at min(policy, ceiling) across the telemetry store, the blob event log, and the Postgres state mirror. |
WORKER_SHUTDOWN_TIMEOUT_MS | 570000 | Drain budget on SIGTERM: how long the worker waits for in-flight jobs (experiments/backfills run for minutes) before force-exiting. Keep it under the orchestrator’s grace period (Helm worker.terminationGracePeriodSeconds, compose stop_grace_period). |
WORKER_PORT | 3002 | Worker /health (liveness), /ready (readiness — pings every datastore), and /metrics (JSON, or Prometheus text with Accept: text/plain / ?format=prometheus) HTTP endpoint |
WORKER_HOST | 127.0.0.1 | Bind host for the worker health/metrics server. Loopback by default — /metrics is unauthenticated and leaks queue depths and per-project evaluator names. Set 0.0.0.0 only for cross-host probes on a trusted network. |
WORKER_METRICS_URL | http://127.0.0.1:3002/metrics | Where the API fetches worker metrics for the ingest-health panel. Set it when the API and worker run on different hosts/pods; fetch failures degrade gracefully (workerReachable: false). |
STATE_RETENTION_HOURS | 72 | Hours a mutable-entity *State row stays in Postgres after its last update before the hourly prune drops it (Doris keeps full history). |
EXPERIMENT_CONCURRENCY | 2 | Concurrent experiment jobs per worker process. Kept low on purpose — each job fans out over dataset items and must not starve ingest. |
EVAL_BACKFILL_CONCURRENCY | 1 | Concurrent evaluator-backfill jobs per worker process. Each one issues judge calls over a whole trace window, so it stays serial by default. |
EXPERIMENT_ITEM_CONCURRENCY | 4 | Dataset items executed in parallel within one experiment run. |
MAINTENANCE_CONCURRENCY | 4 | Maintenance-queue concurrency, so the per-minute alert tick isn’t blocked behind a long daily sweep (retention/export/embeddings). Each job type is lock-guarded. |
GUARDRAIL_EVALUATOR_TIMEOUT_MS | 3000 | Per-check timeout for the LLM-backed guardrails (evaluator guards + the built-in restricted-topic/toxicity model guards). They sit on the request path, so a slow judge must not hang the caller — each fails open on timeout. |
ALERT_ANOMALY_BUCKETS | 12 | Number of trailing time buckets used as the baseline for anomaly-type alert rules. |
ALERT_ANOMALY_MIN_BASELINE | 5 | Minimum baseline events before an anomaly rule can fire (suppresses noise on quiet projects). |
EMBEDDING_PROJECTION_DAYS | 30 | Lookback window for the daily embedding-projection reduction. |
EMBEDDING_PROJECTION_MAX_POINTS | 5000 | Cap on points per projection run. |
EMBEDDING_PROJECTION_CLUSTERS | 8 | k-means cluster count for the projection. |
EMBEDDING_PROJECTION_METHOD | umap | Reduction algorithm: umap (seeded, deterministic) or pca. Sets below 30 points always use PCA. |
SIMILAR_TRACES_SEED_CAP | 8 | Max seed vectors compared per “find similar traces” query (each adds a distance term to the Doris SQL). |
| Var | Default |
|---|
BLOB_ENDPOINT | http://localhost:9000 |
BLOB_REGION | us-east-1 |
BLOB_BUCKET | memoturn |
BLOB_ACCESS_KEY_ID | memoturn |
BLOB_SECRET_ACCESS_KEY | memoturn123 |
BLOB_FORCE_PATH_STYLE | true |
| Var | Default | Notes |
|---|
API_PORT | 3001 | Hono API |
CONSOLE_PORT | 3000 | Vite SPA |
MEMOTURN_API_URL | http://localhost:3001 | API target for the console dev proxy |
RATE_LIMIT_PER_MINUTE | 600 | Per-project global request rate limit (requests/minute). ON by default; 0 disables it (per-key limits still apply). Also applied to the remote MCP endpoint once its principal resolves. |
INGEST_EVENTS_PER_MINUTE | 60000 | Per-project ingest event budget (a POST can carry 1000 events). ON by default; 0 disables. |
API_DOCS_PUBLIC | true | Set false to put /docs + /openapi.json behind auth. |
API_KEY_DEFAULT_EXPIRY_DAYS | unset | Default lifetime for API keys minted without an explicit expiresInDays (unset = never expire). |
PLAYGROUND_MAX_TOKENS | 32768 | Ceiling on maxTokens for a single playground/assistant completion (these spend the project’s provider key) |
MCP_RATE_LIMIT_PER_MINUTE | 120 | Per-IP budget for the remote MCP endpoint (/v1/mcp/:projectId). Unlike the project limiter it defaults on — the route runs a credential lookup before auth resolves, so unauthenticated clients must not get unthrottled tries. 0 disables. |
INGEST_MAX_EVENT_BYTES | 1048576 | Per-event size cap on /v1/ingest (serialized). Over-limit events are rejected individually in the 207 body; large payloads belong in /v1/media. |
INGEST_MAX_JSON_DEPTH | 32 | Per-event nesting cap — protects the worker’s recursive masking/offload walks. |
API_REQUEST_TIMEOUT_MS | 60000 | Wall-clock budget per non-streaming /v1 request; past it the API answers 504 (JSON) instead of holding the connection. SSE and MCP routes are exempt. |
SSE_MAX_STREAMS_PER_PROJECT | 20 | Cap on concurrently open SSE streams (live tail, playground/assistant streaming) per project, shared across replicas via Redis; the 21st gets 429. 0 disables. |
LOG_LEVEL | info | Minimum level emitted by the API’s and worker’s structured logs (debug/info/warn/error). warn silences the per-request access line on a busy install. |
RATE_LIMIT_TRUSTED_PROXIES | 1 | Number of trusted reverse proxies in front of the API, used to derive the real client IP from the right of X-Forwarded-For (a spoofed XFF prefix can’t evade per-IP limits). The shipped Caddy deploy is one proxy; set 0 if the API is directly internet-exposed. |
API_METRICS_TOKEN | unset | Enables GET /metrics (request counts, status classes, per-route latency percentiles). Unset → 404; set → requires Authorization: Bearer <token>. JSON by default; Prometheus text exposition with Accept: text/plain (what Prometheus sends) or ?format=prometheus. GET /ready (public) is the readiness probe — 200 only when Postgres, Redis, the telemetry store, and the blob bucket all answer; GET /health / /v1/health stay pure liveness. |
Production startup guard: in production (NODE_ENV=production) the API and worker
refuse to start if BETTER_AUTH_SECRET, ENCRYPTION_KEY, or AUTH_TRUSTED_ORIGINS are
missing, shorter than 16 characters, or set to a known development placeholder. Generate
fresh values with openssl rand -base64 48.
| Var | Default | Notes |
|---|
BETTER_AUTH_SECRET | dev placeholder | Required in production — signs session cookies and tokens. Use openssl rand -base64 48. |
AUTH_BASE_URL | http://localhost:3001 | Better Auth base URL |
CONSOLE_PUBLIC_URL | first AUTH_TRUSTED_ORIGINS entry | Public origin of the console, used for links in emails (mentions, sign-in, invites). Defaults to the first AUTH_TRUSTED_ORIGINS entry, which is already the console origin — set this only when they differ. Deliberately not AUTH_BASE_URL: that is the API origin (:3001). A localhost value means “no public URL”, so notification emails ship without a link rather than a dead one. |
AUTH_TRUSTED_ORIGINS | http://localhost:3000 | Required in production — comma-separated console origins for CORS + auth. |
ENCRYPTION_KEY | dev placeholder | Required in production — AES-256-GCM key for provider API keys stored at rest. Independent of BETTER_AUTH_SECRET. Rotating this invalidates all stored provider keys (they must be re-entered in Settings → Providers). |
ENCRYPTION_KEYS | unset | Comma-separated key ring for rotation — first entry is ACTIVE (used to encrypt), the rest only decrypt. Procedure: ENCRYPTION_KEYS=new,old → restart → bun run rotate-secrets → ENCRYPTION_KEYS=new. Ciphertexts are v2.<keyId>.… (scrypt-derived AES-256-GCM); pre-v2 ciphertexts still decrypt and are rewritten by the rotation. |
MCP_LOGIN_PAGE | <first AUTH_TRUSTED_ORIGINS>/login | Console sign-in page the remote-MCP OAuth 2.1 flow (Better Auth @better-auth/oauth-provider plugin) redirects unauthenticated users to. Override only if the console login lives elsewhere. |
MCP_CONSENT_PAGE | <first AUTH_TRUSTED_ORIGINS>/consent | Console consent page where the OAuth flow asks the signed-in user to approve the client’s requested scopes. |
| Var | Default | Notes |
|---|
AUTH_DISABLE_PASSWORD_SIGNUP | unset | Set true to disable new email/password signups (existing password logins still work). Meant for hosted/IdP-only deployments; leave unset for self-host so the first admin can register without SMTP or an IdP. |
AUTH_REQUIRE_EMAIL_VERIFICATION | unset | Set true to require a verified email before sign-in (needs a working email transport). Default off so self-host accounts aren’t locked out. |
AUTH_MIN_PASSWORD_LENGTH | 12 | Minimum length for new passwords (existing shorter passwords still sign in). |
AUTH_HIBP_DISABLED | unset | The breached-password check (k-anonymity, api.pwnedpasswords.com) is on by default and fails closed — signup/password-change return 500 when the service is unreachable. Airgapped/offline installs must set true. |
AUTH_SIGNIN_MAX_PER_15M | 10 | Password sign-in attempts per client IP per 15 minutes (brute-force guard, on top of the generic 30/min auth window). 2FA code verification has fixed sub-limits (10 TOTP, 5 backup codes / 15 min). |
AUTH_OAUTH_REGISTER_MAX_PER_HOUR | 20 | Unauthenticated OAuth dynamic client registrations per IP per hour (remote MCP clients). |
AUTH_COOKIE_CACHE_MAX_AGE | 300 | Session cookie cache lifetime (seconds) — getSession is served from a short-lived signed cookie instead of a Postgres query. Revocations/bans take up to this long to bite on issued cookies. |
AUTH_COOKIE_CACHE_DISABLED | unset | Set true to disable the session cookie cache entirely (every getSession hits Postgres). |
AUTH_ORG_MEMBERSHIP_LIMIT | 10000 | Max members per organization. |
AUTH_ORG_INVITATION_LIMIT | 1000 | Max pending invitations per organization. |
AUTH_IP_HEADERS | unset | Comma-separated header(s) carrying the real client IP behind a proxy/CDN (first match wins), e.g. cf-connecting-ip or x-real-ip. Unset, the auth rate limiter trusts x-forwarded-for, which is spoofable when clients can reach the origin directly. |
SSO_ADMIN_GROUPS | empty | Comma-separated IdP groups/roles that map a federated user to org admin on auto-join (matched against the groups/roles/role claims). Empty → everyone joins as member. |
SUPERADMIN_USER_IDS | empty | Comma-separated user IDs that always pass platform-admin authorization (list/ban users, impersonate), independent of the user.role column. Keep empty on self-host unless you need it. |
PASSKEY_RP_ID | derived | WebAuthn relying-party ID (registrable domain, no scheme/port). Defaults derive from the first AUTH_TRUSTED_ORIGINS; set only when the console is served from a custom domain. |
PASSKEY_ORIGIN | derived | Full origin users register passkeys from. Must match the console origin. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | unset | Enable Google sign-in (button appears only when both are set). Callback: ${AUTH_BASE_URL}/auth/callback/google. |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | unset | Enable GitHub sign-in. Callback: ${AUTH_BASE_URL}/auth/callback/github. |
Powers the email alert channel and auth flows (password reset, org invitations, email
verification). The transport is auto-selected: an explicit EMAIL_TRANSPORT, else Resend when
RESEND_API_KEY is set, else SMTP when SMTP_CONNECTION_URL or SMTP_HOST is set, else
disabled. When disabled, auth emails are logged to stderr in development so flows stay testable
without a mail server.
| Var | Default | Notes |
|---|
EMAIL_TRANSPORT | auto | Force resend or smtp instead of auto-selection. |
EMAIL_FROM | memoturn <alerts@memoturn.local> | From address for all outbound mail. |
ALERT_EMAIL_FROM | unset | Legacy fallback for the From address — used only when EMAIL_FROM is unset. Prefer EMAIL_FROM. |
RESEND_API_KEY | unset | Resend HTTP API key (no SMTP server needed). |
SMTP_CONNECTION_URL | unset | Single connection URL (smtp:// / smtps:// / ses://<region>); wins over the discrete vars. |
SMTP_HOST / SMTP_PORT / SMTP_SECURE / SMTP_USER / SMTP_PASS | unset | Discrete SMTP settings (used when no connection URL is set). |
| Var | Default | Notes |
|---|
LLM_TIMEOUT_MS | 60000 | Wall-clock timeout for non-streaming provider calls (evaluator/experiment judges — a hung provider would otherwise wedge a shared worker slot). |
LLM_STREAM_TIMEOUT_MS | 300000 | Timeout for streaming calls (playground). |
| Var | Default | Notes |
|---|
ALLOW_PRIVATE_WEBHOOK_TARGETS | unset | Set to 1 to permit http:// and private/loopback webhook, automation, and analytics-sink URLs. Blocked by default in every environment to prevent SSRF (not just production). Useful for dev/LAN self-hosted targets. In production the API/worker refuse to start with it set unless ALLOW_PRIVATE_WEBHOOK_TARGETS_ACK=1 is also set. |
ALLOW_PRIVATE_WEBHOOK_TARGETS_ACK | unset | Production-only acknowledgement that ALLOW_PRIVATE_WEBHOOK_TARGETS=1 is intentional (see above). |
| Var | Default | Notes |
|---|
MEMOTURN_BASE_URL | http://localhost:3001 | API base used by SDKs |
MEMOTURN_PUBLIC_KEY | pk-mt-dev | Matches the dev key from bun run seed |
MEMOTURN_SECRET_KEY | sk-mt-dev | |
bun run seed creates the default organization, project, API key, and admin user. In
development the credentials are the well-known dev defaults; in production the script
refuses to run unless ALLOW_SEED=1 is set, at which point it generates random
credentials and prints them once.
| Var | Default | Notes |
|---|
ALLOW_SEED | unset | Set to 1 to allow bun run seed in NODE_ENV=production. Without it the script exits with an error (the dev credentials are public knowledge). |
SEED_ADMIN_EMAIL | admin@memoturn.dev | Override the seeded admin email. In production a random value is generated unless this is set. |
SEED_ADMIN_PASSWORD | memoturn-dev-123 | Override the seeded admin password. In production a random value is generated unless this is set. |
Off by default and inert on a normal install. When enabled, a visitor who signs in with an
email that has no organization gets a throwaway sandbox provisioned automatically —
their own organization + project, seeded with generated telemetry — which is hard-deleted
after DEMO_TTL_DAYS. Used to run the public demo; see the worker’s sandbox-prune cron.
| Var | Default | Notes |
|---|
DEMO_MODE | unset | true/1 enables per-visitor sandbox provisioning, the sandbox seeder, and the daily prune cron. Leave unset for every normal deployment. |
DEMO_TTL_DAYS | 7 | Sandbox lifetime. The prune cron hard-deletes telemetry, blob objects, the organization (Prisma cascade), and the visitor’s user row. |
DEMO_MAX_SANDBOXES | 500 | Cap on concurrently-active sandboxes; signups past it are refused. |
DEMO_SEED_DAYS | 3 | Days of backdated demo telemetry generated per sandbox. |
DEMO_SEED_TRACES_PER_DAY | 15 | Traces per day per sandbox — keep small; every sandbox pays this ingest cost. |
DEMO_MEMBER_ROLE | viewer | Role the visitor gets. viewer is read-only (every mutating route is gated), which is what stops a public sandbox from ingesting, spending on the playground, or minting API keys. |
DEMO_FINALIZE_DELAY_MS | 120000 | How long the finalize job waits after the seed batches are submitted, so async ingest can drain before the sign-in link is emailed. Raise it if visitors land on a half-empty dashboard. |
DEMO_START_RATE_LIMIT_PER_MINUTE | 10 | Per-IP cap on POST /v1/demo/start, the unauthenticated pre-provision route. Honors RATE_LIMIT_TRUSTED_PROXIES when resolving the client IP. |
SANDBOX_CONCURRENCY | 2 | Worker concurrency for the sandbox seed queue. |
VITE_GA_MEASUREMENT_ID | unset | GA4 measurement id baked into the demo’s console bundle at image build (compose build arg, not runtime env). Unset ships zero analytics code — leave it unset on self-host installs. Page views are sent with query strings stripped, so magic-link tokens never reach analytics, and Consent Mode v2 keeps analytics storage denied for EEA/UK/CH visitors. |
| Var | Default | Notes |
|---|
CONSOLE_URL | http://localhost:3000 | Console base URL for bun run screenshots (the docs screenshot generator). Dev tooling only — not read by any service. |
For the security-relevant subset of these variables organized as a go-live checklist, see the
hardening guide.