SaaS Starter
Getting Started

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

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=

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.

Seed importer (optional)

Drives the cold-start catalog import. The CLI (bun run seed:servers) works without either variable for FiveM and Minecraft (CSV-based); the Steam-driven games (Rust/CS2/ARK/ARK SA) require an API key.

STEAM_WEB_API_KEY=        # free at https://steamcommunity.com/dev/apikey
SEED_REFRESH_ENABLED=false  # set "true" to install the daily Rust refresh tick

SEED_REFRESH_ENABLED defaults to false because the daily tick exposes the service to upstream-API terms of service. Flip only after the legal review documented in docs/seed-import-legal.md (Plan 7 Task 20).

Owner-dashboard Discord alerts (optional)

Controls the OnServerStatusChange listener that dispatches Discord webhook embeds when an owner's server goes offline or back online.

DISCORD_WEBHOOK_TIMEOUT_MS=5000     # outbound webhook POST timeout
DISCORD_FLAP_DEBOUNCE_MS=300000     # 5-min flap suppression window

Both have sensible defaults; only override if you've observed your Discord webhook latency drifting outside the 5-second budget.

Vote system (Plan 6)

Required for the daily-vote endpoint (POST /api/v1/catalog/servers/:id/vote) to mount. Without these the server fails fast at boot.

VOTE_SALT=...                                              # 32+ random chars; openssl rand -hex 32
TURNSTILE_SITE_KEY=1x00000000000000000000AA                # Cloudflare always-pass test sitekey (dev)
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA   # Cloudflare always-pass test secret (dev)

And on the client:

NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA    # same sitekey, exposed to the browser widget

The Cloudflare test keys above always pass verification — safe to commit as defaults, but production deploys must register a real Turnstile site and rotate them. Rotating VOTE_SALT invalidates one day of voter-dedupe history (acceptable trade-off).

The vote endpoint accepts one vote per voterHash = sha256(ip + UA + VOTE_SALT) per UTC calendar day per server. Returns 200 with {ok, votes30d} on success, 422 on captcha failure, 429 on duplicate, 409 when the server isn't ACTIVE.

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