Skip to content

Environment Variables — Inventory & Classification

Status: Frozen audit. Source of truth for issue #168 Phase A. Last verified: 2026-05-22 against main (commits up to 656f8b5). Companion doc: NAMING.md — target naming convention (Phase B).

This document inventories every environment variable read at runtime, classifies each as public or secret, and notes the consumer. It does not propose renames — those belong to Phase F (per-domain shimmed renames).


ClassMeaningStorage rule
🔒 secretCredential, bearer, token, or password. Leaking it grants attacker access to a third-party service or our own users.Never in /api/config, never in client bundles, never in logs, never committed.
🪪 identifierNon-secret ID (database UUID, OAuth client ID, email address). Knowing it does not authorize anything on its own.Acceptable in /api/config if a client genuinely needs it. Default to not exposing.
🌐 public configHostnames, ports, URLs, feature flags. Safe in client bundles.VITE_* prefix required if the dashboard needs it at build time.
⚙️ infraSet by the runtime (Bun, OS, container orchestrator). Not authored by us.n/a

Defined as required in apps/api/core/env/parseEnvSchema.ts. Boot fails fast with a one-line error listing missing keys.

VariableClassConsumerNotes
DATABASE_URL🔒apps/api/lib/drizzle/db.ts:4The only DB var actually read at runtime.
DATABASE_HOST🔒(schema-required, unused)Dead requirement. Drop in Phase F #3.
DATABASE_USER🔒(schema-required, unused)Dead requirement. Drop in Phase F #3.
DATABASE_PASSWORD🔒(schema-required, unused)Dead requirement. Drop in Phase F #3.
DATABASE_NAME🔒(schema-required, unused)Dead requirement. Drop in Phase F #3.
DATABASE_PORT🔒(schema-required, unused)Dead requirement. Drop in Phase F #3.
BETTER_AUTH_SECRET🔒better-authSession signing key.
AUTH_SECRET🔒(schema-required, unused)Dead requirement; documented as “optional duplicate” in the schema but is in fact z.string() with no default. Collapse in Phase F #2.

⚠️ Hostile boot: the schema currently requires five DATABASE_* fields and AUTH_SECRET that no code reads. A fresh clone following .env.example (which only documents DATABASE_URL and BETTER_AUTH_SECRET) fails at boot. Phase C will tighten the schema; Phase E will regenerate .env.example from it so they cannot drift again.

VariableClassConsumerDefault
NODE_ENV⚙️parseEnvSchema.ts:14development
APP_ENV🌐apps/api/lib/drizzle/db.ts:6-8, schemadevelopment
API_PORT🌐apps/api/index.ts3000
API_URL🌐schemahttp://localhost:3000
CLIENT_URLS🌐CORS allowlisthttp://localhost:5000,http://localhost:4321
BETTER_AUTH_URL🌐better-authhttp://localhost:3000
DASHBOARD_URL🌐apps/api/userSettings/routes/settings.routes.tshttp://localhost:5000
DASHBOARD_PORT🌐schema5000
DATABASE_MIGRATING🌐drizzlefalse
DATABASE_SEEDING🌐drizzlefalse
CHROME_CDP_ENDPOINT🌐platform-ingestion CDP scraperhttp://localhost:9222

Empty string is the schema default; consuming subsystems disable themselves when unset and log a [subsystem] X not set — Y unavailable warning at boot. Behavior intentionally fail-soft.

