The source of truth for environment configuration is the repo-root .env.example file. This page walks through it grouped by domain.
| Variable | Purpose |
|---|
DATABASE_URL | App connection string. Railway DEV uses a service reference to the private postgres.railway.internal host. |
DIRECT_URL | Direct DB connection used by migrations. Railway DEV also derives it from the postgres service rather than duplicating a credential. |
| Variable | Purpose |
|---|
BETTER_AUTH_SECRET | 32+ character secret. Generate with openssl rand -base64 32. |
BETTER_AUTH_URL | Origin for cookie scope. http://localhost:5173 in dev. |
COOKIE_DOMAIN | Leave 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_CIDRS | Comma-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_SCHEMES | true (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_ORIGINS | Comma-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_SECRET | OAuth credentials from Google Cloud Console. |
| Variable | Purpose |
|---|
PORT | Listen port. Default 4000. |
CORS_ORIGINS | Comma-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_API | Override BETTER_AUTH_URL specifically for apps/api. Falls back to BETTER_AUTH_URL when unset. |
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.
| Variable | Purpose |
|---|
FIREBASE_APP_ID_IOS_USER / _IOS_AGENT / _ANDROID_USER / _ANDROID_AGENT | Per-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_GROUPS | Default tester group alias(es), comma-separated (e.g. qa). Override per run with --groups. |
APPLE_TEAM_ID | Apple Developer Team ID — required for iOS ad-hoc archive signing. |
See Mobile distribution → Sending test builds for the workflow.
| Variable | Purpose |
|---|
PUBLIC_API_URL | Origin 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. |
| Variable | Purpose |
|---|
RESEND_API_KEY | From resend.com/api-keys. Required for OTP delivery + Knock email channel. |
RESEND_FROM_EMAIL | Sender address (e.g. noreply@<domain>). |
| Variable | Purpose |
|---|
VALKEY_URL | redis://... or rediss://... for TLS. Used for caching, property search index, BullMQ queues, rate limiting. |
VALKEY_TLS | true for self-signed TLS (Proxmox). |
VALKEY_TLS_REJECT_UNAUTHORIZED | false 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_CERT | Optional PEM CA cert for verifying a private TLS Valkey endpoint. |
QUEUE_SUFFIX | Suffix 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_DAYS | Days 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_PASSWORD | HTTP 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. |
| Variable | Purpose |
|---|
AWS_ENDPOINT_URL | S3-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_NAME | Bucket name (e.g. uploads). |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY | Credentials. |
AWS_DEFAULT_REGION | auto for Cloudflare R2 (dev env). us-east-1 for local SeaweedFS / MinIO (conventional default). |
| Variable | Purpose / placement |
|---|
AI_ROOM_STAGING_ENABLED | Fail-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_TOKEN | Worker only. Creates, polls, and cancels predictions for the official openai/gpt-image-2 model. |
REPLICATE_WEBHOOK_BASE_URL | Worker only. Public HTTPS apps/api origin; the worker appends /api/webhooks/replicate/staging/{providerAttemptId}. |
REPLICATE_WEBHOOK_SIGNING_SECRET | apps/api only. Default Standard Webhooks secret returned by Replicate; verifies the exact raw request body and rejects stale replays. |
AI_STAGING_LEGACY_ALIAS_SUNSET_AT | svelte-web + apps/api. ISO timestamp ending the 90-day compatibility window for the six legacy style names. |
AI_STAGING_QUALITY | svelte-web + apps/api, and worker during the one-release legacy bridge. low, medium, high, or auto; captured on each new job. |
AI_STAGING_ASPECT_RATIO | svelte-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_COMPRESSION | svelte-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_MINUTE | Worker only. Independent BullMQ controls for the dedicated ai-staging queue. |
AI_DESCRIPTION_CONCURRENCY | Worker only. Description queue concurrency; intentionally separate from staging. |
AI_STAGING_USER_HASH_SECRET | Worker 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 / _AUTO | Worker only, optional. Operator-verified cost estimates in micro-USD. Leave unset instead of using stale pricing. |
AI_STAGING_PRICING_VERSION | Worker only, optional. Date/version attached when a configured estimate is recorded. |
See the AI room staging rollout and smoke-test runbook.
| Variable | Purpose |
|---|
IMGPROXY_URL | Public origin serving signed images (e.g. https://assets.<domain>). |
IMGPROXY_KEY / IMGPROXY_SALT | Hex-encoded HMAC key + salt. Match the imgproxy container config. |
IMGPROXY_URL_TTL_SECONDS | Signed 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.
| Variable | Purpose |
|---|
CDN_PURGE_PROVIDER | cloudflare, 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_ID | Zone containing the web + api hosts. |
CLOUDFLARE_API_TOKEN | API 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.
| Variable | Purpose |
|---|
STORAGE_ORPHAN_SWEEP_EXECUTE | Only 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_HOURS | How 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. |
| Variable | Purpose |
|---|
MEILISEARCH_HOST | Internal hostname (http://meilisearch.railway.internal:7700 on Railway DEV, supplied through a service reference). |
MEILISEARCH_ADMIN_API_KEY | Write access for sync scripts and BullMQ worker. |
MEILISEARCH_SEARCH_API_KEY | Read-only for client autocomplete. |
| Variable | Purpose |
|---|
GOOGLE_MAPS_API_KEY | Server-side: Places, Geocoding. Browser: Maps JS API. |
GOOGLE_MAPS_MAP_ID | Cloud-styled map. From Google Cloud Console Maps Studio. |
| Variable | Purpose |
|---|
PUBLIC_GETSTREAM_API_KEY | Public-by-design — client embedding. Set in repo-root .env.local; consumed by apps/api (token mint) and apps/svelte-web. |
GETSTREAM_API_SECRET | Server-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. |
| Variable | Purpose |
|---|
PUBLIC_KNOCK_API_KEY | Public — used by the Knock Feed SDK. |
KNOCK_SIGNING_KEY | Server-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_ID | Server-only. Android FCM push (shared by both apps). |
KNOCK_APNS_CHANNEL_ID | Server-only. iOS APNs for the agent app. |
KNOCK_APNS_USER_CHANNEL_ID | Server-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_TOKEN | CLI/CI only — not read by the app. Authenticates the Knock CLI for knock push/pull against the knock/ source of truth. |
| Variable | Purpose |
|---|
PUBLIC_POSTHOG_KEY / PUBLIC_POSTHOG_HOST | Consent-gated product analytics. |
SENTRY_DSN / VITE_SENTRY_DSN | Error + 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_TOKEN | Source-map / symbol upload during build (svelte-web; native apps upload symbols from their own build pipelines). |
| Variable | Purpose |
|---|
STRIPE_SECRET_KEY | Server-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_SECRET | Verifies 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.
| Variable | Purpose |
|---|
FACTURAPI_SECRET_KEY | Server-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_FORM | SAT 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.
| Variable | Purpose |
|---|
ORIGIN | Canonical 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. |
| Variable | Purpose |
|---|
E2E_TEST_USER_EMAIL / E2E_TEST_USER_PASSWORD | Seeded test account. |
PLAYWRIGHT_BASE_URL | Target origin. http://localhost:5173 for local. |
| Env | Where 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 + Production | GCP 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.