Skip to content

Environment variables

The source of truth for environment configuration is the repo-root .env.example file. This page walks through it grouped by domain.

VariablePurpose
DATABASE_URLApp connection string. Railway DEV uses a service reference to the private postgres.railway.internal host.
DIRECT_URLDirect DB connection used by migrations. Railway DEV also derives it from the postgres service rather than duplicating a credential.
VariablePurpose
BETTER_AUTH_SECRET32+ character secret. Generate with openssl rand -base64 32.
BETTER_AUTH_URLOrigin for cookie scope. http://localhost:5173 in dev.
COOKIE_DOMAINLeave unset for single-origin dev/prod. Set to a parent domain (.<domain>) once apps/api is on a sibling subdomain — adds Domain + crossSubDomainCookies for sharing. Cookies stay SameSite=Lax (web↔api are same-site).
TRUSTED_PROXY_CIDRSComma-separated IPv4 CIDRs whose socket peers may supply forwarded client-IP headers. Railway DEV currently sets 100.64.0.0/10 on web and API as the internal proxy range. Treat it as provisional: verify observed socket peers and prove that forged forwarding headers sent directly to Railway-provided domains cannot influence client-IP resolution before calling it final. When unset in production, getTrustedClientIp fails closed to the socket peer.
AUTH_TRUST_CUSTOM_SCHEMEStrue (default) keeps the native apps’ custom URL schemes in Better Auth trustedOrigins. Set false only after verified App Links are live — otherwise mobile sign-in breaks.
MOBILE_APP_LINK_ORIGINSComma-separated verified HTTPS origins for Universal Links / App Links (e.g. https://app.<domain>). Drives the native apps’ associatedDomains (iOS) / intentFilters (Android) deep-link config.
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETOAuth credentials from Google Cloud Console.
VariablePurpose
PORTListen port. Default 4000.
CORS_ORIGINSComma-separated browser origin allowlist. Used by both the CORS layer and the CSRF Origin allowlist (enforceOrigin + avatar guard) — a browser request with a non-listed Origin is rejected 403. Mobile (RN fetch) sends no Origin and is exempt.
BETTER_AUTH_URL_APIOverride BETTER_AUTH_URL specifically for apps/api. Falls back to BETTER_AUTH_URL when unset.

Mobile distribution (Firebase App Distribution)

Section titled “Mobile distribution (Firebase App Distribution)”

Local-tooling only — consumed by scripts/firebase-distribute.ts (Bun auto-loads .env.local from the repo root), never by an app build or a deployed service. CLI auth is a one-time firebase login, not an env var.

VariablePurpose
FIREBASE_APP_ID_IOS_USER / _IOS_AGENT / _ANDROID_USER / _ANDROID_AGENTPer-app Firebase App ID (Firebase console → Project settings → Your apps → “App ID”, e.g. 1:1234567890:ios:abcd…). Not the bundle id. Not secret.
FIREBASE_DISTRIBUTION_GROUPSDefault tester group alias(es), comma-separated (e.g. qa). Override per run with --groups.
APPLE_TEAM_IDApple Developer Team ID — required for iOS ad-hoc archive signing.

See Mobile distribution → Sending test builds for the workflow.

VariablePurpose
PUBLIC_API_URLOrigin of the standalone tRPC host for browser-side fetches. Build-time — baked into the browser bundle via a Vite define; a runtime change does nothing, so rebuild. Required on Railway DEV as https://api.dev.useast.kottia.com. Railway passes service variables to Docker builds for matching declared ARGs, which is how the existing Dockerfile receives it. SSR/form actions are unaffected because they use createCaller(locals) in process.
VariablePurpose
RESEND_API_KEYFrom resend.com/api-keys. Required for OTP delivery + Knock email channel.
RESEND_FROM_EMAILSender address (e.g. noreply@<domain>).
VariablePurpose
VALKEY_URLredis://... or rediss://... for TLS. Used for caching, property search index, BullMQ queues, rate limiting.
VALKEY_TLStrue for self-signed TLS (Proxmox).
VALKEY_TLS_REJECT_UNAUTHORIZEDfalse to accept self-signed certs over TLS (default verifies). Honored by @repo/cache, the shared queue connection, and the worker (apps/worker/src/connection.ts no longer hardcodes rejectUnauthorized: false).
VALKEY_CA_CERTOptional PEM CA cert for verifying a private TLS Valkey endpoint.
QUEUE_SUFFIXSuffix appended to every BullMQ queue name. Railway DEV and local dev use -dev; production will leave it empty. Producer and worker must use the same suffix.
DLQ_RETENTION_DAYSDays to retain failed_lead_ingestions rows before the worker dlq-purge job deletes them (default 90). Resolved / replayed / discarded rows are purged regardless of age.
BULL_BOARD_USER / BULL_BOARD_PASSWORDHTTP Basic auth for the worker’s Bull Board dashboard. DEV/STG only — Bull Board is not deployed in PRD (a Cloud Run worker pool has no URL; PRD uses the queue:admin scripts — see Incident guides). Required wherever the dashboard runs, in every environment — the worker refuses to start it without both (it grants full queue control + exposes lead PII, and binds 0.0.0.0). Leaving them unset is what keeps the dashboard off PRD; there is no NODE_ENV gate, and none would work, because apps/worker/Dockerfile pins NODE_ENV=production into DEV and STG images too. The credential gate itself used to key off NODE_ENV=production, which served the dashboard unauthenticated wherever the platform env said development/staging. Failed auth attempts are limited to 10/min per client IP (resolved through getTrustedClientIp, so admins behind a proxy don’t share one bucket), after which the IP is refused for 5 minutes before its password is compared. During a Valkey outage the counter still denies but the lockout is not remembered — degraded, not absent.
VariablePurpose
AWS_ENDPOINT_URLS3-compatible endpoint. Cloudflare R2 (https://<ACCOUNT_ID>.r2.cloudflarestorage.com, region auto) on the deployed dev env; local SeaweedFS / MinIO for local dev. The S3 client keeps forcePathStyle.
AWS_S3_BUCKET_NAMEBucket name (e.g. uploads).
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEYCredentials.
AWS_DEFAULT_REGIONauto for Cloudflare R2 (dev env). us-east-1 for local SeaweedFS / MinIO (conventional default).
VariablePurpose / placement
AI_ROOM_STAGING_ENABLEDFail-closed paid-prediction switch. Only exact true enables submissions/new provider attempts; unset or false stays disabled. Set identically on svelte-web, apps/api, and worker. Persisted active attempts remain recoverable/cancelable.
REPLICATE_API_TOKENWorker only. Creates, polls, and cancels predictions for the official openai/gpt-image-2 model.
REPLICATE_WEBHOOK_BASE_URLWorker only. Public HTTPS apps/api origin; the worker appends /api/webhooks/replicate/staging/{providerAttemptId}.
REPLICATE_WEBHOOK_SIGNING_SECRETapps/api only. Default Standard Webhooks secret returned by Replicate; verifies the exact raw request body and rejects stale replays.
AI_STAGING_LEGACY_ALIAS_SUNSET_ATsvelte-web + apps/api. ISO timestamp ending the 90-day compatibility window for the six legacy style names.
AI_STAGING_QUALITYsvelte-web + apps/api, and worker during the one-release legacy bridge. low, medium, high, or auto; captured on each new job.
AI_STAGING_ASPECT_RATIOsvelte-web + apps/api, and worker during the legacy bridge. Official GPT Image 2 value: 1:1, 3:2, or 2:3; defaults to 1:1.
AI_STAGING_OUTPUT_FORMAT / AI_STAGING_OUTPUT_COMPRESSIONsvelte-web + apps/api, and worker during the legacy bridge. jpeg, png, or webp; integer compression from 0–100. Captured on each new job.
AI_STAGING_CONCURRENCY / AI_STAGING_RATE_LIMIT_PER_MINUTEWorker only. Independent BullMQ controls for the dedicated ai-staging queue.
AI_DESCRIPTION_CONCURRENCYWorker only. Description queue concurrency; intentionally separate from staging.
AI_STAGING_USER_HASH_SECRETWorker only. Independent HMAC secret used to pseudonymize Replicate’s optional user_id. Generate with openssl rand -hex 32.
AI_STAGING_COST_MICROUSD_LOW / _MEDIUM / _HIGH / _AUTOWorker only, optional. Operator-verified cost estimates in micro-USD. Leave unset instead of using stale pricing.
AI_STAGING_PRICING_VERSIONWorker only, optional. Date/version attached when a configured estimate is recorded.

See the AI room staging rollout and smoke-test runbook.

VariablePurpose
IMGPROXY_URLPublic origin serving signed images (e.g. https://assets.<domain>).
IMGPROXY_KEY / IMGPROXY_SALTHex-encoded HMAC key + salt. Match the imgproxy container config.
IMGPROXY_URL_TTL_SECONDSSigned URL lifetime (default 86400 = 24 hours). Expiry is part of the HMAC payload — leaked URLs stop working after this window. Also caps the image routes’ cache window: both /api/images/* proxies ship max-age = s-maxage = min(86400, this), so lowering it shortens the browser and CDN windows with it. A literal 0 is the exception — it disables signature expiry rather than requesting a zero-second cache, so the window stays at 86400. Empty, unset, or non-numeric all fall back to the default; only a literal 0 disables expiry (not recommended — the URL then never stops working).

Worker only — consumed by the cdn-purge queue, which evicts a listing’s photos from the edge when it stops being publicly viewable.

VariablePurpose
CDN_PURGE_PROVIDERcloudflare, or gcp-cloud-cdn (not implemented — throws on the first job so a misconfigured STG fails loudly; see PRODUCTION.md prerequisite 8). Leave unset to disable purging: jobs complete as no-ops with one log line per process and de-listed photos stay cached until the 24h s-maxage clamp expires.
CLOUDFLARE_ZONE_IDZone containing the web + api hosts.
CLOUDFLARE_API_TOKENAPI token scoped to exactly one permission: Zone → Cache Purge. Cloudflare honours tag purges on Enterprise zones only — on any other plan the worker logs one Sentry warning (cdn-purge-unsupported) and falls back to the 24h bound.

Worker only — consumed by the storage queue. The per-minute outbox drain needs no configuration; these two govern the daily orphan sweep.

VariablePurpose
STORAGE_ORPHAN_SWEEP_EXECUTEOnly exact true arms the sweep to act. Unset (the default) is report-only: it logs a summary and raises one fingerprinted Sentry warning when it finds anything. When armed it still does not call DeleteObject — it enqueues the keys into storage_deletion_outbox with reason ORPHAN_SWEEP, so the drain’s reference re-check remains the single deletion path. Leave unset until a dry run has been read.
STORAGE_ORPHAN_SWEEP_GRACE_HOURSHow old an unreferenced object must be before it counts as an orphan (default 48). Must cover both the pending-upload window (bytes land before the row that points at them) and the drain’s 24h “still referenced” defer. Empty, unset, negative, fractional or non-numeric all fall back to the default; only a literal 0 disables the grace, which is for local verification and never production.
VariablePurpose
MEILISEARCH_HOSTInternal hostname (http://meilisearch.railway.internal:7700 on Railway DEV, supplied through a service reference).
MEILISEARCH_ADMIN_API_KEYWrite access for sync scripts and BullMQ worker.
MEILISEARCH_SEARCH_API_KEYRead-only for client autocomplete.
VariablePurpose
GOOGLE_MAPS_API_KEYServer-side: Places, Geocoding. Browser: Maps JS API.
GOOGLE_MAPS_MAP_IDCloud-styled map. From Google Cloud Console Maps Studio.
VariablePurpose
PUBLIC_GETSTREAM_API_KEYPublic-by-design — client embedding. Set in repo-root .env.local; consumed by apps/api (token mint) and apps/svelte-web.
GETSTREAM_API_SECRETServer-only. Used to mint client user tokens via HMAC. Never ship to the browser or mobile. Lives in repo-root .env.local only, consumed by apps/api.
VariablePurpose
PUBLIC_KNOCK_API_KEYPublic — used by the Knock Feed SDK.
KNOCK_SIGNING_KEYServer-only. Signs the userToken for Knock Feed authenticate(). Required in production — signKnockUserToken throws when missing. Enable “Require identification token verification” in the Knock dashboard.
KNOCK_FCM_CHANNEL_IDServer-only. Android FCM push (shared by both apps).
KNOCK_APNS_CHANNEL_IDServer-only. iOS APNs for the agent app.
KNOCK_APNS_USER_CHANNEL_IDServer-only. iOS APNs for the customer app (separate bundle id / auth key). registerDevice’s app field routes customer tokens here; leave empty until the channel is provisioned (push then no-ops).
KNOCK_SERVICE_TOKENCLI/CI only — not read by the app. Authenticates the Knock CLI for knock push/pull against the knock/ source of truth.
VariablePurpose
PUBLIC_POSTHOG_KEY / PUBLIC_POSTHOG_HOSTConsent-gated product analytics.
SENTRY_DSN / VITE_SENTRY_DSNError + replay. One project per deployable, so each service (svelte-web, apps/api, apps/worker) sets SENTRY_DSN to its own project’s DSN; VITE_SENTRY_DSN is the svelte-web client DSN (same project as the server). Native-app DSNs are inlined in each app’s Monitoring init (publishable, not env).
SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKENSource-map / symbol upload during build (svelte-web; native apps upload symbols from their own build pipelines).
VariablePurpose
STRIPE_SECRET_KEYServer-only. Read by @repo/server-services, apps/api (the webhook at POST /api/webhooks/stripe) and apps/worker (the daily stripe-reconcile). Unset disables checkout and the billing pages rather than erroring. Test and live are separate keys — each tier needs its own.
STRIPE_WEBHOOK_SIGNING_SECRETVerifies the webhook signature before any event is trusted. It is per endpoint, not per account, so a new endpoint means a new secret even in the same Stripe account.

Checkout is Stripe-hosted, so no publishable key and no Stripe.js reach the browser — which is also why nothing here changes the web CSP.

VariablePurpose
FACTURAPI_SECRET_KEYServer-only. Stamps CFDI 4.0 documents at the SAT through FacturAPI’s PAC. Unsetting it is the clean stop: the facturación card hides and the 15-minute cfdi-scan no-ops, leaving the rest of billing untouched. Live mode additionally needs CSD certificates uploaded in the FacturAPI dashboard — a live key with no certificates stamps nothing.
FACTURAPI_PRODUCT_KEY / FACTURAPI_UNIT_KEY / FACTURAPI_PAYMENT_FORMSAT catalog codes (c_ClaveProdServ, c_ClaveUnidad, c_FormaPago), not secrets. The defaults are defensible; which code actually applies is the issuer’s fiscal call and their contador’s to confirm.

PII_ENCRYPTION_KEY encrypts the fiscal identity both of these integrations exchange — see Settings → Secrets, because it is the one secret in the repo that cannot be re-issued.

VariablePurpose
ORIGINCanonical site origin used in OpenGraph tags + sitemap. Also read by apps/api and apps/worker to absolutize billing-email CTAs — unset in a deployed tier, those degrade to relative paths silently.
VariablePurpose
E2E_TEST_USER_EMAIL / E2E_TEST_USER_PASSWORDSeeded test account.
PLAYWRIGHT_BASE_URLTarget origin. http://localhost:5173 for local.
EnvWhere it lives
Local dev.env.local (gitignored, per-developer).
Dev (deployed)Railway project kottia.com, environment development. Non-secret internal wiring uses service references; integration secrets were manually copied from Dokploy and stay out of source. Declared Docker ARGs receive matching service variables during builds, including required PUBLIC_API_URL. Mobile builds point at https://api.dev.useast.kottia.com.
Staging + ProductionGCP us-central1 — Secret Manager, injected into Cloud Run; QUEUE_SUFFIX=-stg on staging and empty on production. Decided, not provisioned yet — see Production landscape.

See Settings → Secrets for the policy on how secrets are stored and rotated.