Environment
Required and optional env vars. The schema in apps/server/src/bootstrap/env.ts is the source of truth.
All env vars are validated by a Zod schema in apps/server/src/bootstrap/env.ts. The server fails to boot with a clear error if a required var is missing or malformed. Optional adapters boot to a no-op when their config is unset — you never need to fill all the keys.
Minimum to boot
These four are the floor. Without them the server exits early:
BETTER_AUTH_SECRET=<openssl rand -base64 32>
BETTER_AUTH_URL=http://localhost:3005
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/boilerplate?schema=public
CORS_ORIGINS=http://localhost:3004Generate the auth secret with openssl rand -base64 32. CORS_ORIGINS is a comma-separated list of allowed origins for the browser.
Auth providers (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=Leave both empty to disable Google OAuth. Magic-link is configured through your email provider — see the notifications module.
Storage (optional)
STORAGE_PROVIDER=null # null | local | s3 | uploadthing
# S3 / R2 / MinIO
STORAGE_S3_BUCKET=
STORAGE_S3_REGION=
STORAGE_S3_ACCESS_KEY_ID=
STORAGE_S3_SECRET_ACCESS_KEY=
STORAGE_S3_ENDPOINT= # set for R2/MinIO; leave empty for AWS
STORAGE_S3_PUBLIC_BASE_URL= # CDN base
# Local disk (dev)
STORAGE_LOCAL_ROOT=
STORAGE_LOCAL_PUBLIC_BASE_URL=
# UploadThing
UPLOADTHING_TOKEN=
UPLOADTHING_APP_ID=STORAGE_PROVIDER=null (the default) skips registering an adapter so endpoints that need uploads return 503. The local provider builds public URLs from BETTER_AUTH_URL (the server's URL), not APP_URL — uploads are served by the API, not by Next.js.
Background jobs (optional)
REDIS_URL=redis://localhost:6379
JOBS_REDIS_URL= # defaults to REDIS_URL
JOBS_PREFIX=bull
BULL_BOARD_ENABLED=true # mounts /admin/queues UIWithout Redis, BullMQ is registered but no worker connects. BULL_BOARD_ENABLED=true mounts a guarded Bull Board UI at /admin/queues.
Rate limiting
RATE_LIMIT_REDIS_ENABLED=true # falls back to memory when REDIS_URL is unset
RATE_LIMIT_READ_PER_MIN=300
RATE_LIMIT_WRITE_PER_MIN=60
RATE_LIMIT_AUTH_IP_PER_MIN=30
RATE_LIMIT_AUTH_EMAIL_PER_15MIN=5The four tiers (read, write, auth-ip, auth-email) each get their own Redis prefix (rl:read:, rl:write:, etc.) so counters never collide. The limiter handlers are memoized on the RateLimitDeps reference, so memory-mode behaves identically to Redis-mode (one shared store per tier).
Observability (optional)
SENTRY_DSN= # leave empty to disable
SENTRY_TRACES_SAMPLE_RATE=0.1
APP_VERSION=
OTEL_EXPORTER_OTLP_ENDPOINT= # leave empty to disable trace export
OTEL_SERVICE_NAME=mern-saas-server
METRICS_ENABLED=true # Prometheus /metrics endpointOTel must boot before Express/Prisma load. The first import in apps/server/src/index.ts is ./otel-init.js for that reason. Don't reorder it.
The Sentry server SDK is @sentry/bun (not @sentry/node). logger.error(...) does not auto-capture; use the captureError(err, ctx) helper for errors that should page someone.
Billing (provider-dependent)
Set PAYMENT_PROVIDER to one of stripe, mercado-pago, polar, then fill that provider's keys:
PAYMENT_PROVIDER=stripe
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# or
PAYMENT_PROVIDER=mercado-pago
MERCADO_PAGO_ACCESS_TOKEN=
MERCADO_PAGO_WEBHOOK_SECRET=
# or
PAYMENT_PROVIDER=polar
POLAR_ACCESS_TOKEN=
POLAR_WEBHOOK_SECRET=See Billing for the strategy and webhook setup.
Usage metering (optional)
Disabled by default. Flip the flag once the downstream has registered its meters and a plan → cap map.
USAGE_METERING_ENABLED=false # set to "true" to enforce quotas
USAGE_FREE_PRICE_ID=price_free_synthetic # synthetic key for the Free tier; not a real Polar priceSee Usage metering for the full reference.
Adding a new variable
- Edit
apps/server/src/bootstrap/env.tsand extend the Zod schema. Required vars must.min(1); optional ones use.optional()or.default(...). - Add a documented entry in
apps/server/.env.example. - Read it from
env.YOUR_VAR— never fromprocess.envdirectly.