Skip to content

Environment Variables ​

The canonical template is .env.example at the repository root — copy it to .env and fill in values. This page explains each group and which are truly required for local development.

bash
cp .env.example .env

Graceful degradation

Most integrations are optional locally. When a service's vars are blank the code logs and skips that path (SMS, uploads, search, weather, Sentry) rather than crashing. Only DATABASE_URL and JWT_SECRET are needed to boot the API meaningfully.

Database (required) ​

env
DATABASE_URL=postgresql://postgres:<password>@<host>:5432/postgres
# DATABASE_POOL_SIZE=20        # connections per API process (Supabase pooler)
# DATABASE_POOL_TIMEOUT=10     # seconds before P2024 → graceful 503
# DATABASE_POOL_WARM=3         # connections opened at boot (avoids first-request handshake)

Pool sizing on a shared Supabase database: DATABASE_POOL_SIZE ≈ (tier connection cap × 0.8) / (number of processes).

Auth — JWT (required) ​

env
JWT_SECRET=<random string, ≥64 chars>
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=30d
# LOGIN_TIMEOUT_MS=3000        # fast-fail a login with 503 when the pool is saturated
# REFRESH_TOKEN_GRACE_MS=60000 # previous refresh token stays valid this long after rotation

The rotation grace window lets a flaky-network client that rotated a refresh token but never received the response retry with the token it still holds, instead of being logged out.

SMS — MSG91 ​

Every SMS event also writes an in-app notification (the bell), which is the authoritative channel. SMS is best-effort. Only four templates are boot-validated in production (OFFER_ACCEPTED, OTP, BID_PLACED, BID_ACCEPTED and the two *_AUTO_REJECTED_LEAD_EXPIRED). The full catalogue with per-template variables lives in docs/sms-notifications.md.

env
MSG91_API_KEY=...
MSG91_SENDER_ID=CROPTO
MSG91_TEMPLATE_ID_OTP=...
MSG91_TEMPLATE_ID_OFFER_ACCEPTED=...
# … many optional event templates (safe to leave blank) …

Job queue — Redis ​

env
# Railway private endpoint (plain redis://, no TLS, free). Any redis:// / rediss:// URL works.
REDIS_URL=redis://default:<password>@redis.railway.internal:6379

TLS is auto-applied only for a rediss:// URL (e.g. Upstash); family: 0 resolves the IPv6 *.railway.internal host. The legacy UPSTASH_REDIS_URL is still read as a fallback.

Without Redis, BullMQ queues and rate-limit counters fall back to per-process in-memory behaviour: still functional, but not shared across instances and wiped on restart.

Load-testing quota

The Upstash free tier allows 500,000 commands/month and cannot be reset manually. Each login touches Redis several times. Before any load test, set RATE_LIMIT_DISABLED=true to keep a burst run from exhausting the month's quota.

File storage — Cloudflare R2 ​

env
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET_NAME=cropto-uploads
R2_PUBLIC_URL=https://pub-xxxx.r2.dev

The database stores only the R2 object key; the full URL is built from R2_PUBLIC_URL at response time.

Batch search — Meilisearch ​

env
MEILI_HOST=https://...
MEILI_API_KEY=...

When unset, batch search falls back to Postgres pg_trgm and the index is not synced. After configuring, build the initial index:

bash
node --import tsx apps/api/src/scripts/reindex-batches.ts

Weather & observability ​

env
OPENWEATHERMAP_API_KEY=...
SENTRY_DSN=                      # empty = Sentry initialises but drops events silently
SENTRY_ENVIRONMENT=development
SENTRY_RELEASE=                  # set to git SHA in CI/CD
SENTRY_TRACES_SAMPLE_RATE=       # override (default 1.0 dev / 0.2 prod)

App & CORS ​

env
NODE_ENV=development
PORT=3000
FRONTEND_URL=http://localhost:5173   # allowed CORS origin (web app)
# ADMIN_URL=http://localhost:5174    # allowed CORS origin (admin panel)

In non-production, any http://localhost:<port> origin is accepted plus ecropto.com and subdomains; * is never allowed.

Rate limiting ​

env
RATE_LIMIT_DISABLED=false        # master off-switch (IGNORED in production)
OTP_RATE_LIMIT_MAX=5
OTP_RATE_LIMIT_WINDOW_MS=600000
LOGIN_PHONE_LIMIT_MAX=10         # consecutive FAILED attempts (successes clear the counter)
LOGIN_DEVICE_LIMIT_MAX=20
LOGIN_IP_LIMIT_MAX=300           # coarse shared bucket (NAT-safe)
SEARCH_RATE_LIMIT_MAX=120

The login limiters count failures only; a successful login is refunded and clears the phone + device counters. So these are "consecutive failures before lockout", not "logins per window".

QA / automation locks (never on production) ​

env
ENABLE_TEST_ENDPOINTS=false      # + TEST_ENDPOINT_SECRET → mounts /api/v1/test/*
TEST_ENDPOINT_SECRET=
TEST_OTP_PHONES=                 # comma list; matched numbers get fixed OTP 123456, no SMS, rate-exempt

Two independent locks (flag + secret) keep the test endpoints off. TEST_OTP_PHONES supports exact numbers and trailing-* prefix wildcards. Confirm on a running process: GET /api/v1/health → data.config.fixedOtpPhones.

Frontend (Vite) env vars ​

Prefix with VITE_ so they're exposed to the browser bundle. Set in apps/web/.env and apps/admin/.env:

env
VITE_API_URL=http://localhost:3000/api/v1
VITE_SENTRY_DSN=
VITE_ENV=development
VITE_RELEASE=

Admin build-time API URL

VITE_API_URL is baked in at build time. The deployed admin panel's data environment is whatever DB the API it points at uses — the panel never touches the DB directly. Locally, the API often points at the live Supabase pilot DB, so destructive admin actions (e.g. retention purge) hit real pilot data.

Internal technical documentation — Cropto