Deploying
Deploy Launch Now to Vercel with automatic migrations, production Stripe webhooks and OAuth, plus the CI and release workflow.
Launch Now is set up to deploy on Vercel. Each deployment runs the database migrations, then builds the app. This page covers the production setup (environment variables, Stripe, OAuth, email) and the CI and release tooling that ships with the project.
How the build works
vercel.json replaces Vercel's default build command:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"buildCommand": "pnpm build:deploy"
}build:deploy applies pending migrations before building, so the database schema always matches the
deployed code:
"build:deploy": "pnpm db:migrate && next build",
"db:migrate": "NODE_OPTIONS='--no-network-family-autoselection' tsx drizzle/migrate.ts",drizzle/migrate.ts connects with DATABASE_URL and runs Drizzle's migrator on the drizzle/
folder. If DATABASE_URL is not set, it prints a warning and exits successfully, so the build still
runs. If a migration fails, the build fails and the previous deployment stays live.
Migrations run on every deployment, including preview deployments. Give the Preview environment its
own DATABASE_URL (for example a Neon branch), or preview builds will migrate your production
database with unmerged schema changes.
.vercelignore keeps local files out of the upload: .env files (except .env.example), .delta,
.claude, .agents, examples and components/spectrumui.
Deploy to Vercel
Import the repository
Push your project to GitHub, then import it in the Vercel dashboard. Vercel detects Next.js and pnpm;
the build command comes from vercel.json.
Set the environment variables
In Project Settings → Environment Variables, add every required variable from Environment variables. For production:
- Set
NEXT_PUBLIC_APP_URLandBETTER_AUTH_URLto your production URL, for examplehttps://app.example.com, without a trailing slash. - Generate a new
BETTER_AUTH_SECRETwithopenssl rand -base64 32. Do not reuse your local one. - Use your production
DATABASE_URL. - Use live Stripe keys and the production webhook secret (next step).
- Do not set
SKIP_ENV_VALIDATION. - Leave
DEMO_EMAILandDEMO_PASSWORDunset unless you want a public demo account.
Good to know: NEXT_PUBLIC_* variables are inlined at build time. After changing
NEXT_PUBLIC_APP_URL or NEXT_PUBLIC_SUPPORT_EMAIL, redeploy for the change to take effect.
Create the live Stripe products
Run the product script once against your live Stripe account, then copy the printed price IDs into
STRIPE_PRO_PRICE_ID and STRIPE_ULTRA_PRICE_ID in Vercel:
STRIPE_SECRET_KEY=sk_live_... pnpm stripe:productsSeed the plan table of the production database the same way:
DATABASE_URL=postgresql://... pnpm db:seed:plansAdd the Stripe webhook endpoint
In the Stripe dashboard, create a webhook endpoint pointing to:
https://app.example.com/api/webhook/stripeSubscribe it to the events the app handles:
| Event | Handled by |
|---|---|
checkout.session.completed | Better Auth Stripe plugin (creates the subscription). |
customer.subscription.created | Better Auth Stripe plugin. |
customer.subscription.updated | Better Auth Stripe plugin (syncs status and plan). |
customer.subscription.deleted | Better Auth Stripe plugin (cancels the subscription). |
invoice.payment_failed | onEvent in lib/auth.ts (sends a billing notification). |
Copy the endpoint's signing secret into STRIPE_WEBHOOK_SECRET. See Billing.
Configure the OAuth callbacks
Add the production callback URLs to your OAuth apps (or create separate production apps):
https://app.example.com/api/auth/callback/github
https://app.example.com/api/auth/callback/googleThe host must match BETTER_AUTH_URL.
Verify your email domain
Verify your sending domain in Resend, and make sure EMAIL_FROM (and APP_NOREPLY_EMAIL if set)
use an address on that domain. Without it, sign-in codes are not delivered to your users.
Deploy
Trigger a deployment. The build log shows Running migrations... then Migrations complete! before
next build. Once it is live, sign up, then grant yourself the admin role as described in
Installation.
Existing databases
If your production database was created before migrations were used (for example with
drizzle-kit push), the migrator would try to create tables that already exist. Mark the migrations
that are already reflected in the database as applied with pnpm db:baseline. It is a dry run by
default:
DATABASE_URL=... pnpm db:baseline --until 0014_cheerful_silhouette
DATABASE_URL=... pnpm db:baseline --until 0014_cheerful_silhouette --applyPass the tag of the last migration whose changes already exist in that database. After that,
pnpm db:migrate only runs the newer ones.
Continuous integration
.github/workflows/ci.yml runs on pushes and pull requests to the main and prod branches, with
Node.js 22 and the pnpm version from packageManager. It has two jobs:
| Job | Steps |
|---|---|
| Typecheck, format, knip & lint | pnpm typecheck, pnpm format:check, pnpm knip, pnpm lint. |
| Build | pnpm exec next build with SKIP_ENV_VALIDATION=1, since CI has no secrets. |
pnpm knip and pnpm lint are marked continue-on-error for now, so they report problems without
failing the workflow. The CI build uses next build, not build:deploy, so it never touches a
database.
Commits and releases
Git hooks
simple-git-hooks (configured in package.json) runs two hooks:
"simple-git-hooks": {
"pre-commit": "pnpm lint",
"commit-msg": "pnpm commitlint --edit $1"
}Commit messages must follow Conventional Commits
(@commitlint/config-conventional), for example feat(billing): add yearly plans or
fix(auth): handle expired codes.
To write a valid message interactively, use commitizen with the cz-git adapter:
pnpm commitReleases
Releases use release-it with the conventional changelog plugin:
pnpm release:dry # preview the next release
pnpm release # pick the version interactively
pnpm release:patch # or bump a specific part
pnpm release:minor
pnpm release:majorBased on .release-it.json, a release:
- computes the changelog from your conventional commits and writes it to
CHANGELOG.md, - commits with the message
chore: release v<version>and creates the tagv<version>, - creates a GitHub release,
- never publishes to npm.
Creating the GitHub release requires a GITHUB_TOKEN in your environment, as documented by
release-it.
Good to know: CHANGELOG.md is for developers. The public /changelog page and its RSS feed
are fed by features/changelog/changelog-data.ts, which you edit by hand. See
Content.
Production checklist
NEXT_PUBLIC_APP_URLandBETTER_AUTH_URLpoint to your production domain.- A fresh
BETTER_AUTH_SECRETis set. - Preview deployments use a separate database.
- Live Stripe products exist, price IDs are set, and
pnpm db:seed:plansran on the production database. - The Stripe webhook endpoint is subscribed to the events above and its secret is set.
- OAuth apps list the production callback URLs.
- The Resend domain is verified.
pnpm legal:checkpasses and a lawyer reviewed the legal pages.DEMO_EMAILandDEMO_PASSWORDare unset, or you ranpnpm reset-project.