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

Before You Read

Three templates ship in the repo. Whichever you start from, the keys mean the same thing.

TemplateCopy toWhat it's for
apps/web/.env.exampleapps/web/.envLocal dev and production. The canonical surface.
apps/web/.env.test.exampleapps/web/.env.testCI 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() and requireServerEnv(name) from @syntaxkit/shared. They return resolved, validated values (URL/email/secret-length checks, the BETTER_AUTH_URLNEXT_PUBLIC_APP_URL fallback). requireServerEnv throws when a value is missing or invalid. Used in server code (routers, S3/Prisma/PostHog/Plunk/Redis clients, auth providers).
  • Client seam: publicEnv and isAnalyticsEnabled() from @syntaxkit/shared/client. Captures each NEXT_PUBLIC_* via a literal process.env reference so Next.js still inlines it, then derives client capability flags. apps/web/lib/env.ts re-exports it as env.

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.

VariableRequiredWhat it controls
DATABASE_URLAlwaysPostgres connection string. Driver adapter is auto-selected (Neon serverless for *.neon.tech, @prisma/adapter-pg elsewhere).
NEXT_PUBLIC_APP_URLAlwaysCanonical app origin. Drives metadata, OG image, CSP, CORS, OAuth callbacks, and sitemap entries.
BETTER_AUTH_SECRETAlwaysSession cookie encryption. Generate with openssl rand -base64 32; minimum 32 characters.

See Database and Authentication for deeper context.

Email

VariableRequiredWhat it controls
EMAIL_DELIVERY_MODEOptionalLocal-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_KEYWhen mode is plunkAPI key for Plunk's modern REST API (next-api.useplunk.com). Also requires EMAIL_FROM on a verified Plunk domain.
RESEND_API_KEYWhen mode is resendAPI key for Resend.
POSTMARK_SERVER_TOKENWhen mode is postmarkServer token for Postmark.
BREVO_API_KEYWhen mode is brevoAPI key for Brevo.
SENDGRID_API_KEYWhen mode is sendgridAPI key for SendGrid.
SMTP_HOST, SMTP_USER, SMTP_PASSWhen mode is smtpSMTP server hostname and credentials, used by the Nodemailer transport. All three are required for smtp.
SMTP_PORTOptional (smtp)SMTP port. Defaults to 587.
SMTP_SECUREOptional (smtp)"true"/"false" to force implicit TLS. Defaults to true on port 465, otherwise false (STARTTLS).
EMAIL_FROMWhen 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_EMAILContact form enabledRecipient address for public contact-form submissions.
EMAIL_OUTBOX_DIROptionalOverride 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.

VariableRequiredWhat it controls
GITHUB_CLIENT_IDGitHub OAuth enabledClient id from github.com/settings/developers.
GITHUB_CLIENT_SECRETGitHub OAuth enabledMatching app secret.
GOOGLE_CLIENT_IDGoogle OAuth enabledClient id from console.cloud.google.com/apis/credentials.
GOOGLE_CLIENT_SECRETGoogle OAuth enabledMatching client secret.

Callback URLs must point at <NEXT_PUBLIC_APP_URL>/api/auth/callback/<provider>. See Authentication.

Captcha (Cloudflare Turnstile)

VariableRequiredWhat it controls
TURNSTILE_SECRET_KEYCaptcha enabledServer-side secret used by Better Auth's captcha plugin and verifyTurnstileToken in the contact form.
NEXT_PUBLIC_TURNSTILE_SITE_KEYCaptcha enabledPublic 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

VariableRequiredWhat it controls
STRIPE_SECRET_KEYBilling enabledServer-side Stripe API key (sk_live_* in production, sk_test_* elsewhere).
STRIPE_WEBHOOK_SECRETBilling enabledVerifies Stripe webhook signatures. The production secret differs from the dev / Stripe-CLI secret.
STRIPE_PRICE_ID_PRO_MONTHLYBilling enabledServer-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_YEARLYBilling enabledServer-only Stripe price id for the yearly Pro plan.
STRIPE_AUTOMATIC_TAXOptionalSet 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_REQUIREDOptionalOpt-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).

VariableRequiredWhat it controls
NEXT_PUBLIC_POSTHOG_KEYAnalytics enabledPostHog project API key.
NEXT_PUBLIC_POSTHOG_HOSTAnalytics enabledIngest host (e.g. https://us.i.posthog.com).
NEXT_PUBLIC_POSTHOG_UI_HOSTAnalytics enabledUI host for session-replay deep links.
POSTHOG_PROXY_INGEST_HOSTOptionalReverse-proxy target for /ingest/*. Set both proxy hosts to route browser traffic through your origin.
POSTHOG_PROXY_ASSET_HOSTOptionalReverse-proxy target for /ingest/static/*.
POSTHOG_API_KEYSource-map uploadPersonal API key with project scope. Read at build time by withPostHogConfig.
POSTHOG_PROJECT_IDSource-map uploadPostHog project id. Build-time only.

See Analytics and Monitoring.

Object Storage (S3)

VariableRequiredWhat it controls
NEXT_PUBLIC_S3_PUBLIC_URLStorage enabledPublic base URL used to compose served image URLs.
NEXT_PUBLIC_S3_BUCKET_NAME_IMAGESStorage enabledBucket the kit reads and writes.
NEXT_PUBLIC_IMAGE_HOST_ALLOWLISTOptionalComma-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_S3Non-AWS providersCustom endpoint for Cloudflare R2, MinIO, etc. Leave blank for native AWS S3.
AWS_REGIONStorage enabledRegion passed to the SDK. Defaults to "auto"; required by AWS S3.
AWS_ACCESS_KEY_IDStorage enabledCredential for signing presigned URLs and finalize PUTs.
AWS_SECRET_ACCESS_KEYStorage enabledPaired secret for the access key.
AWS_S3_FORCE_PATH_STYLEMinIO and similarSet 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)

VariableRequiredWhat it controls
UPSTASH_REDIS_REST_URLYes in productionUpstash Redis REST URL. Both vars must be set together.
UPSTASH_REDIS_REST_TOKENYes in productionUpstash 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.

VariableRequiredWhat it controls
BETTER_AUTH_URLOptionalOverride the auto-derived auth origin. Most deployments leave it unset; Better Auth derives the base URL from NEXT_PUBLIC_APP_URL.
NEXT_PUBLIC_DOCS_URLOptionalPublic docs URL. Marketing footer links and dashboard help links use it.
TRUST_PROXY_HEADERSBehind a trusted proxySet 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_HEADERSBehind a trusted proxyComma-separated list of header names to trust (e.g. fly-client-ip, cf-connecting-ip, x-forwarded-for).
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYMulti-instance deployments32+ 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.

VariableSet inWhat it controls
DISABLE_ABUSE_PROTECTIONapps/web/.env.test, PlaywrightBypasses the Upstash abuse policy across every protected surface. Hard-blocked at boot in production.
DISABLE_CAPTCHA_FOR_TESTS / NEXT_PUBLIC_DISABLE_CAPTCHA_FOR_TESTSapps/web/.env.test, PlaywrightBypasses server-side Turnstile verification and the client widget so CI never waits on test-key roundtrips. Hard-blocked at boot in production.
RUN_STRIPE_LIVEStripe live test suiteSet 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

On this page