Environment Variable Naming Convention
Status: Decision recorded for issue #168 Phase B. Companion doc: ENV_VARS.md — current inventory. Implementation: This doc is decision-only. Renames happen one domain at a time in Phase F, each behind a shim that warns on legacy names. No rename ships before Phase C (boot validation hardening) lands.
1. Goals
Section titled “1. Goals”- Predictable prefixes — reading a var name should hint at the subsystem that owns it.
- Public/secret legible by name —
VITE_PUBLIC_*is the only browser-bundled prefix; everything else is server-side. - One key per concept — collapse aliases (
AUTH_SECRETvsBETTER_AUTH_SECRET,DATABASE_URLvsDATABASE_HOST/USER/…). - Backwards-compatible rollout — shim helper
readEnv(newName, ...fallbacks)warns when a legacy name is hit. Old names stay readable for one release per domain.
Non-goals: renaming everything in one PR, removing all aliases the moment Phase F lands, or breaking the Coolify deploy in mid-cycle.
2. Prefix scheme
Section titled “2. Prefix scheme”| Prefix | Owner | Examples (target names) | Notes |
|---|---|---|---|
PLATFORM_* | Cross-cutting platform config | PLATFORM_API_URL, PLATFORM_DASHBOARD_URL, PLATFORM_APP_ENV, PLATFORM_EMAIL, PLATFORM_EMAIL_PASSWORD | Top-level config that doesn’t belong to a single subsystem. apps/cli already uses this prefix — extend to apps/api/dashboard. |
AUTH_* | better-auth + session management | AUTH_SECRET, AUTH_URL | Collapse BETTER_AUTH_* into this. Accept legacy BETTER_AUTH_* via shim. |
DB_* (or keep DATABASE_URL only) | Postgres connection | DATABASE_URL | Drop DATABASE_HOST/USER/PASSWORD/NAME/PORT from the schema. Standardize on DATABASE_URL. |
LLM_* | LLM providers and model overrides | LLM_PROVIDER, LLM_CLINE_API_KEY, LLM_ANTHROPIC_API_KEY, LLM_OPENROUTER_API_KEY, LLM_EXTRACT_URLS_MODEL, LLM_ANALYZE_CONTENT_MODEL | New LLM_PROVIDER enum (cline|anthropic|openrouter|auto, default auto) makes precedence explicit. |
NOTION_* | Notion integration | NOTION_INTEGRATION_TOKEN, NOTION_DATABASE_ID | Already consistent. Both unused at runtime now that credentials come from a connected notion:// channel — kept as schema-declared for now. |
GMAIL_* | Gmail OAuth / service account | GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN, GMAIL_SERVICE_ACCOUNT_PATH, GMAIL_USER_EMAIL (rename of MY_EMAIL_ADDRESS) | Already consistent except MY_EMAIL_ADDRESS outlier. |
RESEND_* | Resend email API | RESEND_API_KEY | One key today; prefix anyway for future overrides. |
TWITTER_* | Twitter / X integration | TWITTER_BEARER_TOKEN | Already prefixed. Keep. |
CHROME_* | CDP browser scraper | CHROME_CDP_ENDPOINT | Already prefixed. Keep. |
VITE_PUBLIC_* | Dashboard build-time, bundled into the browser | VITE_PUBLIC_API_URL | Convention: any value matching VITE_PUBLIC_* is treated as public by reviewers and CI. Today only VITE_API_URL exists — rename in Phase F #4. |
2.1 Prefixes we are not introducing
Section titled “2.1 Prefixes we are not introducing”— overlaps too much withAPI_*PLATFORM_*and the existing dashboardAPI_URLproxy var. UsePLATFORM_API_*instead.— same reason. UseDASHBOARD_*PLATFORM_DASHBOARD_*.— confused withSECRET_*/PUBLIC_*VITE_PUBLIC_*. The prefix-by-domain scheme + the public/secret matrix below handles classification.
3. Public / secret matrix (target names)
Section titled “3. Public / secret matrix (target names)”Class definitions: see ENV_VARS.md §1.
3.1 🔒 Secret — server-side only, never in /api/config, never in client bundle
Section titled “3.1 🔒 Secret — server-side only, never in /api/config, never in client bundle”DATABASE_URLAUTH_SECRETLLM_CLINE_API_KEYLLM_ANTHROPIC_API_KEYLLM_OPENROUTER_API_KEYNOTION_INTEGRATION_TOKENGMAIL_CLIENT_SECRETGMAIL_REFRESH_TOKENTWITTER_BEARER_TOKENRESEND_API_KEY3.2 🪪 Identifier — may appear in /api/config if a client needs it
Section titled “3.2 🪪 Identifier — may appear in /api/config if a client needs it”NOTION_DATABASE_IDGMAIL_CLIENT_IDGMAIL_USER_EMAIL (renamed from MY_EMAIL_ADDRESS)3.3 🌐 Public config — may appear in client bundle (must be VITE_PUBLIC_* to do so)
Section titled “3.3 🌐 Public config — may appear in client bundle (must be VITE_PUBLIC_* to do so)”PLATFORM_APP_ENVPLATFORM_API_URLPLATFORM_DASHBOARD_URLAUTH_URLLLM_PROVIDERLLM_EXTRACT_URLS_MODELLLM_ANALYZE_CONTENT_MODELCHROME_CDP_ENDPOINTGMAIL_SERVICE_ACCOUNT_PATHVITE_PUBLIC_API_URL4. Rename map
Section titled “4. Rename map”Each row is a separate PR in Phase F. Order minimizes blast radius. Every PR adds a shim that reads the new name first, falls back to the legacy name(s), and logs a [env] DEPRECATED: <old> → <new> warning on fallback.
| # | Domain | Legacy → Target | Notes |
|---|---|---|---|
| 1 | LLM | CLINE_API_KEY → LLM_CLINE_API_KEY | Add LLM_PROVIDER enum (cline|anthropic|openrouter|auto, default auto). |
| 1 | LLM | ANTHROPIC_API_KEY → LLM_ANTHROPIC_API_KEY | |
| 1 | LLM | OPEN_ROUTER_API_KEY → LLM_OPENROUTER_API_KEY | Note: drops the underscore between OPEN and ROUTER to match brand. |
| 2 | Auth | BETTER_AUTH_SECRET → AUTH_SECRET | AUTH_SECRET already exists in schema (unused) — Phase C resolves the dead-field state; Phase F #2 promotes it. |
| 2 | Auth | BETTER_AUTH_URL → AUTH_URL | |
| 3 | DB | DATABASE_HOST/USER/PASSWORD/NAME/PORT → dropped | Schema cleanup. Standardize on DATABASE_URL. |
| 4 | Dashboard | VITE_API_URL → VITE_PUBLIC_API_URL | Requires coordinated rebuild + redeploy — Vite injects at build time, so the rename re-bakes the bundle. Schedule on a deploy window. |
| 5 | Gmail | MY_EMAIL_ADDRESS → GMAIL_USER_EMAIL | Folds the outlier into the GMAIL_* family. |
| 6 | Platform | API_URL → PLATFORM_API_URL (dashboard + cli usages) | apps/cli already uses PLATFORM_API_URL; this aligns dashboard + api. |
| 6 | Platform | DASHBOARD_URL → PLATFORM_DASHBOARD_URL | |
| 6 | Platform | APP_ENV → PLATFORM_APP_ENV |
4.1 Shim contract
Section titled “4.1 Shim contract”The shim is the only sanctioned way to read env in apps/packages. Sketch (final shape lands in Phase C inside packages/platform-utils/env/):
function readEnv(target: string, ...legacy: string[]): string | undefined { const v = process.env[target]; if (v !== undefined && v !== '') return v; for (const name of legacy) { const fallback = process.env[name]; if (fallback !== undefined && fallback !== '') { console.warn(`[env] DEPRECATED: ${name} is renamed to ${target}; update your .env`); return fallback; } } return undefined;}Rules:
- Only the loader (
packages/platform-utils/env/) callsprocess.envdirectly. Phase I adds an ESLint rule enforcing this. - Each shim entry comes with a stated removal release in the PR description. Default: one release per domain before dropping the legacy name.
- Coolify env UI must be updated to the new name before the legacy fallback is removed.
5. Verification checklist (per Phase F PR)
Section titled “5. Verification checklist (per Phase F PR)”- Schema in
apps/api/core/env/parseEnvSchema.tsupdated. -
.env.exampleregenerated (Phase E gate prevents manual drift). - Shim added; legacy name still works with a single warning at boot.
- CI
vars/secretsreferencing the legacy name renamed (operator action — flag in PR body). - Coolify
.envupdated on staging, smoke-tested, then production (operator action — flag in PR body). - Boot log shows the new name in use after redeploy; no
DEPRECATEDwarning.
6. Open questions deferred to Phase G/H
Section titled “6. Open questions deferred to Phase G/H”- Keep the
BETTER_AUTH_*shim forever for better-auth ecosystem compatibility, or drop after one release? - Are there Coolify-only env vars (set in the UI, not in
docker-compose.coolify.yml) that don’t appear in this map? Someone with Coolify access should export and reconcile before Phase F #1 ships. - Should
${env:...}in user-authored presets (a future feature) be restricted to an allowlist? If so, this naming map can double as that allowlist.
