Przejdź do treści
ErmisAI

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):
KeyCategoryWhat silently breaks when unset
BLOB_ARCHIVE_READ_WRITE_TOKENopsWebhook 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_SECRETopsEvery cron 401s; AI overage never bills to Polar.
APP_URL (or NEXT_PUBLIC_APP_URL)opsWordPress OAuth callbacks and email unsubscribe links resolve to the deployment origin instead of the product origin.
SENTRY_DSN (or NEXT_PUBLIC_SENTRY_DSN)opsServer errors never reach Sentry.
RESEND_WEBHOOK_SECRETemailThe Resend bounce/complaint webhook 503s; hard bounces are never suppressed.
EMAIL_POSTAL_ADDRESSemailMarketing sends hard-fail.
EMAIL_UNSUBSCRIBE_SECRETemailMarketing suppression throws in production.
NEXT_PUBLIC_ERMIS_LEGAL_NAMElegalTerms and privacy render "ErmisAI is operated by ErmisAI" — no controller legal name.
NEXT_PUBLIC_ERMIS_PRIVACY_EMAILlegalPrivacy and GDPR pages render a non-clickable circular fallback with no working DSAR contact.
NEXT_PUBLIC_ERMIS_CONTACT_EMAILlegalThe 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.

  1. 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_EFFORT and ERMIS_AI_DEEPSEEK_FLASH_REASONING seed the AI runtime config only on a fresh state store (src/lib/platform/ai-runtime-config.ts:515-588). Once an operator has written platform:admin:ai-runtime-config through PUT /api/admin/ai/config, changing the env var and redeploying does nothing.
  2. ERMIS_AI_PROVIDER_MODE is the one exception — it beats the database. normalizeProviderMode re-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/ai cosmetically settable but functionally inert.
  3. ERMIS_MODEL_OVERRIDE beats 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).
  4. ERMIS_FORCE_INMEMORY_STORE is inert whenever VERCEL_ENV is set — production and preview (src/lib/platform/shared/persistence.ts:16-22). It is a local-only switch.

App URLs and origins

VariableDefaultEffect / when unset
APP_URLOrigin 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_URLClient-visible twin of APP_URL; either satisfies the launch-readiness check.
NEXT_PUBLIC_SITE_URLCanonical 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.

VariableValue used in this repoEffect / when unset
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYRequired.
CLERK_SECRET_KEYRequired.
CLERK_WEBHOOK_SIGNING_SECRETVerifies 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-inSet both.
CLERK_SIGN_UP_URL / NEXT_PUBLIC_CLERK_SIGN_UP_URL/sign-upSet both.
CLERK_SIGN_IN_FALLBACK_REDIRECT_URL / NEXT_PUBLIC_…/appPost-auth landing; /app then resolves to /app/onboarding or /app/feed.
CLERK_SIGN_UP_FALLBACK_REDIRECT_URL / NEXT_PUBLIC_…/appSame.

There is no NEXT_PUBLIC_CLERK_WAITLIST_URL — the waitlist URL is set in code (src/components/layout/ClerkAppProvider.tsx:22).

Postgres

VariableDefaultEffect / when unset
DATABASE_URLRuntime connection string. Must be the pooled URL in production.
SUPABASE_DATABASE_URLLegacy 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_URLDirect/session URL used only by drizzle-kit. Resolution order there is MIGRATIONS_DATABASE_URLSUPABASE_DATABASE_URLDATABASE_URL; if none parses as a postgres:/postgresql: URL, drizzle.config.ts:38-42 throws.
POSTGRES_POOL_MAX3postgres-js pool size per instance (src/lib/db/client.ts:18).
POSTGRES_STATEMENT_TIMEOUT_MS10000Sent 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.

VariableDefaultEffect / when unset
OBJECT_STORAGE_PROVIDERauto-detectedvercel-blob or supabase.
BLOB_READ_WRITE_TOKENPublic Blob store credential.
BLOB_STORE_IDReplaces the token after the dashboard "Upgrade to OIDC" flow; used with the platform-injected VERCEL_OIDC_TOKEN.
BLOB_ARCHIVE_READ_WRITE_TOKENThe 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_URLVercel Blob has no path→URL helper, so without this getPublicObjectUrl returns null (object-storage.ts:265-276).
SUPABASE_URL / NEXT_PUBLIC_SUPABASE_URLLegacy Supabase Storage. SUPABASE_URL is read first.
SUPABASE_SERVICE_ROLE_KEYLegacy Supabase Storage credential.
SUPABASE_STORAGE_PUBLIC_BUCKETLegacy public bucket name.
SUPABASE_STORAGE_ARCHIVE_BUCKETLegacy 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)

