Environment
Required and optional env vars. The schema in apps/server/src/bootstrap/env.ts is the source of truth.
Last updated Oct 27, 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: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).
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 upstreamPOST /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 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=
POLAR_SERVER=sandbox # or production[!WARNING]
POLAR_SERVERdefaults tosandboxoutside production. WithNODE_ENV=productionandPAYMENT_PROVIDER=polarit must be set explicitly (sandboxorproduction) — 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 priceSee Usage metering for the full reference.
Crypto payments (optional)
The crypto-payments module (Aliquot) boots without any of these. Until CRYPTO_MASTER_MNEMONIC is set, every /api/v1/crypto/* and /api/v1/public/* endpoint answers 503 CRYPTO_NOT_CONFIGURED; the rest of the server is unaffected.
CRYPTO_MASTER_MNEMONIC="<BIP-39 mnemonic>" # custody; unset → crypto endpoints answer 503
CRYPTO_RPC_POLYGON_AMOY=https://polygon-amoy-bor-rpc.publicnode.com,https://polygon-amoy.drpc.org
CRYPTO_RPC_BSC_TESTNET=https://bsc-testnet-rpc.publicnode.com
CRYPTO_RPC_BASE_SEPOLIA=https://sepolia.base.org,https://base-sepolia-rpc.publicnode.com
# CRYPTO_RPC_POLYGON_MAINNET= / CRYPTO_RPC_BSC_MAINNET= / CRYPTO_RPC_BASE_MAINNET=
# CRYPTO_CONFIRMATIONS_POLYGON_AMOY=5 # dev-only override (defaults: Polygon 64, BSC 15, Base 30)
CRYPTO_PLATFORM_FEE_BPS=50 # default processing fee (0.50 %) for orgs without their own config
CRYPTO_PAYOUT_FEE_POLYGON=0.10 # flat token fee per payout, per network family
CRYPTO_PAYOUT_FEE_BSC=0.15
CRYPTO_PAYOUT_FEE_BASE=0.10
# CRYPTO_LOGS_MAX_RANGE_BSC_TESTNET=200 # max blocks per eth_getLogs call (default 200, all 6 networks)
# CRYPTO_NATIVE_PRICE_USD_POLYGON=0.50 # fixed native price for refund fee quotes (BSC 600, BASE 3000)
# CRYPTO_CHECKOUT_BASE_URL=https://pay.example.com # hosted checkout origin; defaults to APP_URL
# TELEGRAM_BOT_TOKEN= # Telegram Mini-App initData verificationCRYPTO_MASTER_MNEMONICis the custody wallet (HD pathm/44'/60'/0'/0/i: index 0 is the hot wallet, invoice deposit addresses start at 1000). The API process derives addresses from the extended public key only; signing happens in the worker. It must be a valid BIP-39 English mnemonic.CRYPTO_RPC_<NETWORK>takes comma-separated URLs (viem fallback, first = primary). A network without URLs is listed as disabled byGET /api/v1/crypto/networksand cannot take invoices. For local chains runbun run chain:devand point these at the printed URLs.NEXT_PUBLIC_CRYPTO_RPC_<NETWORK>(client,apps/client/.env.local, optional, one URL) is the RPC the hosted checkout's "Pay with wallet" button hands to the buyer's wallet when the wallet does not know the network yet (wallet_addEthereumChain). Unset, the checkout uses the network's public RPC. Set it only for local chains, e.g.NEXT_PUBLIC_CRYPTO_RPC_POLYGON_AMOY=http://127.0.0.1:18002, so a browser wallet sends to the same anvil chain the server scans. It is inlined at build time, so rebuild (or restartnext dev) after changing it.NEXT_PUBLIC_SITE_URL(client, optional) is the canonical origin of the public site: metadata, Open Graph, sitemap, robots, JSON-LD andllms.txt. Unset, it ishttps://aliquot.cc, the production domain. Set it for a staging deploy. It is inlined at build time.CRYPTO_CHECKOUT_BASE_URLbuildspay_url(<base>/pay/<invoiceId>), refund claim links (<base>/pay/<invoiceId>/refund/<token>) and payment-link URLs (<base>/l/<slug>).CRYPTO_LOGS_MAX_RANGE_<NETWORK>caps the block range of eacheth_getLogscall (default 200:sepolia.base.orgrefuses more). The scanner also halves any range a provider refuses for size and catches up a backlog in bounded steps, so you rarely need to touch it.CRYPTO_NATIVE_PRICE_USD_<FAMILY>(POLYGON0.50,BSC600,BASE3000) converts the gas of a refund into stablecoin units: the buyer receivesgross − max(gas × max fee × price, 0.01), fixed when they claim.TELEGRAM_BOT_TOKENis the token of the bot that opens the checkout as a Telegram Mini-App (/pay/<invoiceId>?tg=1).POST /api/v1/public/telegram/verifychecks the Mini-AppinitDatasignature with it (HMAC-SHA256, secret = HMAC("WebAppData", token)) and rejects anauth_dateolder than 24 h. Without it that endpoint answers 503TELEGRAM_NOT_CONFIGURED; the checkout itself still works.- The worker (
bun run worker) runs the money path: chain scanner (2 s), expiry (10 s), sweeps (15 s), payouts (3 s), refunds (5 s). It needs Redis; without it nothing is scanned or sent. Native gas sent to the hot wallet (index 0) is recorded automatically as operator funding (EQUITY:OPERATOR_FUNDING).
[!WARNING] With
NODE_ENV=productionthe server refuses to boot with the public development mnemonic (the one committed inapps/server/.envand.env.example), and refusesCRYPTO_CONFIRMATIONS_*overrides for mainnets.
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.