Docs / Deployment / Vercel deploy
Vercel deploy
Keel deploys to Vercel with no special configuration, it's a stock Next.js app. What takes a minute is getting the env vars, the production database, and each provider's webhook pointed at the right place. These are the steps.
Connect the repo
Push your fork to GitHub, then in the Vercel dashboard pick Add New, Project and import it. Vercel detects Next.js and sets the build command (next build) and output on its own, leave those defaults alone.
Set the env vars
Add your variables under Settings, Environment Variables before the first deploy. The full list and what each one unlocks is in Environment variables, don't copy your .env.local blindly, a few need production values:
DATABASE_URL, point at your production Postgres, not the local one. Include?sslmode=requirefor hosted providers like Neon or Supabase.BETTER_AUTH_URL, set to your real deployed origin (e.g.https://yourdomain.com), no trailing slash. This is the CSRF origin auth checks against, a wrong value breaks every sign-in.BETTER_AUTH_SECRET, generate a fresh one for production withnpx @better-auth/cli secret, don't reuse the dev secret.NEXT_PUBLIC_SITE_URL, your public domain, used for sitemap, OG images, and canonical links.- Each OAuth pair (
GOOGLE_*,GITHUB_*), each billing provider's keys, and every webhook signing secret need their production values, the test/sandbox keys won't work against live traffic.
Update your OAuth redirect URIs
Google and GitHub OAuth apps only accept redirects to registered URLs. Add {BETTER_AUTH_URL}/api/auth/callback/google (and /github) to each provider's allowed list, or production sign-in fails with a redirect mismatch.
Run migrations against production
Vercel's build doesn't touch your database. Apply the schema yourself, pointing DATABASE_URL at production:
DATABASE_URL="your-production-url" bun run db:migrate
Use db:migrate, not db:push, for production, see memory/conventions.md for why. Re-run it after any deploy that adds a migration.
Point webhooks at the deployed URL
Only your selected BILLING_PROVIDER sells, but each provider's webhook route is always live at its own path. Register the one you're using in that provider's dashboard, pointed at your deployed domain:
- Stripe,
{BETTER_AUTH_URL}/api/webhooks/stripe - LemonSqueezy,
{BETTER_AUTH_URL}/api/webhooks/lemonsqueezy - Polar,
{BETTER_AUTH_URL}/api/webhooks/polar
Copy the signing secret each dashboard gives you back into the matching *_WEBHOOK_SECRET env var, then redeploy so it takes effect. Without the correct secret the route rejects every event as unsigned.