Launch

Deployment

Ship Init to production. The web app targets Vercel; the database targets Vercel Postgres. Mobile, extension, and desktop each have their own pipeline.

Web App (Vercel)

1. Create the Project

Import the repo at vercel.com/new:

  • Root Directory: apps/web
  • Vercel detects Next.js and Turborepo automatically - no vercel.json needed

Or via CLI:

npm i -g vercel
vercel login
vercel   # from apps/web

2. Environment Variables

Set in the Vercel dashboard (same keys as .env.example):

# Database — the pooled connection string, plus the direct one for schema pushes
POSTGRES_URL=postgresql://...
POSTGRES_URL_NON_POOLING=postgresql://...

# Auth
BETTER_AUTH_SECRET=generate-with-openssl-rand--base64-32
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...

# Avatar uploads (Vercel Blob)
BLOB_READ_WRITE_TOKEN=...

The auth base URL derives from Vercel's built-in VERCEL_ENV / VERCEL_PROJECT_PRODUCTION_URL / VERCEL_URL - nothing to set. Creating the database under the project's Storage tab populates POSTGRES_URL, and creating a Blob store there populates BLOB_READ_WRITE_TOKEN.

3. Preview Deployments

The oAuthProxy plugin in packages/api/src/auth/auth.ts routes OAuth callbacks through the production URL, so GitHub login works on preview deployments without registering every preview domain.

4. Custom Domain

  1. Add your domain in the Vercel dashboard
  2. Update DNS as instructed
  3. SSL is automatic
  4. Update your GitHub OAuth app's callback URL

Database (Vercel Postgres)

  1. Create the database from the Vercel dashboard under Storage, which wires POSTGRES_URL and POSTGRES_URL_NON_POOLING into the project

  2. Push the schema:

    # .env.production.local at the repo root with the production POSTGRES_URL
    # and POSTGRES_URL_NON_POOLING (`vercel env pull` writes both)
    pnpm db:push-remote

    drizzle-kit push hangs behind a transaction-mode pooler, so the config prefers POSTGRES_URL_NON_POOLING and falls back to POSTGRES_URL.

Any Postgres works - Vercel Postgres is the default because of branching (below), not because anything in the code depends on it.

RLS is already on for every table with no policies - deny by default. Nothing exposes Postgres directly, so this is belt-and-braces; authorization lives in the oRPC procedures, and the server connection bypasses RLS as table owner.

Branching

Branches are copy-on-write, so a branch is a full database with production's data at a fraction of the storage. Two uses:

  • Preview deployments - the Vercel integration creates a branch per preview automatically, so PRs run against real-shaped data without touching production
  • Agents and experiments - a branch created from the database's dashboard gives an isolated database to migrate against or destroy freely

Delete a branch when you're done; nothing else in the stack needs to know it existed.

Avatar Storage (Vercel Blob)

Create a Blob store in the Vercel dashboard (Storage → Create → Blob) and add BLOB_READ_WRITE_TOKEN to the project. Uploads go through /api/account/avatar, which checks the better-auth session, caps size at 1MB, and derives the stored Content-Type from the file's magic bytes rather than the client-supplied MIME type.

There is no local emulator for Blob. Without the token that one route returns 501 with a pointer; the rest of the app is unaffected, so local development doesn't need a store.

Mobile (EAS)

Production builds need to know where the API lives:

// eas.json build profile env
{ "EXPO_PUBLIC_API_URL": "https://your-domain.com" }

Dev builds derive the URL from Metro. Then follow the standard EAS Build flow.

Extension and Desktop

pnpm -F @repo/extension build   # wxt build → .output/chrome-mv3
pnpm -F @repo/desktop build     # electron-vite build; package with electron-builder

Upload the extension to the Chrome Web Store; distribute desktop builds per electron-builder.yml.

CI/CD

Vercel deploys on push once the repo is connected - previews per PR, production on main. Schema pushes stay manual (pnpm db:push-remote) or go in a workflow step with the direct connection string as a secret:

- name: Push database schema
  env:
    POSTGRES_URL_NON_POOLING: ${{ secrets.POSTGRES_URL_NON_POOLING }}
  run: pnpm -F db drizzle:kit push --force

Run checks before merging:

pnpm lint && pnpm typecheck && pnpm test && pnpm build

Health Checks

The app ships a liveness endpoint at /api/health (apps/web/src/app/api/health/route.ts). Point your uptime monitor at it:

curl https://your-domain.com/api/health
# {"status":"ok"}

Rollback

vercel ls                          # List deployments
vercel rollback [deployment-url]   # Roll back the web app

drizzle-kit push has no down migrations - take a backup before risky schema changes:

pg_dump $POSTGRES_URL > backup_$(date +%Y%m%d_%H%M%S).sql

Vercel Postgres also keeps point-in-time restore over a retention window, and a branch taken before a migration is itself a cheap rollback target.

Production Checklist

  • BETTER_AUTH_SECRET set and unique per environment
  • Separate GitHub OAuth apps for dev and prod (callback: https://your-domain.com/api/auth/callback/github)
  • POSTGRES_URL uses the pooled endpoint - the client is configured for it; POSTGRES_URL_NON_POOLING is the direct one for schema pushes
  • BLOB_READ_WRITE_TOKEN set, with a Blob store created in the Vercel project
  • Uptime monitor on /api/health

Next Steps

  1. Monitoring - Set up monitoring and observability
  2. Production checklist - Review production best practices

On this page