Skip to content

Environment Variables

All environment variables for both backend and frontend services.


Variable Required Default Description
PORT No 3000 HTTP server port. Railway sets this automatically.
NODE_ENV No production or development. Affects logging and error detail.
Variable Required Description
SUPABASE_URL Yes Project URL from Supabase → Settings → API. e.g. https://abc123.supabase.co
SUPABASE_PUBLISHABLE_KEY Preferred Publishable key used for RLS-enforced client operations.
SUPABASE_ANON_KEY Fallback Legacy public anon key, used when no publishable key is configured.
SUPABASE_SERVICE_ROLE_KEY Yes Service role key (bypasses RLS). Never expose to frontend.
SUPABASE_DB_URL Yes PostgreSQL connection string for direct DB access / migrations. URL-encode password special characters, e.g. # as %23.
Variable Required Description
REDIS_URL Yes Redis connection string. Provided by Railway Redis plugin or local instance. Format: rediss://default:[pw]@[host]:[port]
Variable Required Default Description
JWT_PRIVATE_KEY Yes Private P-256 signing key as one-line JWK JSON. Backend secret only.
JWT_PUBLIC_KEY Yes Matching public P-256 verification key as one-line JWK JSON.
JWT_KEY_ID Yes Supabase signing-key kid; must match both JWKs.
JWT_ISSUER Yes Token issuer, normally https://<project-ref>.supabase.co/auth/v1.
JWT_AUDIENCE Yes authenticated Must be exactly authenticated.
JWT_SECRET Yes Symmetric secret used only for QR check-in tokens. Minimum 32 characters.
Variable Required Description
WHATSAPP_APP_SECRET Yes Meta App secret. Used to verify HMAC-SHA256 webhook signatures. Found in Meta App → App Settings → Basic → App Secret.
WHATSAPP_WEBHOOK_VERIFY_TOKEN Yes A random string you create. Must match the “Verify token” field in Meta App → WhatsApp → Configuration → Webhook.
META_GRAPH_API_VERSION No Meta Graph API version. e.g. v22.0
Variable Required Description
ENCRYPTION_KEY Yes AES-256 key. Must be exactly 32 characters. Used to encrypt WhatsApp tokens and patient DUI numbers. Generate with openssl rand -hex 16 (gives 32 hex chars).
Variable Required Description
FRONTEND_URL Yes Frontend origin for CORS. e.g. https://app.clinicflow.lat. No trailing slash.
BACKEND_URL No Backend URL used in internal references.
BACKEND_PUBLIC_URL Yes Public backend URL used in ICS calendar feed URLs. e.g. https://api.clinicflow.lat
REFRESH_COOKIE_DOMAIN No Registrable domain shared by frontend + API so the refresh-token cookie is sent cross-subdomain (e.g. .clinicflow.lat). Leave unset for localhost (host-only cookie).
Variable Required Default Description
QUEUES_ADMIN_USER No admin Username for Bull Board dashboard at /admin/queues
QUEUES_ADMIN_PASS Yes Password for Bull Board. Use a strong random string.
Variable Required Description
LEMONSQUEEZY_SIGNING_SECRET Yes Webhook signing secret from LemonSqueezy → Store → Webhooks. Used to verify order_created events.
LS_VARIANT_BASIC Yes LemonSqueezy variant ID for the Basic plan (monthly). Found in the product variant settings.
LS_VARIANT_BASIC_ANNUAL Yes LemonSqueezy variant ID for the Basic plan (annual).
LS_VARIANT_PRO Yes LemonSqueezy variant ID for the Pro plan (monthly).
LS_VARIANT_PRO_ANNUAL Yes LemonSqueezy variant ID for the Pro plan (annual).
LS_VARIANT_CLINICA Yes LemonSqueezy variant ID for the Clínica plan (monthly).
LS_VARIANT_CLINICA_ANNUAL Yes LemonSqueezy variant ID for the Clínica plan (annual).
Variable Required Description
SENTRY_DSN No Sentry DSN for error tracking. Leave blank to disable.
Variable Required Description
SUPER_ADMIN_KEYS Yes Comma-separated operator:key[:expiresAt] entries for the superadmin API (e.g. ernesto:abc123...:2026-12-31). Sent as X-Admin-Key header. Each key >=16 characters. expiresAt is optional (ISO date). Generate keys with openssl rand -hex 24.
SUPER_ADMIN_IP_ALLOWLIST No Comma-separated exact IPs allowed to present a superadmin key. Unset = no restriction (default).
OWNER_WHATSAPP_NUMBER No Owner’s WhatsApp number in E.164 format. Used for demo system notifications.
Variable Required Description
LLM_PROVIDER No Which chatbot LLM provider to route to: anthropic or gemini. Defaults to anthropic.
ANTHROPIC_API_KEY Yes (if LLM_PROVIDER=anthropic) Anthropic API key for Claude Haiku chatbot. Get from console.anthropic.com
GEMINI_API_KEY Yes (if LLM_PROVIDER=gemini) Google Gemini API key for the Gemini 2.5 Flash chatbot alternative.
GROQ_API_KEY No Groq key for Whisper (whisper-large-v3-turbo) transcription of WhatsApp voice notes — preferred provider (cheaper + newer). If unset, voice notes degrade gracefully (the bot asks the patient to type). Get from console.groq.com
OPENAI_API_KEY No Fallback transcription provider (whisper-1) used only when GROQ_API_KEY is absent. Get from platform.openai.com
TRANSCRIPTION_URL / TRANSCRIPTION_MODEL No Override the transcription endpoint/model for any other OpenAI-compatible Whisper host. Defaults to Groq when GROQ_API_KEY is set.