VariableDefaultEffect / when unset
UPSTASH_REDIS_REST_URLBoth are required together; src/lib/redis/client.ts:17-39 returns null otherwise and logs [redis] Upstash client disabled: ….
UPSTASH_REDIS_REST_TOKENAs above.
REDIS_URLNeeded only for resumable chat streams.
KV_URLAlternative 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)

VariableDefaultEffect / when unset
POLAR_ACCESS_TOKENThe only thing isPolarConfigured() checks. Unset ⇒ checkout and portal return 503 and the metering drain no-ops with reason polar_not_configured.
POLAR_WEBHOOK_SECRETUnset ⇒ /api/billing/webhooks/polar returns 503.
POLAR_SERVERproductionOnly the literal sandbox selects sandbox; anything else resolves to production (src/lib/services/polar.ts:162).
POLAR_ORGANIZATION_IDOptional; 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 idEnvironment variables, in order
individual_freePOLAR_PRODUCT_ID_INDIVIDUAL_FREE, POLAR_PRODUCT_ID_FREE
individual_plusPOLAR_PRODUCT_ID_INDIVIDUAL_PLUS, POLAR_PRODUCT_ID_PLUS
individual_proPOLAR_PRODUCT_ID_INDIVIDUAL_PRO, POLAR_PRODUCT_ID_PRO
business_plusPOLAR_PRODUCT_ID_B_PLUS, POLAR_PRODUCT_ID_BUSINESS_PLUS
business_proPOLAR_PRODUCT_ID_B_PRO, POLAR_PRODUCT_ID_BUSINESS_PRO
enterprisePOLAR_PRODUCT_ID_ENTERPRISE
internal_unlimitedPOLAR_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)

VariableDefaultEffect / when unset
RESEND_API_KEYsrc/lib/email/resend.ts:3-4 throws at module import: "RESEND_API_KEY environment variable is not set".
RESEND_WEBHOOK_SECRETSvix HMAC for /api/webhooks/resend. Unset ⇒ 503; bounces and complaints are never suppressed.
EMAIL_FROMErmisAI <noreply@ermisai.com>Transactional sender.
EMAIL_MARKETING_FROMfalls back to EMAIL_FROMCampaign sender. Should be an address on a dedicated marketing subdomain so cold-send reputation is isolated from auth mail.
EMAIL_REPLY_TOhey@ermisai.comReply-to header, and the mailto: half of List-Unsubscribe.
EMAIL_UNSUBSCRIBE_SECRETHMAC-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_PREVIOUSRotation slot. Lookups check both secrets.
EMAIL_POSTAL_ADDRESSRequired 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

VariableDefaultEffect / when unset
CRON_SECRETisAuthorizedCronRequest() 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_SECRETHMAC 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_KEYOptional AES-256-GCM at rest, 32 bytes as base64 or 64 hex chars (openssl rand -base64 32). Unset is a silent no-opencryptSecret() 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_SECRETVerifies 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

VariableDefaultEffect / when unset
ERMIS_AI_PROVIDER_MODEgatewaygateway or azure. Overrides the persisted admin config on every normalize pass.
AI_GATEWAY_API_KEYGateway auth. hasGatewayLanguageModelConfiguration() is satisfied by this or the platform-injected VERCEL_OIDC_TOKEN (src/lib/ai/gateway.ts:7-15).
AI_GATEWAY_BASE_URLGateway base URL passed to createGatewayProvider.
AZURE_API_KEYRequired in azure mode.
AZURE_RESOURCE_NAMEOne of this or AZURE_BASE_URL is required in azure mode (src/lib/ai/azure.ts:75-84).
AZURE_BASE_URLFor 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_VERSIONSDK default
AZURE_BYOK_TIMEOUT_MS4000Gateway 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_DEPLOYMENTgpt-5.4-miniSeed deployment mapping, first non-empty wins.
AZURE_TEXT_EMBEDDING_3_SMALL_DEPLOYMENT, AZURE_EMBEDDING_DEPLOYMENTtext-embedding-3-smallSeed deployment mapping. Nothing in the product currently computes embeddings.
AZURE_GPT_4_1_NANO_DEPLOYMENTGateway 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.

VariableDefaultEffect
ERMIS_MODEL_OVERRIDEemptyForces 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_MODELmode defaultSeeds the chat stage model.
ERMIS_CLUSTER_MODELmode defaultSeeds the cluster stage model.
ERMIS_SYNTHESIS_MODELmode defaultSeeds the synthesis stage model.
ERMIS_COMPOSE_MODELthe resolved chat model, not the mode defaultSeeds the compose stage model (src/lib/platform/ai-runtime-config.ts:501-505).
ERMIS_AI_EXPERIMENTAL_MODELSoffAdds the DeepSeek and MiniMax entries to the catalog.
ERMIS_ALLOW_CHAT_MODEL_OVERRIDEfalseSeeds 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

