Environment Variables
Every variable, where it is used, and what it controls.
Every variable the kit reads, grouped by purpose. A fresh clone runs with just three set (DATABASE_URL, NEXT_PUBLIC_APP_URL, and BETTER_AUTH_SECRET), and every optional group degrades cleanly when left blank (auth-only, billing-off, no-storage, no-analytics).
Env templates
The three .env templates that ship in the repo and where each one belongs.
Required vars
The minimum surface every deploy needs to boot.
How env is read
The two validated seams app code goes through instead of process.env.
Test and CI escapes
Determinism flags that are hard-blocked in production.
Before You Read
Three templates ship in the repo. Whichever you start from, the keys mean the same thing.
| Template | Copy to | What it's for |
|---|---|---|
apps/web/.env.example | apps/web/.env | Local dev and production. The canonical surface. |
apps/web/.env.test.example | apps/web/.env.test | CI and Playwright. Includes the test escapes documented at the bottom. |
.env.docker.example | .env (repo root) | Self-hosting via docker-compose.yml. Adds NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for multi-instance deploys. |
packages/database/.env.example is a Prisma-CLI shim that points at apps/web/.env. Leave it alone.
Build-Time vs Runtime
Every NEXT_PUBLIC_* var is inlined into the JS bundle at build time, so changing one needs a rebuild on every host. Everything else is read at runtime. See Deployment build-time vs runtime env for the per-host guide.
How the Kit Reads Env
App code never reads process.env.<NAME> for these variables directly. It goes through two seams, both backed by the validated catalog in packages/shared/src/setup (the single source of truth):
- Server seam:
getServerEnv()andrequireServerEnv(name)from@syntaxkit/shared. They return resolved, validated values (URL/email/secret-length checks, theBETTER_AUTH_URL→NEXT_PUBLIC_APP_URLfallback).requireServerEnvthrows when a value is missing or invalid. Used in server code (routers, S3/Prisma/PostHog/Plunk/Redis clients, auth providers). - Client seam:
publicEnvandisAnalyticsEnabled()from@syntaxkit/shared/client. Captures eachNEXT_PUBLIC_*via a literalprocess.envreference so Next.js still inlines it, then derives client capability flags.apps/web/lib/env.tsre-exports it asenv.
The boot-time validation in apps/web/lib/env.server.ts still catches a misconfigured production deploy — but rather than crashing the process, it renders a branded "Configuration error" page (via the ConfigErrorGuard in the root layout) that names the offending vars, and makes /api/health return HTTP 503 so a health-gated rollout rolls the deploy back. An ESLint rule (no-restricted-syntax in @syntaxkit/eslint-config) blocks new direct process.env reads of the validated server vars so the seam can't be bypassed again — operational vars (NODE_ENV, NEXT_RUNTIME, ...) and NEXT_PUBLIC_* literals remain allowed.
Required For Any Deploy
The minimum surface every deploy needs.
| Variable | Required | What it controls |
|---|---|---|
DATABASE_URL | Always | Postgres connection string. Driver adapter is auto-selected (Neon serverless for *.neon.tech, @prisma/adapter-pg elsewhere). |
NEXT_PUBLIC_APP_URL | Always | Canonical app origin. Drives metadata, OG image, CSP, CORS, OAuth callbacks, and sitemap entries. |
BETTER_AUTH_SECRET | Always | Session cookie encryption. Generate with openssl rand -base64 32; minimum 32 characters. |
See Database and Authentication for deeper context.
| Variable | Required | What it controls |
|---|---|---|
EMAIL_DELIVERY_MODE | Optional | Local-only log (default in dev) or noop (CI), or a real provider: plunk, resend, postmark, brevo, sendgrid, smtp. When unset it auto-resolves from whichever single provider credential is set, else noop when NODE_ENV=test, otherwise log. Setting more than one provider without an explicit mode is a boot error. Production rejects noop and log — the boot doctor treats it as a fatal misconfiguration (Configuration error page + /api/health 503) instead of serving normally. Both modes report success without delivering, which would silently drop signups, password resets, and invitations. |
PLUNK_API_KEY | When mode is plunk | API key for Plunk's modern REST API (next-api.useplunk.com). Also requires EMAIL_FROM on a verified Plunk domain. |
RESEND_API_KEY | When mode is resend | API key for Resend. |
POSTMARK_SERVER_TOKEN | When mode is postmark | Server token for Postmark. |
BREVO_API_KEY | When mode is brevo | API key for Brevo. |
SENDGRID_API_KEY | When mode is sendgrid | API key for SendGrid. |
SMTP_HOST, SMTP_USER, SMTP_PASS | When mode is smtp | SMTP server hostname and credentials, used by the Nodemailer transport. All three are required for smtp. |
SMTP_PORT | Optional (smtp) | SMTP port. Defaults to 587. |
SMTP_SECURE | Optional (smtp) | "true"/"false" to force implicit TLS. Defaults to true on port 465, otherwise false (STARTTLS). |
EMAIL_FROM | When mode is any real provider (plunk, resend, postmark, brevo, sendgrid, smtp) | Provider-agnostic sender identity. Accepts a bare address (noreply@you.com) or the friendly Name <email@domain> form. |
CONTACT_FORM_TO_EMAIL | Contact form enabled | Recipient address for public contact-form submissions. |
EMAIL_OUTBOX_DIR | Optional | Override for the log-mode outbox directory. Defaults to .local/email-outbox/. |
See Email for delivery modes, templates, and the swap-providers walkthrough.
OAuth
Both halves of a provider pair must be set together. Missing either disables that provider gracefully: the button still renders, but disabled.
| Variable | Required | What it controls |
|---|---|---|
GITHUB_CLIENT_ID | GitHub OAuth enabled | Client id from github.com/settings/developers. |
GITHUB_CLIENT_SECRET | GitHub OAuth enabled | Matching app secret. |
GOOGLE_CLIENT_ID | Google OAuth enabled | Client id from console.cloud.google.com/apis/credentials. |
GOOGLE_CLIENT_SECRET | Google OAuth enabled | Matching client secret. |
Callback URLs must point at <NEXT_PUBLIC_APP_URL>/api/auth/callback/<provider>. See Authentication.
Captcha (Cloudflare Turnstile)
| Variable | Required | What it controls |
|---|---|---|
TURNSTILE_SECRET_KEY | Captcha enabled | Server-side secret used by Better Auth's captcha plugin and verifyTurnstileToken in the contact form. |
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Captcha enabled | Public site key for the browser widget. |
Both must be set; either missing disables the captcha plugin and the contact-form check gracefully. See Security.
Stripe Billing
| Variable | Required | What it controls |
|---|---|---|
STRIPE_SECRET_KEY | Billing enabled | Server-side Stripe API key (sk_live_* in production, sk_test_* elsewhere). |
STRIPE_WEBHOOK_SECRET | Billing enabled | Verifies Stripe webhook signatures. The production secret differs from the dev / Stripe-CLI secret. |
STRIPE_PRICE_ID_PRO_MONTHLY | Billing enabled | Server-only Stripe price id for the monthly Pro plan. If Stripe is enabled and either price id is missing, the kit treats it as a fatal config error — it serves the Configuration error page and /api/health returns 503 instead of booting normally. |
STRIPE_PRICE_ID_PRO_YEARLY | Billing enabled | Server-only Stripe price id for the yearly Pro plan. |
STRIPE_AUTOMATIC_TAX | Optional | Set to "true" to enable Stripe Tax on Checkout: automatic_tax, tax_id_collection, customer_update, and a required billing address. Requires Stripe Tax activated with at least one tax registration in the Dashboard (Settings -> Tax); leave unset otherwise or Checkout sessions fail. |
BILLING_REQUIRED | Optional | Opt-in (default off). When set to "true", pnpm setup:doctor treats missing or partial billing as a hard problem (non-zero exit) instead of "disabled intentionally". Use it on a paid / paywall deploy so a forgotten Stripe config fails the doctor instead of silently booting free-only. Read by the doctor; the app itself does not read it. |
See Billing for the plan catalog and Webhooks And Async Workflows for the inbound contract.
PostHog Analytics And Monitoring
The first three activate analytics, error tracking, and structured logs. Missing any one disables the integration cleanly. The rest enable optional features (reverse proxy, source-map upload).
| Variable | Required | What it controls |
|---|---|---|
NEXT_PUBLIC_POSTHOG_KEY | Analytics enabled | PostHog project API key. |
NEXT_PUBLIC_POSTHOG_HOST | Analytics enabled | Ingest host (e.g. https://us.i.posthog.com). |
NEXT_PUBLIC_POSTHOG_UI_HOST | Analytics enabled | UI host for session-replay deep links. |
POSTHOG_PROXY_INGEST_HOST | Optional | Reverse-proxy target for /ingest/*. Set both proxy hosts to route browser traffic through your origin. |
POSTHOG_PROXY_ASSET_HOST | Optional | Reverse-proxy target for /ingest/static/*. |
POSTHOG_API_KEY | Source-map upload | Personal API key with project scope. Read at build time by withPostHogConfig. |
POSTHOG_PROJECT_ID | Source-map upload | PostHog project id. Build-time only. |
See Analytics and Monitoring.
Object Storage (S3)
| Variable | Required | What it controls |
|---|---|---|
NEXT_PUBLIC_S3_PUBLIC_URL | Storage enabled | Public base URL used to compose served image URLs. |
NEXT_PUBLIC_S3_BUCKET_NAME_IMAGES | Storage enabled | Bucket the kit reads and writes. |
NEXT_PUBLIC_IMAGE_HOST_ALLOWLIST | Optional | Comma-separated extra hostnames accepted on user-supplied image URLs (org logos, avatars). Hosts from NEXT_PUBLIC_S3_PUBLIC_URL / AWS_ENDPOINT_URL_S3 plus built-in defaults (DiceBear, GitHub/Google/Gravatar) are always allowed; this is the escape hatch for extra CDN domains. |
AWS_ENDPOINT_URL_S3 | Non-AWS providers | Custom endpoint for Cloudflare R2, MinIO, etc. Leave blank for native AWS S3. |
AWS_REGION | Storage enabled | Region passed to the SDK. Defaults to "auto"; required by AWS S3. |
AWS_ACCESS_KEY_ID | Storage enabled | Credential for signing presigned URLs and finalize PUTs. |
AWS_SECRET_ACCESS_KEY | Storage enabled | Paired secret for the access key. |
AWS_S3_FORCE_PATH_STYLE | MinIO and similar | Set to "true" for path-style endpoints. AWS S3 and R2 use the default (false). |
See Storage for the upload pipeline, bucket config, and provider tabs.
Abuse Protection (Upstash Redis)
| Variable | Required | What it controls |
|---|---|---|
UPSTASH_REDIS_REST_URL | Yes in production | Upstash Redis REST URL. Both vars must be set together. |
UPSTASH_REDIS_REST_TOKEN | Yes in production | Upstash Redis REST token. |
Production won't boot without both; assertValidSetupEnv errors out otherwise. Non-production auto-bypasses missing values (one-time dev_no_upstash warning) so pnpm dev just works.
What happens when abuse protection is unavailable in production?
Every protected surface (uploads, chat, auth emails, and the contact form) fails closed with SERVICE_UNAVAILABLE. See Security for the full posture matrix and the DISABLE_ABUSE_PROTECTION non-production escape hatch.
Operational
Things that don't fit a single subsystem.
| Variable | Required | What it controls |
|---|---|---|
BETTER_AUTH_URL | Optional | Override the auto-derived auth origin. Most deployments leave it unset; Better Auth derives the base URL from NEXT_PUBLIC_APP_URL. |
NEXT_PUBLIC_DOCS_URL | Optional | Public docs URL. Marketing footer links and dashboard help links use it. |
TRUST_PROXY_HEADERS | Behind a trusted proxy | Set to "true" so Better Auth reads the real client IP from forwarded headers (needed for per-IP auth rate limits). Fly forwards the client IP (X-Forwarded-For / Fly-Client-IP), so the kit's fly.toml defaults this on; Render's blueprint sets it too. Vercel handles the client IP itself. Only enable it behind a proxy you control — the headers are client-spoofable. |
TRUSTED_PROXY_IP_HEADERS | Behind a trusted proxy | Comma-separated list of header names to trust (e.g. fly-client-ip, cf-connecting-ip, x-forwarded-for). |
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY | Multi-instance deployments | 32+ random bytes (openssl rand -base64 32) shared across replicas so server-action signatures decrypt across hosts. |
See Security operational secrets and Deployment for per-host placement.
Test And CI Escapes
Never set these in production. They exist for deterministic test runs only. The Security pre-launch checklist verifies each one before launch.
| Variable | Set in | What it controls |
|---|---|---|
DISABLE_ABUSE_PROTECTION | apps/web/.env.test, Playwright | Bypasses the Upstash abuse policy across every protected surface. Hard-blocked at boot in production. |
DISABLE_CAPTCHA_FOR_TESTS / NEXT_PUBLIC_DISABLE_CAPTCHA_FOR_TESTS | apps/web/.env.test, Playwright | Bypasses server-side Turnstile verification and the client widget so CI never waits on test-key roundtrips. Hard-blocked at boot in production. |
RUN_STRIPE_LIVE | Stripe live test suite | Set to "1" to opt in to pnpm test:stripe against the real Stripe test-mode API. Off ("0") by default. |
Better Auth's per-route rate limits also widen for tests, but with no env var: the widening is gated on NODE_ENV === "test" directly in packages/auth/src/server.ts, so production can never disable them.
There is no env var to bypass server-side validation at runtime. next build is detected via Next.js's PHASE_PRODUCTION_BUILD constant from next/constants (process.env.NEXT_PHASE === "phase-production-build"), and Vitest/Playwright via NODE_ENV === "test", so build pipelines and test runs work without a flag — but a production server always boots through full validation. On misconfiguration it does not crash: it serves the "Configuration error" page instead of the app and returns 503 from /api/health, so a health-gated rollout (Fly, Render) rolls the bad deploy back.
Where To Go Next
Setup
The env-driven first-run walkthrough that validates this whole list with pnpm setup:doctor.
Deployment
Build-time vs runtime split per host, plus per-platform setup for the operational vars.
Security
How each env-gated protection fits the abuse, signature, and session stack.
Going To Production
The pre-launch checklist that ticks every env-gated category, including the OAuth callback audit.
Commands And Scripts
setup:doctor and the auth:generate / admin:bootstrap scripts that consume this surface.