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).
1. Classification legend
Section titled “1. Classification legend”| Class | Meaning | Storage rule |
|---|---|---|
| 🔒 secret | Credential, 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. |
| 🪪 identifier | Non-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 config | Hostnames, ports, URLs, feature flags. Safe in client bundles. | VITE_* prefix required if the dashboard needs it at build time. |
| ⚙️ infra | Set by the runtime (Bun, OS, container orchestrator). Not authored by us. | n/a |
2. Server-side inventory (apps/api)
Section titled “2. Server-side inventory (apps/api)”2.1 Required at boot
Section titled “2.1 Required at boot”Defined as required in apps/api/core/env/parseEnvSchema.ts. Boot fails fast with a one-line error listing missing keys.
| Variable | Class | Consumer | Notes |
|---|---|---|---|
DATABASE_URL | 🔒 | apps/api/lib/drizzle/db.ts:4 | The 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-auth | Session 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 andAUTH_SECRETthat no code reads. A fresh clone following.env.example(which only documentsDATABASE_URLandBETTER_AUTH_SECRET) fails at boot. Phase C will tighten the schema; Phase E will regenerate.env.examplefrom it so they cannot drift again.
2.2 Optional with defaults
Section titled “2.2 Optional with defaults”| Variable | Class | Consumer | Default |
|---|---|---|---|
NODE_ENV | ⚙️ | parseEnvSchema.ts:14 | development |
APP_ENV | 🌐 | apps/api/lib/drizzle/db.ts:6-8, schema | development |
API_PORT | 🌐 | apps/api/index.ts | 3000 |
API_URL | 🌐 | schema | http://localhost:3000 |
CLIENT_URLS | 🌐 | CORS allowlist | http://localhost:5000,http://localhost:4321 |
BETTER_AUTH_URL | 🌐 | better-auth | http://localhost:3000 |
DASHBOARD_URL | 🌐 | apps/api/userSettings/routes/settings.routes.ts | http://localhost:5000 |
DASHBOARD_PORT | 🌐 | schema | 5000 |
DATABASE_MIGRATING | 🌐 | drizzle | false |
DATABASE_SEEDING | 🌐 | drizzle | false |
CHROME_CDP_ENDPOINT | 🌐 | platform-ingestion CDP scraper | http://localhost:9222 |
2.3 Optional secrets / integrations
Section titled “2.3 Optional secrets / integrations”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.
| Variable | Class | Consumer | Disabled-when-empty behavior |
|---|---|---|---|
CLINE_API_KEY | 🔒 | apps/api/index.ts:122, ClineAdapter | Falls through to next LLM provider. |
ANTHROPIC_API_KEY | 🔒 | apps/api/index.ts:123, AnthropicAdapter | Falls through to next LLM provider. |
OPEN_ROUTER_API_KEY | 🔒 | apps/api/index.ts:124, OpenRouterAdapter | Currently dead — OpenRouterAdapter flagged broken in index.ts:120. |
LLM_EXTRACT_URLS_MODEL | 🌐 | apps/api/index.ts:125 | Provider default used. |
LLM_ANALYZE_CONTENT_MODEL | 🌐 | apps/api/index.ts:126 | Provider default used. |
NOTION_INTEGRATION_TOKEN | 🔒 | declared in the env schema only | Unused 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 only | Unused 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_ID | — | — | Retired (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_SECRET | — | — | Retired (Wave C). See above. |
GMAIL_REFRESH_TOKEN | — | — | Retired (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_PATH | — | — | Retired (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 app | Reconnect-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 callback | Defaults 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 OAuth | X connect unavailable. |
X_OAUTH_REDIRECT_URI | 🌐 | X OAuth callback | Defaults 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 OAuth | LinkedIn connect unavailable. |
LINKEDIN_OAUTH_REDIRECT_URI | 🌐 | LinkedIn OAuth callback | Defaults 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,11 | Email sending disabled. |
ACCOUNTING_ENABLED | 🌐 | Feature flag (resolveFeatures) → /api/accounting + dashboard /accounting | Absent ⇒ 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/gate | Master 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.ts | viite’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.ts | Comma-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.ts → CoolifySiteRebuildTrigger | Coolify 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.ts → CoolifySiteRebuildTrigger | Bearer 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). |
2.4 Read but not in schema
Section titled “2.4 Read but not in schema”These are read directly via process.env (not validated):
| Variable | Class | Consumer |
|---|---|---|
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. Client-side inventory (apps/dashboard)
Section titled “3. Client-side inventory (apps/dashboard)”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.
| Variable | Class | Consumer |
|---|---|---|
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)”| Variable | Class | Consumer | Default |
|---|---|---|---|
DASHBOARD_PORT | 🌐 | apps/dashboard/vite.config.ts:10 | 5000 |
API_URL | 🌐 | apps/dashboard/vite.config.ts:11 (proxy target) | http://localhost:3000 |
API_PORT | 🌐 | apps/dashboard/vite.config.ts:11 | 3000 |
No dashboard env validator exists today. Phase C will add
apps/dashboard/src/lib/env.tsasserting requiredVITE_*keys at module load.
4. CLI inventory (apps/cli)
Section titled “4. CLI inventory (apps/cli)”| Variable | Class | Consumer |
|---|---|---|
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/clialready usesPLATFORM_*prefix — supports the convention in NAMING.md.
5. Other apps and packages
Section titled “5. Other apps and packages”packages/platform-domain/— no env reads (pure domain).packages/platform-ingestion/— no env reads at runtime; configured viacreateIngestion({...}).packages/platform-sdk/— no env reads in runtime code;tests/test-config.tsreadsAPI_URL/API_PORTfor integration tests only.packages/platform-utils/— no env reads today. Phase C will add the sharedenv/module here.
6. ${env:...} resolution in job presets
Section titled “6. ${env:...} resolution in job presets”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
EnvResolvercallback. Today that resolver is backed byEnvConfigProvider.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_PROVIDER | key env | model (extractUrls / analyzeContent / classifyItem) |
|---|---|---|
cline (default) | LLM_CLINE_API_KEY | anthropic/claude-haiku-4.5 (all three) |
anthropic | LLM_ANTHROPIC_API_KEY | claude-haiku-4-5 (all three) |
openrouter | LLM_OPENROUTER_API_KEY | z-ai/glm-5 / z-ai/glm-4.7 / z-ai/glm-5 |
auto | — | first 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.
8. /api/config exposure
Section titled “8. /api/config exposure”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.
9. CI surface (.github/workflows)
Section titled “9. CI surface (.github/workflows)”| Secret / Var | Type | Workflows | Blast radius |
|---|---|---|---|
HETZNER_SSH_PRIVATE_KEY | 🔒 | deploy-hetzner.yml | Root SSH on API server. Highest blast radius — Phase H should narrow to a deploy user with sudo docker scope. |
COOLIFY_WEBHOOK_URL | 🔒 | deploy-hetzner.yml | Deploy trigger. |
COOLIFY_API_TOKEN | 🔒 | deploy-hetzner.yml | Coolify API. Scope unknown — Phase H should audit. |
RAILWAY_TOKEN | 🔒 | deploy-railway.yml | Railway deploys. Likely legacy — open question for Phase H. |
GITLEAKS_LICENSE | 🔒 | security.yml | Gitleaks Pro license. |
NPM_TOKEN | 🔒 | publish-packages.yml | npm publish under our scope. |
GITHUB_TOKEN | ⚙️ | every workflow | Auto-provisioned, repo-scoped. |
API_DOMAIN, API_SERVER_IP, DASHBOARD_DOMAIN | 🌐 (vars, not secrets) | deploy-hetzner.yml | Non-secret config. |
Notably absent: no
DATABASE_URL_PRODUCTION/STAGING, noRAILWAY_API_*_SERVICE_ID, noANTHROPIC_API_KEYin CI. Production app secrets live on the Hetzner host (via Coolify), not in CI.
10. Hetzner / Coolify surface
Section titled “10. Hetzner / Coolify surface”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 retiredGMAIL_CLIENT_ID/_SECRET/_REFRESH_TOKEN/_SERVICE_ACCOUNT_PATHentries 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;--applyto 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.
Database topology + resilience
Section titled “Database topology + resilience”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_dumporpgBackRest/wal-gPITR → 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_timeoutso 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.
11. Drift, gaps, and follow-ups
Section titled “11. Drift, gaps, and follow-ups”Items below are tracked here so Phase E (regen .env.example) and Phase F (renames) can close them.
| Item | Impact | Phase |
|---|---|---|
DATABASE_HOST/USER/PASSWORD/NAME/PORT required in schema but never read | Hostile boot for fresh clones | C (schema fix) + E (regen example) |
AUTH_SECRET required but unused; only BETTER_AUTH_SECRET consumed | Hostile boot | C + F #2 |
parseEnvSchema.ts does not validate APP_ENV as enum | Silently accepts typos like prouction | C |
| No dashboard env validator | Misconfig surfaces only at request time | C |
Direct process.env reads in db.ts, drizzle.config.ts, cli-context.ts, LocalFileStorageAdapter.ts | Bypasses validation; no central inventory | I |
${env:...} resolver scope unrestricted | Latent risk if user presets ship | G (decision) |
OPEN_ROUTER_API_KEY adapter known-broken since #165 | Misleading availability signal | Follow-up issue |
Coolify-only env vars (set in UI, not in docker-compose.coolify.yml) | Unknown — needs operator export | H |
12. Provenance
Section titled “12. Provenance”- Source comment with full audit: issue #168 comment.
- Re-verified against
mainon 2026-05-22 (commits up to656f8b5). - Closed-loop fixes already on
main: PR #169 (stdout leak), PR #171 (/api/configleak).
