Docs / Getting Started / Environment variables
Environment variables
Everything Keel talks to is configured through .env.local, copied from .env.example. Almost every variable is feature-gated: a missing key disables one feature and fails clearly at the point you use it. The exceptions are the database and auth vars below, which lib/env.ts checks when the server starts, so a production deploy missing them fails at boot with a list of what's wrong instead of breaking sign-ins later. Here's what each group unlocks and what happens when you leave it blank.
Database
DATABASE_URL, your Postgres connection string. This is the one variable you actually need. Without it the migrations, seed, and every page that touches the database fail. Works with Neon, Supabase, Railway, or local Postgres, Keel usesdrizzle-orm/postgres-js, not a provider-specific driver.
Auth (Better Auth)
BETTER_AUTH_SECRET, generate one withnpx @better-auth/cli secret. Sessions can't be signed without it. Required in production, at least 32 characters.BETTER_AUTH_URL, leave it ashttp://localhost:3000in dev. It's the origin for the CSRF check, so running dev on a different port breaks every auth call. Required in production, set it to your public app URL so auth emails never build links from the request's Host header.
Tip, start with the database only
As the Setup page notes, auth and AI features stay disabled until their keys are set. You can see the whole app render with just DATABASE_URL and the two auth vars filled in.
OAuth providers
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETandGITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET. Both vars in a pair are required to enable that provider, either pair can stay blank to leave it disabled. The sign-in button for an unconfigured provider simply doesn't appear. Register the redirect URI{BETTER_AUTH_URL}/api/auth/callback/google(or/github) with the provider.
Email (Resend)
RESEND_API_KEY, without it every transactional email (verify, reset, magic link, team invite, welcome) logs to the console instead of sending. Get a key at resend.com, it works immediately with no domain setup.RESEND_FROM_EMAIL, the from address. This is one of the vars that must fall back with||, not??: a blankRESEND_FROM_EMAIL=line is a defined empty string, and??would pass it straight through to Resend, which rejects an empty from address with a confusing "domain is invalid" error. Leave it unset to use Resend's shared test domain (delivers only to your own address), set it once you've verified your own domain.CONTACT_INBOX_EMAIL, where the marketing contact form (app/api/contact/route.ts) delivers. In dev, without it (or withoutRESEND_API_KEY) submissions just log. In production the form returns an error instead, so a visitor is never told a message was sent when it went nowhere.
Billing (Stripe / Polar / LemonSqueezy)
Keel ships three swappable providers behind one typed interface in lib/billing/. Pick exactly one with BILLING_PROVIDER (stripe, lemonsqueezy, or polar, defaults to stripe). Only the selected provider's checkout and portal calls run, though every provider's webhook route stays live at its own URL.
- Stripe,
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRET. Needs zero dashboard setup, prices are built inline at checkout (seelib/billing/plans.ts). Register the webhook at{BETTER_AUTH_URL}/api/webhooks/stripe, or runstripe listenin dev, to get the signing secret. - LemonSqueezy,
LEMONSQUEEZY_API_KEY,LEMONSQUEEZY_STORE_ID,LEMONSQUEEZY_WEBHOOK_SECRET, plus aLEMONSQUEEZY_VARIANT_*id for every plan, interval, and credit pack. It has no inline pricing, so each variant must exist in your store first. - Polar,
POLAR_ACCESS_TOKEN,POLAR_WEBHOOK_SECRET,POLAR_MODE(sandboxby default), plus aPOLAR_PRODUCT_*id per plan, interval, and credit pack, created in the Polar dashboard first.
Leave billing unconfigured and checkout fails with a clear error, the rest of the app is unaffected.
AI providers
ANTHROPIC_API_KEY/OPENAI_API_KEY, set either one to enable real replies in the AI chat demo (/app/ai). Without a key the chat shows a clear "isn't configured yet" error and charges no credits, rather than faking a reply.AI_PROVIDER(anthropicoropenai) only matters when both keys are set, otherwise whichever key exists wins.ANTHROPIC_MODELandOPENAI_MODELpick the model per provider.
Rate limiting (Upstash)
UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN, optional. They protect paid-per-request routes like the AI chat from being burst-hit. Without them rate limiting silently no-ops, except on the public contact form, which falls back to a per-instance in-memory limit. Use the REST database from upstash.com, not the ioredis one.
File uploads (uploadthing)
UPLOADTHING_TOKEN, gates avatar and team-logo uploads. Without it the upload buttons still render but each click fails with a clear error. This is the v7 single-token format, copied from your uploadthing app's dashboard.
Site URL
NEXT_PUBLIC_SITE_URL, used for the sitemap, OG images, and canonical links. In dev it falls back tohttp://localhost:3000automatically (seelib/seo.ts, another||fallback). Set it to your real domain in production.
Marketing site
All NEXT_PUBLIC_*, so they're inlined at build time, redeploy after changing one. See lib/site.ts.
NEXT_PUBLIC_SUPPORT_EMAIL, the address shown on/contact,/docs, and in the contact form's error message. Defaults tosupport@plus yourNEXT_PUBLIC_SITE_URLdomain.NEXT_PUBLIC_DOCS_REPO_URL, a public GitHub repo URL. Turns on "Edit this page on GitHub" under each doc and the "Open an issue" card on/contact. Leave it blank for a private repo and both stay hidden.NEXT_PUBLIC_KEEL_STORE_SUBDOMAINplusNEXT_PUBLIC_KEEL_SOLO_VARIANT_ID,NEXT_PUBLIC_KEEL_STUDIO_VARIANT_ID,NEXT_PUBLIC_KEEL_AGENCY_VARIANT_ID, the LemonSqueezy store and variants behind the one-time purchase buttons on/pricing. These are separate fromBILLING_PROVIDER, which is for your app's own subscriptions. A tier missing either value shows "Contact us" instead of a checkout link that would 404.