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.jsonneeded
Or via CLI:
npm i -g vercel
vercel login
vercel # from apps/web2. 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
- Add your domain in the Vercel dashboard
- Update DNS as instructed
- SSL is automatic
- Update your GitHub OAuth app's callback URL
Database (Supabase)
-
Create the project from the Vercel dashboard under Storage → Supabase (a Marketplace integration). It sets
POSTGRES_URL(the transaction-mode pooler) andPOSTGRES_URL_NON_POOLING(session mode) on the Vercel project, plusSUPABASE_*keys this app doesn't read. Or create the project at supabase.com and copy its transaction pooler string intoPOSTGRES_URLand its session pooler string intoPOSTGRES_URL_NON_POOLING -
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
publicschema 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 -
Match the Postgres version - local Supabase runs
major_versionfrompackages/db/supabase/config.toml(17); keep it equal toSHOW server_version;on the project -
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-remotedrizzle-kit pushhangs behind a transaction-mode pooler, so the config prefersPOSTGRES_URL_NON_POOLINGand falls back toPOSTGRES_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_URLandPOSTGRES_URL_NON_POOLINGfor Preview only, and push the schema to it withpnpm 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:startgives 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/bundleUpload 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 --forceRun checks before merging:
pnpm lint && pnpm typecheck && pnpm test && pnpm buildHealth 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 appdrizzle-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).sqlSupabase 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_SECRETset and unique per environment- Separate GitHub OAuth apps for dev and prod (callback:
https://your-domain.com/api/auth/callback/github) POSTGRES_URLuses the pooled endpoint - the client is configured for it;POSTGRES_URL_NON_POOLINGis the direct one for schema pushes- Supabase's Data API off - the app doesn't use it
BLOB_READ_WRITE_TOKENset, with a Blob store created in the Vercel project- Uptime monitor on
/api/health
Next Steps
- Monitoring - Set up monitoring and observability
- Production checklist - Review production best practices