Launch

Deployment

Ship the web app to Vercel and the database to Supabase, plus the mobile, extension and desktop pipelines

Ship Init to production. The web app targets Vercel; the database targets Supabase. 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. Adding Supabase from the project's Storage tab populates POSTGRES_URL and POSTGRES_URL_NON_POOLING, and creating a Blob store there populates BLOB_READ_WRITE_TOKEN.

3. Preview Deployments

The oAuthProxy plugin in packages/service/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 (Supabase)

  1. Create the project from the Vercel dashboard under Storage → Supabase (a Marketplace integration). It sets POSTGRES_URL (the transaction-mode pooler) and POSTGRES_URL_NON_POOLING (session mode) on the Vercel project, plus SUPABASE_* keys this app doesn't read. Or create the project at supabase.com and copy its transaction pooler string into POSTGRES_URL and its session pooler string into POSTGRES_URL_NON_POOLING

  2. Turn the Data API off - the app reaches Postgres only through its own server, so nothing needs Supabase's Data API, which would otherwise serve the public schema to anyone holding the project's anon key. Untick it under Advanced options when creating the project, or switch it off later in the dashboard's Data API settings

  3. Match the Postgres version - local Supabase runs major_version from packages/db/supabase/config.toml (17); keep it equal to SHOW server_version; on the project

  4. 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 - nothing in the code depends on Supabase, and POSTGRES_URL is the only input. Supabase is the default because the same Postgres runs locally as a native process (pnpm db:start), so development and production run one database flavor.

RLS is already on for every table with no policies - deny by default. With the Data API off 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.

Previews and Branches

Supabase Branching gives a branch your schema but none of production's data, and its per-PR preview branches are built from migration files, which this template doesn't keep (drizzle-kit push syncs the schema instead). So:

  • Preview deployments - give Vercel's Preview environment a second Supabase project (staging): set its POSTGRES_URL and POSTGRES_URL_NON_POOLING for Preview only, and push the schema to it with pnpm db:push-remote (its URLs in .env.production.local). If you want every PR on a copy-on-write copy of production's data, that's Neon's branching, also on the Vercel Marketplace - swap the URLs; nothing else changes
  • Agents and experiments - local databases are already isolated: pnpm db:start gives every git branch and worktree its own, to migrate against or destroy freely (pnpm db:reset)

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 package   # tauri build → src-tauri/target/release/bundle

Upload the extension to the Chrome Web Store. The desktop bundle is ad-hoc signed by default; tauri build signs with a Developer ID and notarizes when APPLE_SIGNING_IDENTITY and the APPLE_* notarization variables are set (see Tauri's macOS signing guide). To point a build at another deployment, pass --config '{"build":{"frontendDist":"https://staging.example.com"}}'.

Updates are off until you configure them. Generate a key pair with pnpm -F @repo/desktop tauri signer generate, then add the public key and your feed to apps/desktop/src-tauri/tauri.conf.json:

"bundle": { "createUpdaterArtifacts": true /* … */ },
"plugins": {
  "updater": {
    "pubkey": "<contents of the .pub file>",
    "endpoints": ["https://github.com/<owner>/<repo>/releases/latest/download/latest.json"],
    "requireSignedVersion": true
  }
}

Build with TAURI_SIGNING_PRIVATE_KEY set and publish the bundle beside its latest.json (see the updater guide). The web app then checks for a release each time it loads in the shell and shows its update banner when one is out (on macOS, the app menu's Check for Updates… also checks on demand); the download and the restart each wait for a click.

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

Supabase backs up paid-plan projects daily (7 days on Pro), and point-in-time recovery is a paid add-on. The Free plan has no automatic backups, so the pg_dump above is the whole safety net there.

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
  • Supabase's Data API off - the app doesn't use it
  • 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