SaaS Starter
Getting Started

Environment

Required and optional env vars. The schema in apps/server/src/bootstrap/env.ts is the source of truth.

Last updated May 12, 2026

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:3004

Generate 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 UI

Without 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=5

The 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).

AI chat (optional)

ANTHROPIC_API_KEY=               # empty = stub stream, no upstream calls
AI_MODEL=claude-haiku-4-5        # default model, always allowed
AI_ALLOWED_MODELS=               # extra models clients may request (comma-separated)
AI_MAX_OUTPUT_TOKENS=1024        # hard cap on generated tokens per completion
AI_STREAM_TIMEOUT_MS=120000     # hard wall-clock cap (ms) per chat stream; aborts upstream

POST /api/v1/ai/chat rejects any model outside AI_MODEL + AI_ALLOWED_MODELS with a 422, so a caller cannot pick a more expensive model.

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 endpoint

OTel 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=
POLAR_SERVER=sandbox   # or production

[!WARNING] POLAR_SERVER defaults to sandbox outside production. With NODE_ENV=production and PAYMENT_PROVIDER=polar it must be set explicitly (sandbox or production) — the server refuses to boot otherwise, so a forgotten variable can't silently send live traffic to the Polar sandbox.

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 price

See Usage metering for the full reference.

Adding a new variable

  1. Edit apps/server/src/bootstrap/env.ts and extend the Zod schema. Required vars must .min(1); optional ones use .optional() or .default(...).
  2. Add a documented entry in apps/server/.env.example.
  3. Read it from env.YOUR_VAR — never from process.env directly.

On this page