VariableDefaultEffect
ERMIS_AI_PROFILEbalancedfast, 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_EFFORTprovider-defaultGlobal reasoning effort: provider-default, minimal, low, medium, high, xhigh. Per-stage overrides live in the persisted config only.
ERMIS_AI_DEEPSEEK_FLASH_REASONINGfalseEnables DeepSeek V4 Flash thinking. Inert unless the experimental catalog is on.
ERMIS_ENABLE_PROVIDER_OPTIONS_MATRIXsee noteApplies the per-provider reasoning/thinking options matrix.
ERMIS_ENABLE_AI_COMPOSEtruefalse forces the deterministic compose fallback.
ERMIS_DISABLE_COMPOSE_CACHEfalseDisables the compose-stage wrapGenerate response cache.
ERMIS_CHAT_STREAM_MODEabortServer-side stream mode: abort or resume.
NEXT_PUBLIC_ERMIS_CHAT_STREAM_MODEabortClient-side stream mode.
ERMIS_PROMPT_VERSION_STORY_COMPOSEunsetPins a story-compose prompt template version.
ERMIS_PROMPT_VERSION_STORY_REFINEMENTunsetPins 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

VariableDefaultEffect
ERMIS_AI_GUARDRAILS_MODEenforceenforce, observe or disabled. In observe the call still runs and only reports wouldBlock.
ERMIS_AI_METERING_ENABLEDtrueQueues billable tenant overage into the Polar metering outbox.
ERMIS_AI_INFLIGHT_RESERVE_CENTS25USD 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_RATE1The 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_CENTSnull (no cap)Platform-wide daily spend cap, in USD cents, matching the raw ledger.
ERMIS_PLATFORM_AI_MONTHLY_CAP_CENTSnull (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).

VariableDefaultBounds
ERMIS_FEED_CACHE_TTL_MS180 000 (3 min)30 000 – 1 800 000
ERMIS_FEED_CACHE_PERSIST_RETENTION_MS10 800 000 (3 h)cache TTL – 86 400 000
ERMIS_EMPTY_FEED_CACHE_TTL_MS25 0005 000 – cache TTL
ERMIS_FEED_REQUEST_TIMEOUT_MS8 0002 000 – 60 000
ERMIS_FEED_FETCH_CONCURRENCY121 – 48
ERMIS_FEED_MAX_ITEMS_PER_SOURCE305 – 100
ERMIS_ARTICLE_REQUEST_TIMEOUT_MS5 0001 500 – 30 000
ERMIS_FEED_MAX_ARTICLE_ENRICHMENT_PER_REFRESH488 – 200
ERMIS_ARTICLE_ENRICHMENT_CONCURRENCY61 – 24
ERMIS_ARTICLE_BODY_CACHE_TTL_MS2 700 000 (45 min)60 000 – 43 200 000
ERMIS_FEED_MAX_CLUSTER_INPUT_ARTICLES488 – 200
ERMIS_FEED_MAX_CLUSTERS_PER_REFRESH122 – 60
ERMIS_FEED_SYNTHESIS_CONCURRENCY31 – 12
ERMIS_FEED_AUTO_REFRESH_MIN_INTERVAL_MS45 0000 – 600 000
ERMIS_FEED_FORCE_REFRESH_MIN_INTERVAL_MS20 0000 – 600 000
ERMIS_TENANT_INGESTION_TIME_BUDGET_MS240 000— (src/lib/platform/local-platform-data.ts:1186)

Kill switches and operator brakes

VariableDefaultEffect
ERMIS_DISABLE_AI_PIPELINEfalseThe literal true makes isAiPipelineConfigured() return false regardless of credentials (src/lib/services/rss-aggregation.ts:1991-1996).
ERMIS_AI_PIPELINE_STRICT_MODEfalseThe 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_CACHEfalseDisables the platform_state-backed feed cache. Not documented in README.md.
ERMIS_DISABLE_COMPOSE_CACHEfalseDisables the compose response cache.
ERMIS_AI_PAUSEDfalseSeeds the platform AI kill switch.
ERMIS_SIGNUPS_PAUSEDfalseSeeds the "New signups are paused" panel on /sign-up.
ERMIS_WAITLIST_ENABLEDfalseSeeds waitlist mode, which swaps the sign-up form for Clerk's waitlist join form.
ERMIS_FORCE_INMEMORY_STOREunsetThe 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

VariableDefaultEffect / when unset
ERMIS_CONTENT_LOCALEenPlatform 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.

