Launch NowDocs

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:

vercel.json
{
  "$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:

package.json
"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_URL and BETTER_AUTH_URL to your production URL, for example https://app.example.com, without a trailing slash.
  • Generate a new BETTER_AUTH_SECRET with openssl 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_EMAIL and DEMO_PASSWORD unset 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:products

Seed the plan table of the production database the same way:

DATABASE_URL=postgresql://... pnpm db:seed:plans

Add the Stripe webhook endpoint

In the Stripe dashboard, create a webhook endpoint pointing to:

https://app.example.com/api/webhook/stripe

Subscribe it to the events the app handles:

EventHandled by
checkout.session.completedBetter Auth Stripe plugin (creates the subscription).
customer.subscription.createdBetter Auth Stripe plugin.
customer.subscription.updatedBetter Auth Stripe plugin (syncs status and plan).
customer.subscription.deletedBetter Auth Stripe plugin (cancels the subscription).
invoice.payment_failedonEvent 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/google

The 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 --apply

Pass 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:

JobSteps
Typecheck, format, knip & lintpnpm typecheck, pnpm format:check, pnpm knip, pnpm lint.
Buildpnpm 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:

package.json
"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 commit

Releases

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:major

Based 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 tag v<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_URL and BETTER_AUTH_URL point to your production domain.
  • A fresh BETTER_AUTH_SECRET is set.
  • Preview deployments use a separate database.
  • Live Stripe products exist, price IDs are set, and pnpm db:seed:plans ran 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:check passes and a lawyer reviewed the legal pages.
  • DEMO_EMAIL and DEMO_PASSWORD are unset, or you ran pnpm reset-project.