All Vite env vars must be prefixed with VITE_. They are embedded in the build at compile time — do not put secrets here.

Variable Required Description
VITE_API_URL Yes Backend API base URL. e.g. https://api.clinicflow.lat/api for production, http://localhost:3000/api for local dev.
VITE_LS_URL_BASIC No LemonSqueezy checkout URL for the Basic plan (monthly).
VITE_LS_URL_BASIC_ANNUAL No LemonSqueezy checkout URL for the Basic plan (annual).
VITE_LS_URL_PRO No LemonSqueezy checkout URL for the Pro plan (monthly).
VITE_LS_URL_PRO_ANNUAL No LemonSqueezy checkout URL for the Pro plan (annual).
VITE_LS_URL_CLINICA No LemonSqueezy checkout URL for the Clínica plan (monthly).
VITE_LS_URL_CLINICA_ANNUAL No LemonSqueezy checkout URL for the Clínica plan (annual).
VITE_SENTRY_DSN No Sentry DSN for frontend error tracking. Leave blank to disable.
VITE_ANALYTICS_ID No Analytics tracking ID. Leave blank to disable.
VITE_META_APP_ID No Meta App ID for the Embedded Signup WhatsApp onboarding flow (Phase 2).
VITE_META_CONFIG_ID No Meta Embedded Signup config ID. Leave blank to use the phone+OTP onboarding flow instead.
VITE_META_GRAPH_VERSION No Meta Graph API version used by the Embedded Signup SDK. e.g. v22.0

Used when running npm test. Separate from .env so tests never touch the production database.

Required keys: Same as .env but pointing to a test Supabase project (or the same project with test data).

The test runner loads this file automatically:

Terminal window
node --env-file=.env.test ./node_modules/.bin/vitest run

Terminal window
# JWT_SECRET (64 hex chars)
openssl rand -hex 32
# ENCRYPTION_KEY (exactly 32 chars)
openssl rand -hex 16
# SUPER_ADMIN_KEYS (per-operator)
openssl rand -hex 24
# QUEUES_ADMIN_PASS
openssl rand -base64 18

The format already supports multiple concurrent operator keys (operator:key[:expiresAt], comma-separated) — rotation needs no dual-window logic:

  1. Add the new key as a new entry (new operator name, or the same operator with a new key value) alongside the existing one.
  2. Deploy. Both keys now work.
  3. Confirm the new key works (X-Admin-Key header on any /api/superadmin/* route).
  4. Remove the old entry, deploy again.

To pre-schedule a cutover instead of two deploys, set the old key’s expiresAt (ISO date) to the cutover date in the same deploy that adds the new key — expired keys fail closed automatically (backend/src/config/superAdminKeys.ts’s matchSuperAdminOperator).

Not a simple env-var swap. encrypt()/decrypt() derive their AES-256-GCM and HMAC blind-index keys via HKDF from this single master secret (backend/src/utils/encryption.ts) — there is no key-versioning scheme, so every existing v3:-prefixed ciphertext (patients.email/notes, conversation_messages.body/ patient_phone, chat_sessions.patient_phone, whatsapp_outbox.phone/body, audit_logs.changes, periogram_snapshots.data_payload, calendar_feed_token) becomes unreadable the instant the key changes.

Procedure — mirrors the one-time backend/scripts/backfill-track2-encryption.ts migration (which did the same shape of work for the v2:v3: format change):

  1. Write a one-off script that, for each affected table/column: reads the row with the old ENCRYPTION_KEY loaded, decrypts, holds the plaintext in memory only as long as needed, re-encrypts with the new key, writes back.
  2. Run it during a low-traffic window. decrypt() fails closed (returns null, never throws), so any row not yet migrated degrades to a blank field rather than a 500 — but that’s still a real availability gap for that row until the script reaches it, so don’t treat this as zero-downtime.
  3. Only after the backfill completes, update ENCRYPTION_KEY in Railway and redeploy.

There is no dual-key support for this secret today — that’s the biggest gap versus SUPER_ADMIN_KEYS. If scheduled/recurring rotation becomes a requirement, that’s a code change (versioned ciphertext prefix + multi-key decrypt()), not a runbook step.

JWT_PRIVATE_KEY / JWT_PUBLIC_KEY / JWT_KEY_ID

Section titled “JWT_PRIVATE_KEY / JWT_PUBLIC_KEY / JWT_KEY_ID”

These are Supabase’s own Auth (GoTrue) signing key, reused so the app’s own session JWTs verify against Supabase’s role: authenticated/RLS checks directly. verifyUserToken() (backend/src/auth/userToken.ts) checks the token’s kid against exactly one configured JWT_KEY_ID — no dual-kid verification window in the app code.

  1. In the Supabase Dashboard → Authentication → JWT Keys, generate/rotate the signing key and copy its new JWK pair + kid.
  2. Update JWT_PRIVATE_KEY, JWT_PUBLIC_KEY, and JWT_KEY_ID together in Railway (all three must match the same key generation) and redeploy.
  3. Any access token signed with the old kid now fails verification — but refresh tokens are opaque and DB-backed (refresh_tokens table, SHA-256 hash, no JWT involved), so the very next API call from an affected session gets one 401, the frontend’s axios interceptor (frontend/src/services/api.ts) silently calls /api/auth/refresh, and the response carries a token signed with the new key. No re-login required.
  4. Access tokens are short-lived — USER_TOKEN_TTL_SECONDS in backend/src/auth/userToken.ts hardcodes 8 hours (not env-configurable; JWT_EXPIRES_IN appears in .env.test but nothing reads it) — so old-kid tokens drain from circulation within one 8-hour window regardless.