Environment variable reference
Every environment variable ErmisAI reads, what it does, whether it is required, and its default.
Every variable the ErmisAI application reads, grouped by concern, with its default and what happens when it is unset. Paths are relative to the app repository root (the directory is named hermesai; the package is ermisai).
Most of the failure modes here are silent. A missing key rarely crashes the build — it turns a capability off while pnpm build, pnpm typecheck and pnpm test all stay green. That is why every table below carries a "when unset" column.
.env.example (242 lines, committed) is the fullest reference in the repository. README.md is not exhaustive: it never mentions MIGRATIONS_DATABASE_URL, POSTGRES_POOL_MAX, ERMIS_QUEUE_SIGNING_SECRET, FLAGS_SECRET, EMAIL_UNSUBSCRIBE_SECRET, SENTRY_DSN, APP_URL, NEXT_PUBLIC_ERMIS_LEGAL_NAME or the ERMIS_FEED_* tuning block. Both files omit some variables the code reads — see Read by the code but not in .env.example.
The ten launch-critical keys
src/lib/platform/launch-readiness.ts:28-103 declares ten entries whose absence fails silently in a deployed environment. They are reported by GET /api/health under config, and printed once to stderr at boot by src/instrumentation.ts (Node runtime, deployed environments only), starting with:
[launch-readiness] WARNING: launch-critical environment variables are unset in this deployed
environment. These fail SILENTLY (build/typecheck/tests stay green while the capability is off):| Key | Category | What silently breaks when unset |
|---|---|---|
BLOB_ARCHIVE_READ_WRITE_TOKEN | ops | Webhook payload archives (PII) fall back to inline DB storage; those rows are never pruned, so PII grows unbounded. Satisfied alternatively by SUPABASE_STORAGE_ARCHIVE_BUCKET + SUPABASE_SERVICE_ROLE_KEY when OBJECT_STORAGE_PROVIDER !== 'vercel-blob'. |
CRON_SECRET | ops | Every cron 401s; AI overage never bills to Polar. |
APP_URL (or NEXT_PUBLIC_APP_URL) | ops | WordPress OAuth callbacks and email unsubscribe links resolve to the deployment origin instead of the product origin. |
SENTRY_DSN (or NEXT_PUBLIC_SENTRY_DSN) | ops | Server errors never reach Sentry. |
RESEND_WEBHOOK_SECRET | The Resend bounce/complaint webhook 503s; hard bounces are never suppressed. | |
EMAIL_POSTAL_ADDRESS | Marketing sends hard-fail. | |
EMAIL_UNSUBSCRIBE_SECRET | Marketing suppression throws in production. | |
NEXT_PUBLIC_ERMIS_LEGAL_NAME | legal | Terms and privacy render "ErmisAI is operated by ErmisAI" — no controller legal name. |
NEXT_PUBLIC_ERMIS_PRIVACY_EMAIL | legal | Privacy and GDPR pages render a non-clickable circular fallback with no working DSAR contact. |
NEXT_PUBLIC_ERMIS_CONTACT_EMAIL | legal | The contact page has no clickable address. |
config.ready is true only when missing is empty. It does not change the HTTP status — /api/health still returns 200 with config.ready: false (src/app/api/health/route.ts:144-149). It is a go-public gate you have to read, not an alarm.
Precedence rules that surprise people
These four rules cause more confusion than any individual variable.
- Persisted admin config beats env seeds.
ERMIS_AI_GUARDRAILS_MODE,ERMIS_AI_METERING_ENABLED,ERMIS_AI_PAUSED,ERMIS_SIGNUPS_PAUSED,ERMIS_WAITLIST_ENABLED,ERMIS_PLATFORM_AI_*_CAP_CENTS, the per-stage model variables,ERMIS_ALLOW_CHAT_MODEL_OVERRIDE,ERMIS_CHAT_STREAM_MODE,ERMIS_AI_REASONING_EFFORTandERMIS_AI_DEEPSEEK_FLASH_REASONINGseed the AI runtime config only on a fresh state store (src/lib/platform/ai-runtime-config.ts:515-588). Once an operator has writtenplatform:admin:ai-runtime-configthroughPUT /api/admin/ai/config, changing the env var and redeploying does nothing. ERMIS_AI_PROVIDER_MODEis the one exception — it beats the database.normalizeProviderModere-applies the env value on every normalize pass (src/lib/platform/ai-runtime-config.ts:132-141). Setting it in Vercel makes the provider-mode dropdown on/admin/aicosmetically settable but functionally inert.ERMIS_MODEL_OVERRIDEbeats everything, including per-stage models and per-request model selection. It is validated only for provider-prefix support, not against the model catalog (src/lib/ai/providers.ts:99-112).ERMIS_FORCE_INMEMORY_STOREis inert wheneverVERCEL_ENVis set — production and preview (src/lib/platform/shared/persistence.ts:16-22). It is a local-only switch.
App URLs and origins
| Variable | Default | Effect / when unset |
|---|---|---|
APP_URL | — | Origin for external redirects (WordPress OAuth callbacks, unsubscribe links). In NODE_ENV=production, when neither this nor NEXT_PUBLIC_APP_URL is set, resolveFallbackAppOrigin throws RouteError 503 with code missing_app_origin (src/lib/auth/clerk-session.ts:150-188). |
NEXT_PUBLIC_APP_URL | — | Client-visible twin of APP_URL; either satisfies the launch-readiness check. |
NEXT_PUBLIC_SITE_URL | — | Canonical marketing origin. resolveSiteUrl() falls back to VERCEL_PROJECT_PRODUCTION_URL, then VERCEL_URL (src/lib/marketing/metadata.ts:71-77). With none of the three, buildAbsoluteSiteUrl returns null and absolute canonical/OG URLs are omitted. |
Clerk
@clerk/nextjs reads NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY itself — they do not appear in a process.env read anywhere in src/, but the app cannot boot without them. ErmisAI ships no auth form of its own.
| Variable | Value used in this repo | Effect / when unset |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | — | Required. |
CLERK_SECRET_KEY | — | Required. |
CLERK_WEBHOOK_SIGNING_SECRET | — | Verifies POST /api/webhooks/clerk. Unset ⇒ the route returns 503; user, organization and membership sync silently stops. |
CLERK_SIGN_IN_URL / NEXT_PUBLIC_CLERK_SIGN_IN_URL | /sign-in | Set both. |
CLERK_SIGN_UP_URL / NEXT_PUBLIC_CLERK_SIGN_UP_URL | /sign-up | Set both. |
CLERK_SIGN_IN_FALLBACK_REDIRECT_URL / NEXT_PUBLIC_… | /app | Post-auth landing; /app then resolves to /app/onboarding or /app/feed. |
CLERK_SIGN_UP_FALLBACK_REDIRECT_URL / NEXT_PUBLIC_… | /app | Same. |
There is no NEXT_PUBLIC_CLERK_WAITLIST_URL — the waitlist URL is set in code (src/components/layout/ClerkAppProvider.tsx:22).
Postgres
| Variable | Default | Effect / when unset |
|---|---|---|
DATABASE_URL | — | Runtime connection string. Must be the pooled URL in production. |
SUPABASE_DATABASE_URL | — | Legacy alias, read before DATABASE_URL by the runtime client (src/lib/db/client.ts:65-69). Note the opposite priority in drizzle.config.ts. |
MIGRATIONS_DATABASE_URL | — | Direct/session URL used only by drizzle-kit. Resolution order there is MIGRATIONS_DATABASE_URL → SUPABASE_DATABASE_URL → DATABASE_URL; if none parses as a postgres:/postgresql: URL, drizzle.config.ts:38-42 throws. |
POSTGRES_POOL_MAX | 3 | postgres-js pool size per instance (src/lib/db/client.ts:18). |
POSTGRES_STATEMENT_TIMEOUT_MS | 10000 | Sent as a session startup parameter. Omitted entirely on a CapyDB pooled URL (*.db.capydb.dev on port 6432) because PgBouncer silently drops it (src/lib/db/client.ts:58-63, 144-148). |
A direct Postgres host in NODE_ENV=production throws at client construction rather than falling through to the next candidate: "<NAME> points at a direct Postgres host. Use the pooled/transaction connection string for the app runtime in production." (src/lib/db/client.ts:97-104). "Direct" means a Supabase db.<ref>.supabase.co host on a port other than 6543, or a *.db.capydb.dev host on a port other than 6432.
CapyDB port convention: :6432 pooled for the app runtime, :5432 direct for DDL only.
Production has no in-memory fallback for shared state. If platform_state is unreachable or unmigrated, every authenticated surface 503s and /api/health reports database.migrated: false with the detail "platform_state unreachable (run db:migrate before serving traffic)".
If a connection string fails to parse and contains REDIS_URL= or UPSTASH_REDIS_, the error adds "appears to contain another environment variable. Check for a missing newline in your env file." (src/lib/db/client.ts:81-89) — a missing newline in .env is the usual cause.
Object storage
Provider selection (src/lib/storage/object-storage.ts:101-117): explicit OBJECT_STORAGE_PROVIDER wins; otherwise Supabase if a Supabase URL and service-role key are both present; otherwise Vercel Blob if BLOB_READ_WRITE_TOKEN or BLOB_STORE_ID is present; otherwise null.
| Variable | Default | Effect / when unset |
|---|---|---|
OBJECT_STORAGE_PROVIDER | auto-detected | vercel-blob or supabase. |
BLOB_READ_WRITE_TOKEN | — | Public Blob store credential. |
BLOB_STORE_ID | — | Replaces the token after the dashboard "Upgrade to OIDC" flow; used with the platform-injected VERCEL_OIDC_TOKEN. |
BLOB_ARCHIVE_READ_WRITE_TOKEN | — | The separate private Blob store holding webhook payload archives. Unset ⇒ uploadPrivateArchiveObject returns null and the caller inlines the payload into its DB row. |
BLOB_PUBLIC_BASE_URL | — | Vercel Blob has no path→URL helper, so without this getPublicObjectUrl returns null (object-storage.ts:265-276). |
SUPABASE_URL / NEXT_PUBLIC_SUPABASE_URL | — | Legacy Supabase Storage. SUPABASE_URL is read first. |
SUPABASE_SERVICE_ROLE_KEY | — | Legacy Supabase Storage credential. |
SUPABASE_STORAGE_PUBLIC_BUCKET | — | Legacy public bucket name. |
SUPABASE_STORAGE_ARCHIVE_BUCKET | — | Legacy private archive bucket name. |
Vercel Blob store access is fixed at store creation and the public store rejects private puts, so the archive store must be created separately with vercel blob create-store <name> --access private. Deletions route by the provider and bucket recorded on each row, not by the currently configured provider, so the legacy SUPABASE_* storage variables must stay set until a migration run reports clean.
Redis (Upstash)
| Variable | Default | Effect / when unset |
|---|---|---|
UPSTASH_REDIS_REST_URL | — | Both are required together; src/lib/redis/client.ts:17-39 returns null otherwise and logs [redis] Upstash client disabled: …. |
UPSTASH_REDIS_REST_TOKEN | — | As above. |
REDIS_URL | — | Needed only for resumable chat streams. |
KV_URL | — | Alternative to REDIS_URL; either satisfies hasResumableRedisConfig() (src/lib/ai/resumable-stream.ts:18-23). |
Redis holds rate limits, AI guardrail windows, the AI ledger retry queue and DLQ, the feed build lock and short caches. It is never application state. Without it: edge rate limiting fails open, guardrail windows fall back to a per-process counter that is not cross-instance coherent, the in-flight spend reservation is skipped, and the ledger retry queue cannot enqueue at all.
Billing (Polar)
| Variable | Default | Effect / when unset |
|---|---|---|
POLAR_ACCESS_TOKEN | — | The only thing isPolarConfigured() checks. Unset ⇒ checkout and portal return 503 and the metering drain no-ops with reason polar_not_configured. |
POLAR_WEBHOOK_SECRET | — | Unset ⇒ /api/billing/webhooks/polar returns 503. |
POLAR_SERVER | production | Only the literal sandbox selects sandbox; anything else resolves to production (src/lib/services/polar.ts:162). |
POLAR_ORGANIZATION_ID | — | Optional; used only when creating customers. |
Product id mapping — first non-empty wins per plan (src/lib/db/billing-repository.ts:294-306). Duplicate ids across plans throw at seed time.
| Plan id | Environment variables, in order |
|---|---|
individual_free | POLAR_PRODUCT_ID_INDIVIDUAL_FREE, POLAR_PRODUCT_ID_FREE |
individual_plus | POLAR_PRODUCT_ID_INDIVIDUAL_PLUS, POLAR_PRODUCT_ID_PLUS |
individual_pro | POLAR_PRODUCT_ID_INDIVIDUAL_PRO, POLAR_PRODUCT_ID_PRO |
business_plus | POLAR_PRODUCT_ID_B_PLUS, POLAR_PRODUCT_ID_BUSINESS_PLUS |
business_pro | POLAR_PRODUCT_ID_B_PRO, POLAR_PRODUCT_ID_BUSINESS_PRO |
enterprise | POLAR_PRODUCT_ID_ENTERPRISE |
internal_unlimited | POLAR_PRODUCT_ID_INTERNAL (hidden internal plan; never shown on any tenant-facing surface or the public pricing page) |
A plan with no product id cannot be checked out — the route returns 409 POLAR product mapping missing for plan <id>.
Email (Resend)
| Variable | Default | Effect / when unset |
|---|---|---|
RESEND_API_KEY | — | src/lib/email/resend.ts:3-4 throws at module import: "RESEND_API_KEY environment variable is not set". |
RESEND_WEBHOOK_SECRET | — | Svix HMAC for /api/webhooks/resend. Unset ⇒ 503; bounces and complaints are never suppressed. |
EMAIL_FROM | ErmisAI <noreply@ermisai.com> | Transactional sender. |
EMAIL_MARKETING_FROM | falls back to EMAIL_FROM | Campaign sender. Should be an address on a dedicated marketing subdomain so cold-send reputation is isolated from auth mail. |
EMAIL_REPLY_TO | hey@ermisai.com | Reply-to header, and the mailto: half of List-Unsubscribe. |
EMAIL_UNSUBSCRIBE_SECRET | — | HMAC-SHA256 key for suppression tokens. Throws when NODE_ENV=production or VERCEL_ENV is set; outside those it falls back to the literal development-email-unsubscribe-secret (src/lib/email/suppression.ts:30-38). |
EMAIL_UNSUBSCRIBE_SECRET_PREVIOUS | — | Rotation slot. Lookups check both secrets. |
EMAIL_POSTAL_ADDRESS | — | Required for marketing sends; unset makes them hard-fail. |
Suppression records store an HMAC of the address, never plaintext, and are never pruned. Rotating EMAIL_UNSUBSCRIBE_SECRET without moving the old value into EMAIL_UNSUBSCRIBE_SECRET_PREVIOUS makes every existing opt-out unmatchable — it silently re-consents everyone who unsubscribed (src/lib/email/suppression.ts:57-64).
pnpm email:campaign runs node --env-file=.env.production, so it reads .env.production, not .env (package.json).
Security and operational secrets
| Variable | Default | Effect / when unset |
|---|---|---|
CRON_SECRET | — | isAuthorizedCronRequest() compares Authorization: Bearer ${CRON_SECRET} with timingSafeEqual and returns false when the variable is unset (src/lib/api/cron-auth.ts:46-65), so a missing secret can never open an endpoint — every cron 401s instead. |
ERMIS_QUEUE_SIGNING_SECRET | — | HMAC over canonical sorted-key JSON for Vercel Queue messages, because @vercel/queue performs no inbound verification. Unset ⇒ verifyRssMessageSignature() fails closed and publishRssMessage() throws (src/lib/platform/queues-adapter.ts:138-198). |
WEBHOOK_SECRET_ENCRYPTION_KEY | — | Optional AES-256-GCM at rest, 32 bytes as base64 or 64 hex chars (openssl rand -base64 32). Unset is a silent no-op — encryptSecret() returns plaintext. Covers tenant outbound-webhook HMAC secrets only; nothing else in the codebase uses it. Turning it on later needs no migration: decryptSecret() passes non-prefixed legacy values through. |
FLAGS_SECRET | — | Verifies request signatures on the Vercel Flags discovery endpoint src/app/.well-known/vercel/flags/route.ts. Unauthorized requests get 401 and flags do not auto-populate in the Vercel dashboard. |
CLERK_WEBHOOK_SIGNING_SECRET, POLAR_WEBHOOK_SECRET and RESEND_WEBHOOK_SECRET are listed with their own subsystems above. All three receivers return 503 when their secret is missing, not 401 — a missing secret is a configuration fault, not an authentication failure.
AI provider and credentials
| Variable | Default | Effect / when unset |
|---|---|---|
ERMIS_AI_PROVIDER_MODE | gateway | gateway or azure. Overrides the persisted admin config on every normalize pass. |
AI_GATEWAY_API_KEY | — | Gateway auth. hasGatewayLanguageModelConfiguration() is satisfied by this or the platform-injected VERCEL_OIDC_TOKEN (src/lib/ai/gateway.ts:7-15). |
AI_GATEWAY_BASE_URL | — | Gateway base URL passed to createGatewayProvider. |
AZURE_API_KEY | — | Required in azure mode. |
AZURE_RESOURCE_NAME | — | One of this or AZURE_BASE_URL is required in azure mode (src/lib/ai/azure.ts:75-84). |
AZURE_BASE_URL | — | For Foundry / AI Services / Cognitive Services hosts the code appends /openai/v1 itself, because @ai-sdk/azure only auto-injects /v1 for *.openai.azure.com — without it every request 404s "Resource not found" (azure.ts:36-73). |
AZURE_API_VERSION | SDK default | |
AZURE_BYOK_TIMEOUT_MS | 4000 | Gateway BYOK provider timeout. Values below 1000 are ignored and the default applies (src/lib/ai/language-model-policies.ts:256-264). |
AZURE_GPT_5_4_MINI_DEPLOYMENT, AZURE_CHAT_DEPLOYMENT | gpt-5.4-mini | Seed deployment mapping, first non-empty wins. |
AZURE_TEXT_EMBEDDING_3_SMALL_DEPLOYMENT, AZURE_EMBEDDING_DEPLOYMENT | text-embedding-3-small | Seed deployment mapping. Nothing in the product currently computes embeddings. |
AZURE_GPT_4_1_NANO_DEPLOYMENT | — | Gateway BYOK mapping for openai/gpt-4.1-nano. BYOK is populated only when AZURE_API_KEY and AZURE_RESOURCE_NAME are both present; AZURE_BASE_URL alone does not enable it. |
Azure deployments are otherwise managed at runtime through the admin Azure model registry, which needs no redeploy.
AI model selection
All of these are seed defaults for the persisted runtime config, except ERMIS_MODEL_OVERRIDE.
| Variable | Default | Effect |
|---|---|---|
ERMIS_MODEL_OVERRIDE | empty | Forces one provider/model id for all four stages. Beats per-stage models and per-request selection. Validated only for provider-prefix support, not against the catalog. |
ERMIS_CHAT_MODEL | mode default | Seeds the chat stage model. |
ERMIS_CLUSTER_MODEL | mode default | Seeds the cluster stage model. |
ERMIS_SYNTHESIS_MODEL | mode default | Seeds the synthesis stage model. |
ERMIS_COMPOSE_MODEL | the resolved chat model, not the mode default | Seeds the compose stage model (src/lib/platform/ai-runtime-config.ts:501-505). |
ERMIS_AI_EXPERIMENTAL_MODELS | off | Adds the DeepSeek and MiniMax entries to the catalog. |
ERMIS_ALLOW_CHAT_MODEL_OVERRIDE | false | Seeds chatAllowModelOverride, which controls whether the workspace shows an "Assistant settings" model picker. |
The mode default is openai/gpt-5-mini in gateway mode and azure/gpt-5.4-mini in azure mode. Out of the box all four stages use that same model — there is no distinct shipped model per stage.
.env.example:147 describes ERMIS_AI_EXPERIMENTAL_MODELS as a "comma-separated allowlist of experimental model ids". The code treats it as a boolean: only the literal true or 1 enables it (src/lib/ai/models.ts:123-129). The comment is wrong.
Separately, ERMIS_ALLOW_CHAT_MODEL_OVERRIDE=true does not durably enable the picker. The /admin/ai panel hard-writes chatAllowModelOverride: false (and enableProviderOptionsMatrix: true) on every save, with no UI control for either (src/components/features/admin/AiConfigPanel.tsx:367-368), so any admin save turns the picker off again.
AI policy and behaviour
| Variable | Default | Effect |
|---|---|---|
ERMIS_AI_PROFILE | balanced | fast, balanced or high-accuracy. Scales maxOutputTokens by ×0.78 / ×1 / ×1.22 and nudges temperature by −0.03 / 0 / +0.02. An empty string resolves to balanced. |
ERMIS_AI_REASONING_EFFORT | provider-default | Global reasoning effort: provider-default, minimal, low, medium, high, xhigh. Per-stage overrides live in the persisted config only. |
ERMIS_AI_DEEPSEEK_FLASH_REASONING | false | Enables DeepSeek V4 Flash thinking. Inert unless the experimental catalog is on. |
ERMIS_ENABLE_PROVIDER_OPTIONS_MATRIX | see note | Applies the per-provider reasoning/thinking options matrix. |
ERMIS_ENABLE_AI_COMPOSE | true | false forces the deterministic compose fallback. |
ERMIS_DISABLE_COMPOSE_CACHE | false | Disables the compose-stage wrapGenerate response cache. |
ERMIS_CHAT_STREAM_MODE | abort | Server-side stream mode: abort or resume. |
NEXT_PUBLIC_ERMIS_CHAT_STREAM_MODE | abort | Client-side stream mode. |
ERMIS_PROMPT_VERSION_STORY_COMPOSE | unset | Pins a story-compose prompt template version. |
ERMIS_PROMPT_VERSION_STORY_REFINEMENT | unset | Pins a story-refinement prompt template version. |
ERMIS_ENABLE_PROVIDER_OPTIONS_MATRIX has two conflicting defaults. The flag adapter default is false (src/lib/platform/feature-flags-adapter.ts:79-85); the runtime-config default is hardcoded true (src/lib/platform/ai-runtime-config.ts:530). The runtime config is what reaches the policy resolver in practice, so the matrix is on.
Chat stream mode is split-brained. The server decides from the persisted runtime config plus the presence of REDIS_URL/KV_URL; the client reads only NEXT_PUBLIC_ERMIS_CHAT_STREAM_MODE. The two sources are different variables and can disagree; the chat GET response carries resumableStreamingEnabled so the client can correct itself.
A pinned prompt version that does not exist silently falls back to the default — no error, no warning, only usedDefaultVersion in the message metadata (src/lib/ai/prompts/prompt-versioning.ts:54-55).
Guardrails, metering and spend
| Variable | Default | Effect |
|---|---|---|
ERMIS_AI_GUARDRAILS_MODE | enforce | enforce, observe or disabled. In observe the call still runs and only reports wouldBlock. |
ERMIS_AI_METERING_ENABLED | true | Queues billable tenant overage into the Polar metering outbox. |
ERMIS_AI_INFLIGHT_RESERVE_CENTS | 25 | USD cents reserved atomically in Redis before each billable call so concurrent calls cannot collectively overshoot the monthly limit. 0 disables the reservation. Non-finite or negative values fall back to 25 (src/lib/ai/usage-guardrails.ts:195-220). |
ERMIS_USD_TO_EUR_RATE | 1 | The single USD→EUR conversion point. Non-finite or ≤ 0 falls back to 1 (src/lib/ai/usage-fx.ts:14-28). |
ERMIS_PLATFORM_AI_DAILY_CAP_CENTS | null (no cap) | Platform-wide daily spend cap, in USD cents, matching the raw ledger. |
ERMIS_PLATFORM_AI_MONTHLY_CAP_CENTS | null (no cap) | Platform-wide monthly spend cap, in USD cents. |
The ledger accrues USD cents; plan envelopes and hard limits are EUR cents. Guardrail admission, overage metering and the tenant usage snapshot all convert through resolveUsdToEurRate(), so they agree with each other — but they are not currency-accurate until an operator sets ERMIS_USD_TO_EUR_RATE to a real rate.
Setting ERMIS_AI_GUARDRAILS_MODE=disabled inside a Vercel deployment bypasses monthly limits, overage gating, subscription-standing blocks and rate limits. It emits exactly one loud stderr warning at boot (src/lib/platform/ai-runtime-config.ts:290-316) — no alert, no ongoing signal.
Ingestion pipeline tuning
All optional, all read once at module init, all clamped to the bounds below. Values outside the range are silently clamped, not rejected (src/lib/services/rss-aggregation.ts:110-244).
| Variable | Default | Bounds |
|---|---|---|
ERMIS_FEED_CACHE_TTL_MS | 180 000 (3 min) | 30 000 – 1 800 000 |
ERMIS_FEED_CACHE_PERSIST_RETENTION_MS | 10 800 000 (3 h) | cache TTL – 86 400 000 |
ERMIS_EMPTY_FEED_CACHE_TTL_MS | 25 000 | 5 000 – cache TTL |
ERMIS_FEED_REQUEST_TIMEOUT_MS | 8 000 | 2 000 – 60 000 |
ERMIS_FEED_FETCH_CONCURRENCY | 12 | 1 – 48 |
ERMIS_FEED_MAX_ITEMS_PER_SOURCE | 30 | 5 – 100 |
ERMIS_ARTICLE_REQUEST_TIMEOUT_MS | 5 000 | 1 500 – 30 000 |
ERMIS_FEED_MAX_ARTICLE_ENRICHMENT_PER_REFRESH | 48 | 8 – 200 |
ERMIS_ARTICLE_ENRICHMENT_CONCURRENCY | 6 | 1 – 24 |
ERMIS_ARTICLE_BODY_CACHE_TTL_MS | 2 700 000 (45 min) | 60 000 – 43 200 000 |
ERMIS_FEED_MAX_CLUSTER_INPUT_ARTICLES | 48 | 8 – 200 |
ERMIS_FEED_MAX_CLUSTERS_PER_REFRESH | 12 | 2 – 60 |
ERMIS_FEED_SYNTHESIS_CONCURRENCY | 3 | 1 – 12 |
ERMIS_FEED_AUTO_REFRESH_MIN_INTERVAL_MS | 45 000 | 0 – 600 000 |
ERMIS_FEED_FORCE_REFRESH_MIN_INTERVAL_MS | 20 000 | 0 – 600 000 |
ERMIS_TENANT_INGESTION_TIME_BUDGET_MS | 240 000 | — (src/lib/platform/local-platform-data.ts:1186) |
Kill switches and operator brakes
| Variable | Default | Effect |
|---|---|---|
ERMIS_DISABLE_AI_PIPELINE | false | The literal true makes isAiPipelineConfigured() return false regardless of credentials (src/lib/services/rss-aggregation.ts:1991-1996). |
ERMIS_AI_PIPELINE_STRICT_MODE | false | The literal true removes the heuristic fallbacks for AI clustering, synthesis and the feed cache (src/lib/services/rss-aggregation.ts:630-650). |
ERMIS_DISABLE_PERSISTED_FEED_CACHE | false | Disables the platform_state-backed feed cache. Not documented in README.md. |
ERMIS_DISABLE_COMPOSE_CACHE | false | Disables the compose response cache. |
ERMIS_AI_PAUSED | false | Seeds the platform AI kill switch. |
ERMIS_SIGNUPS_PAUSED | false | Seeds the "New signups are paused" panel on /sign-up. |
ERMIS_WAITLIST_ENABLED | false | Seeds waitlist mode, which swaps the sign-up form for Clerk's waitlist join form. |
ERMIS_FORCE_INMEMORY_STORE | unset | The literal true routes shared state to memory, only when VERCEL_ENV is unset. |
aiPaused and the platform spend caps apply to every invocation, including non-billable pipeline runs, and ignore the guardrails mode entirely (src/lib/ai/usage-guardrails.ts:415-439). Platform-cap resolution fails open on any ledger read error.
None of signupsPaused, waitlistEnabled, aiPaused or the two spend caps has a control anywhere in the admin UI. Flipping them on a running deployment requires a hand-written PUT /api/admin/ai/config or a redeploy with the env seed on a fresh state store. Their current values are readable anonymously from GET /api/health under controls.
Waitlist mode is half app, half Clerk dashboard. ERMIS_WAITLIST_ENABLED changes the form; Clerk's own sign-up mode (Configure → Restrictions → Waitlist) is what enforces it server-side. Flip both or you get a form Clerk will reject, or ordinary sign-ups Clerk still accepts.
Localization
| Variable | Default | Effect / when unset |
|---|---|---|
ERMIS_CONTENT_LOCALE | en | Platform default synthesis output locale for the global scope and any consumer with no tenant contentLocale. An unsupported value falls back to en and writes [i18n] ERMIS_CONTENT_LOCALE="<value>" is not a supported content locale; falling back to "en" to stderr (src/lib/i18n/config.ts:37-59). Supported values: en, el, pl, it, es, pt, sv, da, nb, fi. |
ERMIS_CONTENT_LOCALE is read by the code and documented in README.md but is not present in .env.example.
Readiness attestations
Operator checkboxes expressed as environment variables, surfaced in GET /api/admin/ai/usage under operationalReadiness and rendered on /admin/ai. They attest to something; they verify nothing.
| Variable | Default |
|---|---|
ERMIS_{OPENAI,ANTHROPIC,GOOGLE,XAI,AZURE}_SPEND_ALERTS_CONFIRMED | unconfirmed |
ERMIS_{OPENAI,ANTHROPIC,GOOGLE,XAI,AZURE}_DPA_CONFIRMED | unconfirmed |
ERMIS_AI_PRIVACY_DISCLOSURE_CONFIRMED | unconfirmed |
ERMIS_AI_PRIVACY_DISCLOSURE_CONFIRMED_AT | unset (ISO timestamp for the audit trail) |
The names are generated as ERMIS_${PROVIDER_UPPERCASE}_${SPEND_ALERTS|DPA}_CONFIRMED (src/app/api/admin/ai/usage/route.ts:51-57).
Public marketing configuration
Every value here is NEXT_PUBLIC_, so it is inlined into the client bundle at build time. Changing one needs a redeploy.
| Variable | Default | Effect / when unset |
|---|---|---|
NEXT_PUBLIC_ERMIS_LEGAL_NAME | none | Controller legal name in terms and privacy. Unset makes the copy read "ErmisAI is operated by ErmisAI". |
NEXT_PUBLIC_ERMIS_LOCATION | Greece | Marketing location string (src/lib/marketing/site-config.ts:8). |
NEXT_PUBLIC_ERMIS_CURRENCY | EUR | ISO 4217 currency for the public pricing schema. |
NEXT_PUBLIC_ERMIS_X_URL | a hardcoded profile URL in site-config.ts | Footer link and Organization.sameAs structured data. |
NEXT_PUBLIC_ERMIS_LINKEDIN_URL | a hardcoded profile URL in site-config.ts | As above. |
NEXT_PUBLIC_ERMIS_CONTACT_EMAIL | none | Launch-critical. |
NEXT_PUBLIC_ERMIS_PRIVACY_EMAIL | none | Launch-critical. |
NEXT_PUBLIC_ERMIS_SECURITY_EMAIL | none | Security page contact. |
NEXT_PUBLIC_ERMIS_SALES_EMAIL | none | Contact page row. |
NEXT_PUBLIC_ERMIS_PARTNERSHIPS_EMAIL | none | Contact page row. |
NEXT_PUBLIC_ERMIS_CAREERS_EMAIL | none | Careers page row. |
getBestAvailableEmail() falls through the six addresses in the order general → sales → partnerships → privacy → security → careers, so one address set keeps most pages functional. When all six are unset, the affected rows degrade to muted non-actionable text with no fallback channel.
Sentry
| Variable | Default | Effect / when unset |
|---|---|---|
SENTRY_DSN | — | Server-side DSN. With no DSN, Sentry is skipped entirely. |
NEXT_PUBLIC_SENTRY_DSN | — | Client-side DSN; also derives the CSP connect-src ingest origin (src/proxy.ts:120-130). Client Sentry starts only after explicit cookie consent. |
SENTRY_ENVIRONMENT / NEXT_PUBLIC_SENTRY_ENVIRONMENT | VERCEL_ENV → NODE_ENV → development | Environment tag (src/instrumentation.ts:33-37). |
SENTRY_RELEASE / NEXT_PUBLIC_SENTRY_RELEASE | VERCEL_GIT_COMMIT_SHA | Release tag. |
SENTRY_ORG / SENTRY_PROJECT | entro314 / ermisai in next.config.ts | Used by the build plugin and pnpm sentry:sourcemaps. |
SENTRY_AUTH_TOKEN | — | Sourcemap upload. CI deliberately omits it so @sentry/nextjs skips the upload (.github/workflows/ci.yml). |
Platform-injected values
Set by Vercel, not by you. Listed because the code branches on them.
| Variable | Where it matters |
|---|---|
VERCEL_ENV | Presence (production or preview) disables every in-memory fallback path and makes ERMIS_FORCE_INMEMORY_STORE inert. scripts/deploy/prod-migrate.ts migrates only when the value is exactly production. |
NEXT_PUBLIC_VERCEL_ENV | Client-side environment tag for Sentry. |
VERCEL_URL / VERCEL_PROJECT_PRODUCTION_URL | Fallbacks for resolveSiteUrl(). |
VERCEL_DEPLOYMENT_ID | Seeds the deployment-stable CSP nonce on cacheable marketing routes: base64("ermis-marketing-" + VERCEL_DEPLOYMENT_ID), falling back to local (src/proxy.ts:90). |
VERCEL_REGION | OTel deployment.region attribute and queue message metadata; defaults to local. |
VERCEL_GIT_COMMIT_SHA | Sentry release fallback. |
VERCEL_OIDC_TOKEN | Satisfies gateway AI configuration in place of AI_GATEWAY_API_KEY, backs the BLOB_STORE_ID OIDC storage path, and counts as queue provisioning. |
VERCEL_QUEUE_API_TOKEN | Explicit queue credential. Without it and without OIDC, publishRssMessage dispatches to a registered in-process handler instead (src/lib/platform/queues-adapter.ts:112-124). |
NODE_ENV | Gates the production-only guards: direct-Postgres refusal, HTTPS-only webhook endpoints, loopback rejection in every SSRF check, the unsubscribe-secret throw, and telemetry.isEnabled. |
NEXT_RUNTIME | The launch-readiness stderr block is emitted only on nodejs. |
CI | Silences the Sentry build plugin when unset. |
pnpm build and pnpm vercel:build both hardcode NODE_ENV=production (package.json:11-12). A shell-exported NODE_ENV=development otherwise leaks into next build and breaks prerendering.
Read by the code but not in .env.example
These are read at runtime and have no line in the committed example file:
| Variable | Read at |
|---|---|
SUPABASE_DATABASE_URL | src/lib/db/client.ts:65-69, drizzle.config.ts — and it is read before DATABASE_URL at runtime |
BLOB_ARCHIVE_READ_WRITE_TOKEN | src/lib/storage/object-storage.ts — one of the ten launch-critical keys |
BLOB_STORE_ID | src/lib/storage/object-storage.ts:54-61 |
ERMIS_CONTENT_LOCALE | src/lib/i18n/config.ts:42 |
ERMIS_TENANT_INGESTION_TIME_BUDGET_MS | src/lib/platform/local-platform-data.ts:1186 |
README.md additionally omits ERMIS_AI_GUARDRAILS_MODE, ERMIS_AI_METERING_ENABLED, ERMIS_AI_PAUSED, ERMIS_AI_REASONING_EFFORT, ERMIS_AI_DEEPSEEK_FLASH_REASONING, ERMIS_AI_EXPERIMENTAL_MODELS, ERMIS_AI_INFLIGHT_RESERVE_CENTS, ERMIS_USD_TO_EUR_RATE, ERMIS_PLATFORM_AI_{DAILY,MONTHLY}_CAP_CENTS, ERMIS_CHAT_MODEL, ERMIS_CLUSTER_MODEL, ERMIS_SYNTHESIS_MODEL, ERMIS_ALLOW_CHAT_MODEL_OVERRIDE, ERMIS_CHAT_STREAM_MODE, ERMIS_DISABLE_COMPOSE_CACHE, AI_GATEWAY_BASE_URL, AZURE_BYOK_TIMEOUT_MS, AZURE_GPT_4_1_NANO_DEPLOYMENT, AZURE_CHAT_DEPLOYMENT and AZURE_EMBEDDING_DEPLOYMENT, all of which are in .env.example.
Declared but read nowhere
Do not spend time provisioning these:
NEXT_PUBLIC_SUPABASE_ANON_KEYandNEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY— present in.env.exampleand in the test neutralization list, but no read exists undersrc/orscripts/.DATABASE_DIRECT_URLandDATABASE_POOL_URL— these names appear in some local.envfiles but no application code reads them. The app readsDATABASE_URL,SUPABASE_DATABASE_URLandMIGRATIONS_DATABASE_URL.
Checking what a live deployment actually has
.env.production is gitignored and Vercel does not read it. The only authoritative answer for a running deployment is the health endpoint:
curl -s https://ermisai.com/api/health | jqIt returns, anonymously and with Cache-Control: no-store:
config.ready,config.missing[]andconfig.entries[]— the launch-critical key names and whether each is present. Values are never returned.controls—aiGuardrailsMode,aiPaused,signupsPaused,waitlistEnabled,platformDailySpendCapCents,platformMonthlySpendCapCents. These come from the persisted runtime config, so this is the only way to see whether an env seed was ever superseded.checks.databaseincludingmigrated, andchecks.redis(not_configuredwhen no Upstash credentials are present).
HTTP 200 when the database check passes, 503 otherwise. Redis degradation does not fail readiness, and neither does config.ready: false.
Exposing guardrail mode, pause states, spend caps in cents and the names of missing launch-critical variables on an anonymous endpoint is a deliberate, documented disclosure. Values are never included.
Local minimum
To boot pnpm dev you need Clerk plus one of two storage choices — that is all:
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYandCLERK_SECRET_KEY- either
DATABASE_URL, orERMIS_FORCE_INMEMORY_STORE=true
Polar, Resend, Upstash, Sentry and an AI provider are all optional for a first run. Note that src/lib/email/resend.ts throws at import when RESEND_API_KEY is missing, so any code path that touches email will fail until you set it.
Local development setup
Version pins, the CapyDB port convention, and running without Postgres.
Deploying to production
The migration gate, launch-critical configuration, and post-deploy smoke tests.
Feature flags and runtime controls
The two flag systems, and the operator brakes with no UI.
AI runtime: providers, stages and prompts
Provider modes, model resolution precedence, and prompt versioning.
Ingestion pipeline: fetch, cluster, synthesize
What every ERMIS_FEED_* knob actually tunes.
Security controls
Secret encryption, cron auth, queue signing, and the gaps.