VariableClassConsumerDisabled-when-empty behavior
CLINE_API_KEY🔒apps/api/index.ts:122, ClineAdapterFalls through to next LLM provider.
ANTHROPIC_API_KEY🔒apps/api/index.ts:123, AnthropicAdapterFalls through to next LLM provider.
OPEN_ROUTER_API_KEY🔒apps/api/index.ts:124, OpenRouterAdapterCurrently deadOpenRouterAdapter flagged broken in index.ts:120.
LLM_EXTRACT_URLS_MODEL🌐apps/api/index.ts:125Provider default used.
LLM_ANALYZE_CONTENT_MODEL🌐apps/api/index.ts:126Provider default used.
NOTION_INTEGRATION_TOKEN🔒declared in the env schema onlyUnused at runtime — Notion credentials now come from a connected notion:// channel (/channels/new), not this env var.
NOTION_DATABASE_ID🪪declared in the env schema onlyUnused at runtime — was the veille preset’s input default; a Notion database id is now supplied per-channel or typed directly into the dashboard’s “Build custom flow” dispatch step.
GMAIL_CLIENT_IDRetired (Wave C). Gmail/Drive/Calendar credentials moved onto the owner’s gmail:// connector row. Still declared in docker-compose.coolify.yml, but nothing reads it.
GMAIL_CLIENT_SECRETRetired (Wave C). See above.
GMAIL_REFRESH_TOKENRetired (Wave C). The token now lives on the gmail:// connector and is minted by the self-serve “Reconnect Gmail” flow (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GMAIL_OAUTH_REDIRECT_URI below), not by get-gmail-token.ts. It still needs both gmail.modify and calendar.readonly — the same connector backs read_gmail, tag, and the calendarToTasks preset’s calendar_to_tasks step.
GMAIL_SERVICE_ACCOUNT_PATHRetired (Wave C). Service-account auth is gone; the connector row is the only Gmail credential path.
GMAIL_USER_EMAIL🪪read-filter / CLI identity (/api/config)Some Gmail filters degrade. Not a credential — this is why it survived Wave C.
GOOGLE_CLIENT_ID🪪/api/connect/gmail/* — the self-serve Gmail OAuth appReconnect-Gmail flow dormant; a gmail:// connector can’t be minted through the UI. Public.
GOOGLE_CLIENT_SECRET🔒/api/connect/gmail/*Reconnect-Gmail flow dormant.
GMAIL_OAUTH_REDIRECT_URI🌐Gmail OAuth callbackDefaults to empty; must byte-for-byte match a redirect URI registered on the Google OAuth client, e.g. https://platform-api.viite.ai/api/connect/gmail/callback (prod) or http://localhost:3000/api/connect/gmail/callback (local).
MY_EMAIL_ADDRESS🪪legacy alias for GMAIL_USER_EMAIL (older CLI installs)Some flows degrade. Safe to drop once every CLI reads GMAIL_USER_EMAIL.
TWITTER_BEARER_TOKEN🔒Twitter integration (unwired today)Unused at runtime.
SOCIAL_TOKEN_ENCRYPTION_KEY🔒apps/api/social/buildSocialOAuth.ts (AES key, sha256-derived)Gates ALL social token storage. Unset → social connect/publish dormant for every platform. Any strong random string (openssl rand -base64 32).
X_CLIENT_ID🪪X OAuth (XOAuthClient)X connect unavailable. Public; compose default is the prod app id.
X_CLIENT_SECRET🔒X OAuthX connect unavailable.
X_OAUTH_REDIRECT_URI🌐X OAuth callbackDefaults to the prod callback; must byte-match a registered redirect URL.
LINKEDIN_CLIENT_ID🪪LinkedIn OAuth (LinkedInOAuthClient)LinkedIn connect unavailable. Public. LinkedIn publishing is LIVE (2026-06-27).
LINKEDIN_CLIENT_SECRET🔒LinkedIn OAuthLinkedIn connect unavailable.
LINKEDIN_OAUTH_REDIRECT_URI🌐LinkedIn OAuth callbackDefaults to https://platform-api.viite.ai/api/connect/linkedin/callback; must byte-match a registered redirect URL.
RESEND_API_KEY🔒apps/api/messaging/adapters/resend/ResendEmailSenderAdapter.ts:7,11Email sending disabled.
ACCOUNTING_ENABLED🌐Feature flag (resolveFeatures) → /api/accounting + dashboard /accountingAbsent ⇒ feature OFF. Gates the incoming-documents surface (supplier invoices/receipts read from the owner’s Drive drop folder). Declared in docker-compose.coolify.yml with an off default, because a Coolify value never reaches the container without an environment: entry. Note env:coolify:sync --apply will try to revert a prod true back to that default; pin it in apps/api/.env first.
STRIPE_SECRET_KEY🔒every Connect surface (offers, invoicing, connect)The ONE Stripe key. The platform is not a merchant of record — nothing is charged on its own account — so this key only ever acts on connected accounts. Unset → those gateways build as null and their routes 400 (“not configured”) rather than crash-looping the boot.
ENTITLEMENT_PAYWALL_ENABLED🌐buildEntitlements.ts/api/entitlements/gateMaster switch for the dashboard hard paywall. Default false — the gate ships dormant so a deploy can’t lock existing users out. The server only reports the paywall as enabled when this is true AND the two ENTITLEMENT_* vars below are set: a gate with no reachable way to buy past it is an outage, not a paywall.
ENTITLEMENT_SELLER_ACCOUNT_ID🌐entitlements/config.tsviite’s own Stripe connected account id (acct_…). A customer.subscription.* event from any other account grants nothing. Without this check, every org selling on the platform could mint Business Studio access for its buyers. Unset → no purchase can grant entitlement, and the paywall stays open.
ENTITLEMENT_OFFER_SLUGS🌐entitlements/config.tsComma-separated offer slugs whose purchase grants access (e.g. business-studio-pro). Even on viite’s own account, an unlisted offer grants nothing — selling a €20/mo hosting offer must not hand the buyer a dashboard. Also drives requiresAccount on the public offer view (these slugs can’t be bought anonymously) and the plan list on /api/entitlements/gate. Promoting a tier is a config edit, not a migration.
STRIPE_CONNECT_WEBHOOK_SECRET🔒connect.webhooks.routes.ts (constructEventAsync)Signing secret of the Connect-enabled endpoint that delivers account.updated — the only way Stripe reports that a connected account finished onboarding. Was STRIPE_TICKETING_CONNECT_WEBHOOK_SECRET, which named the vertical it happened to be mounted in; ticketing sells nothing now, but this event still gates whether an org can sell offers and issue invoices. Missing → Connect events are rejected; chargesEnabled still refreshes via the GET /api/connect/stripe/status live-sync.
SUBSCRIPTION_TRIAL_DAYS🌐better-auth/index.ts (stamp at signup) + buildSubscriptions.ts (gate fallback)Free-trial length granted to a brand-new org before the paywall applies. Default 14; 0 disables the trial. Stamped onto organization.trial_ends_at at signup; the gate also derives created_at + N days when the column is null (so accounts created before the trial column existed aren’t paywalled by deploy timing).
STRIPE_INVOICING_WEBHOOK_SECRET🔒invoicing.webhooks.routes.ts (constructEventAsync)Signing secret for /api/invoicing/webhooks/stripe, distinct from the offers/connect webhook secrets. Missing → invoice.paid/invoice.voided events never reconcile onto our Invoice ledger (finalize still works locally without it, just without live status sync). Reuses STRIPE_SECRET_KEY. See Invoicing & Customers.
STRIPE_OFFERS_WEBHOOK_SECRET🔒offers.webhooks.routes.ts (constructEventAsync)Signing secret for /api/offers/webhooks/stripe — the Connect-enabled endpoint recording anonymous one-off offer purchases (checkout.session.completed / checkout.session.async_payment_succeeded fire on the org’s connected account, so a plain platform endpoint never receives them; locally use stripe listen --forward-connect-to). Missing → paid one-off checkouts are never recorded as Customer + Invoice and no receipt email is sent. Reuses STRIPE_SECRET_KEY.
VIITE_AI_REBUILD_WEBHOOK_URL🔒buildOffers.tsCoolifySiteRebuildTriggerCoolify deploy endpoint for the viite.ai marketing site (…/api/v1/deploy?uuid=<app-uuid>). When an org edits its public offer catalog (an active offer created/updated/archived/deleted), the API POSTs this URL — debounced — to queue a viite.ai redeploy, so its pricing/funnel pages re-fetch offers via the SDK. Requires VIITE_AI_REBUILD_WEBHOOK_TOKEN too (the endpoint 401s without it). Either missing → the rebuild trigger is a silent no-op.
VIITE_AI_REBUILD_WEBHOOK_TOKEN🔒buildOffers.tsCoolifySiteRebuildTriggerBearer token for the deploy endpoint above — a Coolify API token, ideally deploy-scoped to limit blast radius (a full-access token in the API container widens exposure). Sent as Authorization: Bearer. Missing → the trigger no-ops (paired with the URL).

These are read directly via process.env (not validated):

VariableClassConsumer
PORT🌐apps/api/index.ts:1 (Bun listener; usually aligned with API_PORT)
NOTIFICATION_EMAIL🪪apps/api/quotes/resolveAdminEmails.ts (resolveNotificationEmail) — RECIPIENT of the per-submission quote notification (quotes.routes.ts). For the quote_requests preset email digest it’s the fallback recipient behind the creating user’s app:notifications.defaultEmail setting (${setting:notificationEmail}); with SMTP_FROM as the final fallback. Needs RESEND_API_KEY to actually send. (Renamed from ADMIN_EMAIL; authorization split out to ADMIN_EMAILS.)
ADMIN_EMAILS🪪apps/api/quotes/resolveAdminEmails.ts (resolveAdminEmails) — comma-separated AUTHORIZATION allowlist for admin-only routes: quotes bulk-delete + /api/jobs/queue-stats (via requireAdmin, case-insensitive). Falls back to [NOTIFICATION_EMAIL] when unset.
SMTP_FROM🪪final fallback in the NOTIFICATION_EMAIL recipient chain
UPLOAD_DIR🌐apps/api/quotes/adapters/LocalFileStorageAdapter.ts:2

Phase I task: route these through the validated loader. Today they silently default and there is no audit of who reads them.


3.1 Bundled into the client (Vite-injected, build-time)

Section titled “3.1 Bundled into the client (Vite-injected, build-time)”

🌐 Everything VITE_* is shipped to the browser. Must be non-secret.

VariableClassConsumer
VITE_API_URL🌐apps/dashboard/src/lib/auth-client.ts:10, apps/dashboard/platformClient.ts

3.2 Read at Vite dev/build time (not bundled)

Section titled “3.2 Read at Vite dev/build time (not bundled)”
VariableClassConsumerDefault
DASHBOARD_PORT🌐apps/dashboard/vite.config.ts:105000
API_URL🌐apps/dashboard/vite.config.ts:11 (proxy target)http://localhost:3000
API_PORT🌐apps/dashboard/vite.config.ts:113000

No dashboard env validator exists today. Phase C will add apps/dashboard/src/lib/env.ts asserting required VITE_* keys at module load.


VariableClassConsumer
PLATFORM_API_URL🌐apps/cli/lib/cli-context.ts (primary)
API_URL🌐apps/cli/lib/cli-context.ts (fallback)
API_PORT🌐apps/cli/lib/cli-context.ts (fallback)
APPDATA, XDG_DATA_HOME⚙️apps/cli/commands/worktree/lib/warp-launcher.ts (OS-provided)

Note: apps/cli already uses PLATFORM_* prefix — supports the convention in NAMING.md.


  • packages/platform-domain/ — no env reads (pure domain).
  • packages/platform-ingestion/ — no env reads at runtime; configured via createIngestion({...}).
  • packages/platform-sdk/ — no env reads in runtime code; tests/test-config.ts reads API_URL/API_PORT for integration tests only.
  • packages/platform-utils/ — no env reads today. Phase C will add the shared env/ module here.

Implemented in apps/api/jobs/usecases/commands/CreateJobUsecase.ts:94-121.

  • Pattern: whole-string match ^\$\{env:([A-Z_][A-Z0-9_]*)\}$ only. Mixed strings like "prefix-${env:X}" are not substituted.
  • Scope: any key resolvable by the EnvResolver callback. Today that resolver is backed by EnvConfigProvider.get() which can return any value in the parsed schema.
  • Used by: no currently-registered preset — the last consumer (veille, "databaseId": "${env:NOTION_DATABASE_ID}") was removed in favor of the dashboard’s custom-flow composer, where per-schedule values are typed directly rather than defaulted from env.
  • Risk: today’s presets are code-defined so the resolver scope is fine. If user-authored presets become a feature, restrict the resolver to an explicit allowlist (open question carried into Phase G).

7. LLM provider — one default agent everywhere

Section titled “7. LLM provider — one default agent everywhere”

buildEnrichStep (apps/api/llm/buildEnrichStep.ts) picks the LLM client by the explicit LLM_PROVIDER env. The default is cline (a multi-provider gateway) — the single agent shared by every AI feature: enrich, analyze, and the social-post composer (the composer reuses the enrich provider’s analyzeContent model rather than choosing its own). Pinning one provider keeps local dev and Coolify on the same model instead of silently diverging.

LLM_PROVIDERkey envmodel (extractUrls / analyzeContent / classifyItem)
cline (default)LLM_CLINE_API_KEYanthropic/claude-haiku-4.5 (all three)
anthropicLLM_ANTHROPIC_API_KEYclaude-haiku-4-5 (all three)
openrouterLLM_OPENROUTER_API_KEYz-ai/glm-5 / z-ai/glm-4.7 / z-ai/glm-5
autofirst provider whose key is set, precedence Cline → Anthropic → OpenRouter

LLM_{EXTRACT_URLS,ANALYZE_CONTENT,CLASSIFY_ITEM}_MODEL override the per-task model. With an explicit provider, a missing key is a fail-fast boot error (clearer than auto’s silent fallthrough).

Local ↔ Coolify parity: set the SAME LLM_PROVIDER + LLM_CLINE_API_KEY in apps/api/.env and in the Coolify env (docker-compose.coolify.yml passes them through). The legacy ANTHROPIC_API_KEY / CLINE_API_KEY / OPEN_ROUTER_API_KEY names still resolve via readEnv but are slated for removal — prefer the LLM_* names.

Logged at boot: [jobs] enrich step using <provider> (extractUrls=…, analyzeContent=…, classifyItem=…) — informational, no secret material.


Whitelist lives in apps/api/config/allowed.config.types.ts and is enforced at the route handler. Post-PR #171:

ALLOWED_CONFIG_KEYS = [
'NOTION_DATABASE_ID', // 🪪 identifier
'GMAIL_USER_EMAIL', // 🪪 user-specific
'MY_EMAIL_ADDRESS', // 🪪 legacy alias for the above
]

GMAIL_CLIENT_ID used to be on this list; it left with Wave C, when Gmail credentials moved onto the gmail:// connector row. The file’s header comment lists the removed keys and explains the policy. Never add 🔒-class keys here.


Secret / VarTypeWorkflowsBlast radius
HETZNER_SSH_PRIVATE_KEY🔒deploy-hetzner.ymlRoot SSH on API server. Highest blast radius — Phase H should narrow to a deploy user with sudo docker scope.
COOLIFY_WEBHOOK_URL🔒deploy-hetzner.ymlDeploy trigger.
COOLIFY_API_TOKEN🔒deploy-hetzner.ymlCoolify API. Scope unknown — Phase H should audit.
RAILWAY_TOKEN🔒deploy-railway.ymlRailway deploys. Likely legacy — open question for Phase H.
GITLEAKS_LICENSE🔒security.ymlGitleaks Pro license.
NPM_TOKEN🔒publish-packages.ymlnpm publish under our scope.
GITHUB_TOKEN⚙️every workflowAuto-provisioned, repo-scoped.
API_DOMAIN, API_SERVER_IP, DASHBOARD_DOMAIN🌐 (vars, not secrets)deploy-hetzner.ymlNon-secret config.

Notably absent: no DATABASE_URL_PRODUCTION/STAGING, no RAILWAY_API_*_SERVICE_ID, no ANTHROPIC_API_KEY in CI. Production app secrets live on the Hetzner host (via Coolify), not in CI.


docker-compose.coolify.yml references (all are Coolify-resolved placeholders, no plaintext):

  • DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, API_URL, CLIENT_URLS (plural — comma-separated trusted origins, read by CORS + auth), DASHBOARD_URL.
  • LLM (one default agent everywhere — see §7): LLM_PROVIDER, LLM_ANTHROPIC_API_KEY, LLM_{EXTRACT_URLS,ANALYZE_CONTENT,CLASSIFY_ITEM}_MODEL.
  • Presets: GOOGLE_CLIENT_* + GMAIL_OAUTH_REDIRECT_URI (the Gmail connect flow — the retired GMAIL_CLIENT_ID/_SECRET/_REFRESH_TOKEN/_SERVICE_ACCOUNT_PATH entries are still declared here but unread), NOTION_* (export), CHROME_CDP_ENDPOINT, RESEND_API_KEY, NOTIFICATION_EMAIL, ADMIN_EMAILS, SMTP_FROM.
  • Sync local → Coolify with bun run env:coolify:sync (dry-run; --apply to push). It pushes the shared/integration set only and never touches the environment-specific keys above.
  • Dashboard build arg: VITE_API_URL=${API_URL} (build-time injection).
  • Hardcoded: NODE_ENV=production, APP_ENV=production, PORT=3000.

infra/hetzner/terraform.tfvars holds the Hetzner API token, DB password, and app secrets in plaintext (gitignored). Phase G will decide a secrets backend (sops + age recommended); Phase H will document deploy + rollback.

The Postgres database is not in docker-compose.coolify.yml — there is no postgres/db service, and the compose network is coolify (external: true). The API (network_mode: host) connects via DATABASE_URL to Postgres on a separate Hetzner cloud server at the private IP 10.0.1.20 (the cpx22). Adminer (db.viite.ai, ADMINER_DEFAULT_SERVER=10.0.1.20) targets the same box. env:coolify:sync deliberately skips DATABASE_URL (environment-specific — Coolify owns it).

This is a single point of failure: a fault on that node takes the whole API down while the rest of the stack stays up. On 2026-06-29 Hetzner cloud node 104806 failed (08:08 UTC) and platform-api went running:unhealthy → Traefik 503 (“no available server”), while the Coolify host, Traefik, Adminer, viite.ai, and developer.viite.ai all stayed healthy. The API self-heals once 10.0.1.20 is reachable again.

The outage was amplified by apps/api/server/health.ts running an unbounded pool.query('SELECT 1'): an unreachable DB hangs the health endpoint → the Docker healthcheck times out → Traefik pulls the entire API, even DB-independent routes. The health probe now races a 2s timeout and returns 200 {db:'down', status:'degraded'} so a DB blip degrades gracefully instead of blackholing the API.

Resilience backlog (durability before availability):

  • Durability (do first): confirm off-node backups exist at all; automate pg_dump or pgBackRest/wal-g PITR → an off-node store (Hetzner Storage Box / B2 / S3) with a tested restore runbook; move PGDATA from local NVMe to a Hetzner Volume (survives a node fault — re-attach to a fresh VM).
  • Blast-radius: the health-probe timeout (done) + pool connectionTimeoutMillis/statement_timeout so app routes fail fast rather than hang.
  • Availability (later): managed Postgres (mind cross-internet latency vs the current private-network DB) or a primary + replica with pg_auto_failover.

Items below are tracked here so Phase E (regen .env.example) and Phase F (renames) can close them.

ItemImpactPhase
DATABASE_HOST/USER/PASSWORD/NAME/PORT required in schema but never readHostile boot for fresh clonesC (schema fix) + E (regen example)
AUTH_SECRET required but unused; only BETTER_AUTH_SECRET consumedHostile bootC + F #2
parseEnvSchema.ts does not validate APP_ENV as enumSilently accepts typos like prouctionC
No dashboard env validatorMisconfig surfaces only at request timeC
Direct process.env reads in db.ts, drizzle.config.ts, cli-context.ts, LocalFileStorageAdapter.tsBypasses validation; no central inventoryI
${env:...} resolver scope unrestrictedLatent risk if user presets shipG (decision)
OPEN_ROUTER_API_KEY adapter known-broken since #165Misleading availability signalFollow-up issue
Coolify-only env vars (set in UI, not in docker-compose.coolify.yml)Unknown — needs operator exportH

  • Source comment with full audit: issue #168 comment.
  • Re-verified against main on 2026-05-22 (commits up to 656f8b5).
  • Closed-loop fixes already on main: PR #169 (stdout leak), PR #171 (/api/config leak).