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-AI, no-storage, no-analytics). Billing is provider-scoped: BILLING_PROVIDER picks stripe, polar, or creem, and only that provider's variables are required.
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, the email client, Redis, 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.
Billing
Exactly one billing provider is active per deployment (ADR 0006). Only the active provider's group below is required; the other group can stay blank, and setting it anyway produces a doctor warning, not an error.
| Variable | Required | What it controls |
|---|---|---|
BILLING_PROVIDER | Optional | stripe (the default when unset), polar, or creem. Selects which provider handles checkout, portal, webhooks, reconciliation, and the price check. Any other value is a fatal setup error (BILLING_PROVIDER must be one of: stripe, polar, creem.). Changing it on an installation with live subscriptions does not migrate them; those rows resolve to billing_provider_mismatch. Creem seats are not advertised. |
BILLING_REQUIRED | Optional | Opt-in (default off). When set to "true", pnpm setup:doctor treats missing or partial billing for the active provider as a hard problem (non-zero exit) instead of "disabled intentionally". Use it on a paid / paywall deploy so a forgotten provider config fails the doctor instead of silently booting free-only. Read by the doctor; the app itself does not read it. |
Stripe (BILLING_PROVIDER unset or stripe)
| 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 the active provider and any catalog-declared 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_PRICE_ID_ULTRA_MONTHLY | Billing enabled | Server-only Stripe price id for the monthly Ultra plan. |
STRIPE_PRICE_ID_ULTRA_YEARLY | Billing enabled | Server-only Stripe price id for the yearly Ultra plan. |
STRIPE_PRICE_ID_ENTERPRISE_YEARLY | Optional | Server-only Stripe price id for the yearly Enterprise plan. Leave unset and Enterprise falls back to Contact sales. |
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. |
Polar (BILLING_PROVIDER=polar)
| Variable | Required | What it controls |
|---|---|---|
POLAR_ACCESS_TOKEN | Billing enabled | Organization access token from the Polar dashboard (Settings -> Developers). Sandbox and production tokens are separate and not interchangeable. |
POLAR_WEBHOOK_SECRET | Billing enabled | Signing secret of the webhook endpoint you registered for /api/webhooks/polar. Shown once when the endpoint is created. Both Polar secret encodings are accepted. |
POLAR_SERVER | Optional | sandbox or production. Defaults to production when unset, so sandbox is the explicit opt-in. sandbox in a production build is a doctor warning; any other value is a fatal setup error (POLAR_SERVER must be one of: sandbox, production.). |
POLAR_PRODUCT_ID_PRO_MONTHLY | Billing enabled | Server-only Polar product id (UUID) for the monthly Pro plan. Polar models one billing interval per product, so each catalog price maps to its own product. A missing id for the active provider is a fatal config error, as with Stripe. |
POLAR_PRODUCT_ID_PRO_YEARLY | Billing enabled | Server-only Polar product id for the yearly Pro plan. |
POLAR_PRODUCT_ID_ULTRA_MONTHLY | Billing enabled | Server-only Polar product id for the monthly Ultra plan. |
POLAR_PRODUCT_ID_ULTRA_YEARLY | Billing enabled | Server-only Polar product id for the yearly Ultra plan. |
POLAR_PRODUCT_ID_ENTERPRISE_YEARLY | Optional | Server-only Polar product id for the yearly Enterprise plan. Leave unset and Enterprise falls back to Contact sales. |
The STRIPE_PRICE_ID_* and POLAR_PRODUCT_ID_* names come from each price's stripePriceEnv / polarProductEnv in packages/payments/src/catalog/declaration.ts; add a plan there and its variables are validated automatically. See Billing for the provider choice and plan catalog, and Webhooks And Async Workflows for the inbound contract.
AI Chat (Vercel AI Gateway)
| Variable | Required | What it controls |
|---|---|---|
AI_GATEWAY_API_KEY | AI chat enabled | Server-only Vercel AI Gateway key used by the AI SDK gateway() adapter. When omitted, pnpm setup:doctor reports AI chat as disabled without making setup invalid. |
See AI for model routing and plan gates.
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 | PUBLIC bucket for avatars and organization logos. Served by URL. |
S3_BUCKET_NAME_ATTACHMENTS | Chat image attachments | Server-only (deliberately not NEXT_PUBLIC_). A SEPARATE PRIVATE bucket with block-all-public-access on, holding chat image attachments, which are served only via short-lived signed GET URLs. Reuses the same AWS_* credentials and endpoint. Leave it unset and chat image uploads stay disabled (the setup doctor warns); the kit never falls back to the public bucket. See Storage: Required Bucket Configuration. |
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). |
SYNTAXKIT_NOINDEX | Optional | Set to "true" to keep the whole deployment out of search results: every page response carries X-Robots-Tag: noindex. Use it for staging, previews, and public demos. Read at runtime by the proxy on each request, so it takes effect on restart without a rebuild, and removing it from a reused image makes the pages indexable again. The page HTML keeps its normal robots meta, since prerendered markup cannot change per request; search engines follow the stricter header. Only "true" turns it on; pnpm setup:doctor warns about other values such as 1 or yes. robots.txt stays crawlable on purpose, because a crawler has to fetch a page to see its noindex. |
NEXT_PUBLIC_DEMO_BANNER_URL | Optional | An absolute http(s) URL. When set, a small dismissible notice floats at the bottom of every public page (fixed-positioned, so it never shifts the layout or covers the sticky header). It reads "You're exploring the SyntaxKit demo" (with your brand name from @syntaxkit/brand) and links here, for example to your pricing page. Build-time: redeploy after changing it. Not yet passed through the Docker build args, so it applies to Vercel and other next build hosts. Anything that isn't an absolute http(s) URL hides the notice, and pnpm setup:doctor warns about it. It stays off dashboard routes. |
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. |
SYNTAXKIT_COOKIE_CONSENT | Optional | Who sees the analytics consent banner before PostHog loads in the browser. geo (default) asks everyone except visitors from countries known not to require prior consent; an unknown country is asked. always asks everyone. never asks no one (the doctor warns in production). Any other value is a setup error, and the proxy treats it as always. Read at runtime by the proxy. See Consent banner. |
SYNTAXKIT_GEO_COUNTRY | Optional | Two-letter country code used when no Vercel, Cloudflare, or x-syntaxkit-country header names the visitor's country. Without it, a host with no geo headers asks every visitor. Read at runtime by the proxy. |
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. |
RUN_POLAR_LIVE | Polar live test | Set to "1" to opt in to the Polar sandbox contract test in packages/payments (pnpm --filter @syntaxkit/payments test:live). Also requires POLAR_SERVER=sandbox, a real sandbox POLAR_ACCESS_TOKEN, and POLAR_PRODUCT_ID_PRO_MONTHLY; the test refuses to run against production. |
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.