VariableDefault
ERMIS_{OPENAI,ANTHROPIC,GOOGLE,XAI,AZURE}_SPEND_ALERTS_CONFIRMEDunconfirmed
ERMIS_{OPENAI,ANTHROPIC,GOOGLE,XAI,AZURE}_DPA_CONFIRMEDunconfirmed
ERMIS_AI_PRIVACY_DISCLOSURE_CONFIRMEDunconfirmed
ERMIS_AI_PRIVACY_DISCLOSURE_CONFIRMED_ATunset (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.

VariableDefaultEffect / when unset
NEXT_PUBLIC_ERMIS_LEGAL_NAMEnoneController legal name in terms and privacy. Unset makes the copy read "ErmisAI is operated by ErmisAI".
NEXT_PUBLIC_ERMIS_LOCATIONGreeceMarketing location string (src/lib/marketing/site-config.ts:8).
NEXT_PUBLIC_ERMIS_CURRENCYEURISO 4217 currency for the public pricing schema.
NEXT_PUBLIC_ERMIS_X_URLa hardcoded profile URL in site-config.tsFooter link and Organization.sameAs structured data.
NEXT_PUBLIC_ERMIS_LINKEDIN_URLa hardcoded profile URL in site-config.tsAs above.
NEXT_PUBLIC_ERMIS_CONTACT_EMAILnoneLaunch-critical.
NEXT_PUBLIC_ERMIS_PRIVACY_EMAILnoneLaunch-critical.
NEXT_PUBLIC_ERMIS_SECURITY_EMAILnoneSecurity page contact.
NEXT_PUBLIC_ERMIS_SALES_EMAILnoneContact page row.
NEXT_PUBLIC_ERMIS_PARTNERSHIPS_EMAILnoneContact page row.
NEXT_PUBLIC_ERMIS_CAREERS_EMAILnoneCareers 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

VariableDefaultEffect / when unset
SENTRY_DSNServer-side DSN. With no DSN, Sentry is skipped entirely.
NEXT_PUBLIC_SENTRY_DSNClient-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_ENVIRONMENTVERCEL_ENVNODE_ENVdevelopmentEnvironment tag (src/instrumentation.ts:33-37).
SENTRY_RELEASE / NEXT_PUBLIC_SENTRY_RELEASEVERCEL_GIT_COMMIT_SHARelease tag.
SENTRY_ORG / SENTRY_PROJECTentro314 / ermisai in next.config.tsUsed by the build plugin and pnpm sentry:sourcemaps.
SENTRY_AUTH_TOKENSourcemap 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.

VariableWhere it matters
VERCEL_ENVPresence (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_ENVClient-side environment tag for Sentry.
VERCEL_URL / VERCEL_PROJECT_PRODUCTION_URLFallbacks for resolveSiteUrl().
VERCEL_DEPLOYMENT_IDSeeds the deployment-stable CSP nonce on cacheable marketing routes: base64("ermis-marketing-" + VERCEL_DEPLOYMENT_ID), falling back to local (src/proxy.ts:90).
VERCEL_REGIONOTel deployment.region attribute and queue message metadata; defaults to local.
VERCEL_GIT_COMMIT_SHASentry release fallback.
VERCEL_OIDC_TOKENSatisfies 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_TOKENExplicit 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_ENVGates 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_RUNTIMEThe launch-readiness stderr block is emitted only on nodejs.
CISilences 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:

VariableRead at
SUPABASE_DATABASE_URLsrc/lib/db/client.ts:65-69, drizzle.config.ts — and it is read before DATABASE_URL at runtime
BLOB_ARCHIVE_READ_WRITE_TOKENsrc/lib/storage/object-storage.ts — one of the ten launch-critical keys
BLOB_STORE_IDsrc/lib/storage/object-storage.ts:54-61
ERMIS_CONTENT_LOCALEsrc/lib/i18n/config.ts:42
ERMIS_TENANT_INGESTION_TIME_BUDGET_MSsrc/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_KEY and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY — present in .env.example and in the test neutralization list, but no read exists under src/ or scripts/.
  • DATABASE_DIRECT_URL and DATABASE_POOL_URL — these names appear in some local .env files but no application code reads them. The app reads DATABASE_URL, SUPABASE_DATABASE_URL and MIGRATIONS_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 | jq

It returns, anonymously and with Cache-Control: no-store:

  • config.ready, config.missing[] and config.entries[] — the launch-critical key names and whether each is present. Values are never returned.
  • controlsaiGuardrailsMode, 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.database including migrated, and checks.redis (not_configured when 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_KEY and CLERK_SECRET_KEY
  • either DATABASE_URL, or ERMIS_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.

Na tej stronie