Roxabi uses a staging-based deployment architecture with Vercel as the single platform for both the web app and API.
Feature branch → PR to staging → CI runs → (optional) Deploy Preview via GitHub Actionsstaging → PR to main → CI runs → Vercel auto-deploys to production
Component
Platform
Details
Web (TanStack Start + Nitro)
Vercel
SSR, edge caching, Fluid compute
API (NestJS + Fastify)
Vercel
Zero-config NestJS, Fluid compute, auto-scaling
Database
Neon
Serverless PostgreSQL, not managed by Vercel
No Docker is needed for deployment. Docker configurations (Dockerfiles, docker-compose.prod.yml, Nginx configs, deploy/) remain in the repo for local development only.
Add them at Settings → Secrets and variables → Actions → New repository secret.
Note: The NEON_API_KEY and NEON_PROJECT_ID secrets are only required if you use the Deploy Preview workflow to create API preview environments with isolated databases. They are not needed for production-only deployments.
Disable Vercel's automatic preview deployments to prevent rate-limit exhaustion from canceled builds on non-production branches:
# Get your team ID and project IDs from .vercel/project.json after linkingTEAM_ID="<your-team-id>"TOKEN="<your-vercel-token>"# Disable for web projectcurl -X PATCH -H "Authorization: Bearer $TOKEN" \ "https://api.vercel.com/v9/projects/<web-project-id>?teamId=$TEAM_ID" \ -H "Content-Type: application/json" \ -d '{"previewDeploymentsDisabled":true}'# Disable for API projectcurl -X PATCH -H "Authorization: Bearer $TOKEN" \ "https://api.vercel.com/v9/projects/<api-project-id>?teamId=$TEAM_ID" \ -H "Content-Type: application/json" \ -d '{"previewDeploymentsDisabled":true}'
Why this matters: Even with ignoreCommand in vercel.json, Vercel still attempts a deployment for every push, which counts toward the rate limit. The previewDeploymentsDisabled setting prevents Vercel from initiating the deployment entirely. Use the Deploy Preview GitHub Action or Vercel CLI when you need a preview.
cd apps/webfor env in production preview development; do printf '%s' "https://api.roxabi.vercel.app" | vercel env add API_URL "$env" printf '%s' "https://app.roxabi.vercel.app" | vercel env add APP_URL "$env"done
Warning: Always use printf '%s' instead of echo when piping values to vercel env add. echo appends a trailing newline that becomes part of the stored value, which causes ERR_INVALID_CHAR errors when the values are used in HTTP headers (e.g., CORS origin).
Select your Vercel team/account and link to the API project
Verify: cd apps/api && vercel env ls production | grep DATABASE_URL
Trigger a redeploy: vercel redeploy <latest-deployment-url>
Test: verify /api/health returns 200
Note: The integration also injects DATABASE_URL into preview and development scopes. Preview scope is overridden by the Deploy Preview workflow's --env flag. Development scope is unused (local dev reads .env files).
Rollback: If production fails after install, re-add manually: vercel env add DATABASE_URL production and redeploy.
Rate limiting is enabled by default (RATE_LIMIT_ENABLED=true) and requires Upstash Redis. The code uses @upstash/redis and reads KV_REST_API_URL and KV_REST_API_TOKEN — the env var names auto-injected by the Upstash Marketplace integration.
Step-by-step via Vercel Integration:
Go to vercel.com/marketplace/upstash and click Install (or go to your team's integrations page: https://vercel.com/<team>/~/integrations/upstash)
Select your Vercel team/account when prompted
Choose Redis as the product
Configure the database:
Name: e.g. roxabi-redis
Region: choose the closest to your Neon database region
Plan: Free tier is sufficient for development
Link the database to your API project (e.g. roxabi-api)
Vercel auto-creates KV_REST_API_URL and KV_REST_API_TOKEN as environment variables on the API project
Redeploy the API project for the new env vars to take effect
Via Vercel CLI:
cd apps/apivercel integration add upstash/upstash-kv --name roxabi-redis
Note: The CLI requires accepting the Upstash terms of service in the dashboard first. If you see a "Terms have not been accepted" prompt, visit the integration page above first.
Verify the env vars were added:
cd apps/api && vercel env ls | grep KV_REST
You should see KV_REST_API_URL and KV_REST_API_TOKEN listed.
Alternative — manual setup (without the integration):
Copy the REST URL and token from the database details page
Add them to the API project:
cd apps/apifor env in production preview development; do printf '%s' "https://your-redis.upstash.io" | vercel env add KV_REST_API_URL "$env" printf '%s' "your-upstash-token" | vercel env add KV_REST_API_TOKEN "$env"done
Warning: Without these vars, the API will crash at startup in production. If you do not need rate limiting (e.g., early development), set RATE_LIMIT_ENABLED=false explicitly — but note this disables auth brute-force protection.
Variable
Description
DATABASE_URL
PostgreSQL connection string (auto-injected by Neon Marketplace integration)
DATABASE_APP_URL
App user connection URL with RLS enforced (use instead of DATABASE_URL for API server queries)
CORS_ORIGIN
Web project URL (for CORS)
BETTER_AUTH_SECRET
Random 32+ character string
BETTER_AUTH_URL
This project's URL
APP_URL
Web project URL
API_URL
This project's URL
EMAIL_FROM
Sender email address
RESEND_API_KEY
Resend API key for transactional emails (auto-injected by Resend Marketplace integration)
KV_REST_API_URL
Upstash Redis REST URL (auto-injected by Upstash Marketplace integration)
KV_REST_API_TOKEN
Upstash Redis REST token (auto-injected by Upstash Marketplace integration)
Static — only changes if Neon project is recreated
BETTER_AUTH_SECRET
Manual rotation: vercel env rm BETTER_AUTH_SECRET production && vercel env add BETTER_AUTH_SECRET production
Tip:BETTER_AUTH_SECRET rotation requires a redeploy to take effect. Between env rm and the completed redeploy with the new value, auth sessions will be invalid. Plan rotation during a maintenance window.
Tip: Instead of manual setup, you can install the Neon Marketplace integration to auto-inject DATABASE_URL into your Vercel API project. You'll still need to initialize the database schema (steps 3-4 below).
Copy the DATABASE_URL connection string from the Neon dashboard
Initialize the database schema (required before any migration can run — see Database Initialization for why):
cd apps/apiDATABASE_URL="<your-neon-connection-string>" bunx drizzle-kit push --force
Register existing migrations so Drizzle does not re-apply them:
cd apps/apiDATABASE_URL="<your-neon-connection-string>" bun run db:migrate
Rate limiting is enabled by default and requires Upstash Redis. Follow the step-by-step in Upstash Redis setup above, or set RATE_LIMIT_ENABLED=false temporarily if you need to deploy without it.
Note: If you skip this step, the API will crash at startup in production.
Disable Vercel's automatic preview deployments on both projects to prevent rate-limit exhaustion. See Disable Automatic Preview Deployments for the API commands.
Both Vercel projects ship with SSO deployment protection enabled by default. See Deployment Protection for options to adjust this for preview environments.
vercel env ls # List allvercel env add SECRET_NAME production # Add (interactive)vercel env rm SECRET_NAME production # Removevercel env pull .env.local # Pull to local file
Nitro (TanStack Start) outputs to .vercel/output/ using the Vercel Build Output API — no outputDirectory override needed.
Important: Dashboard build settings (Install Command, Build Command) must match vercel.json values. If they diverge, Vercel shows a "Configuration Settings differ" warning. Use the Vercel API to sync them if needed:
Automatic preview deployments are disabled at the Vercel project level via the previewDeploymentsDisabled API setting. This prevents Vercel from attempting (and canceling) preview builds on every push to non-production branches, which would otherwise consume the deployment rate limit.
As a secondary safeguard, both projects also use ignoreCommand in vercel.json to skip builds on non-main branches:
Prevents Vercel from even attempting preview deployments
Secondary
ignoreCommand in vercel.json
Skips builds on non-main branches if a deployment is triggered
Preview deploys are manual only — use the Deploy Preview GitHub Action (see Preview Deploys above) or the Vercel CLI.
Important: Always run vercel preview deploys from the repository root, not from apps/web or apps/api. The Vercel project linking (.vercel/project.json) and TurboRepo build pipeline expect the root as the working directory. Running from a subdirectory may produce broken builds or skip workspace dependencies.
The API build command runs migrations before building:
"buildCommand": "bun run db:migrate && turbo run build"
Behavior
Details
When
Every API deploy to production (merge to main)
Idempotent
Drizzle Kit tracks applied migrations — re-running is safe
On failure
Build fails, Vercel keeps the previous production deployment live
On success
Build continues, new code deploys with the updated schema
Note: Because migrations run before the build, your database schema is always at least as new as the deployed code. There is no window where new code runs against an old schema.
Drizzle Kit does not support automatic rollback (db:rolldown). If a migration causes issues:
Strategy
Steps
Corrective migration (preferred)
Write a new migration that undoes the change, commit, and push. The next deploy applies it automatically.
Revert and redeploy
Revert the commit containing the bad migration, push to main. Vercel redeploys with the previous schema. Only works if the migration was additive (e.g., adding a column). Destructive migrations (dropping columns/tables) cannot be reverted this way.
CI automatically detects when a developer modifies Drizzle schema files but forgets to generate the corresponding migration.
How it works:
The typecheck job runs bun run db:generate with a dummy DATABASE_URL
It checks git diff on the apps/api/drizzle/ directory
If uncommitted migration files are produced, CI fails
Error message you will see:
Schema drift detected! Your Drizzle schema has changes that are not captured in a migration file.Run 'cd apps/api && bun run db:generate' locally and commit the generated migration.
To fix: Run cd apps/api && bun run db:generate locally, review the generated SQL, and commit it with your schema changes.
When deploying API previews, the Deploy Preview workflow creates an isolated Neon database branch so preview environments do not share the production database.
Step
What happens
create-neon-branch job
Creates a Neon branch named preview/{branch} with its own DATABASE_URL
deploy-api job
Builds the API with the branch DATABASE_URL, runs db:migrate during build
After review
Re-run the workflow with target cleanup to delete the Neon branch
Cleanup:
Go to Actions > Deploy Preview
Click Run workflow
Select the same branch used for the preview
Choose target: cleanup
Click Run workflow
The workflow deletes the Neon branch named preview/{branch}. You can also delete branches directly from the Neon console.
On a fresh database (new Neon project, empty PostgreSQL instance), you must initialize the schema before migrations can run. This is due to a chicken-and-egg dependency between Better Auth and Drizzle.
Better Auth creates its core tables (users, sessions, accounts, verifications) at runtime via the Drizzle adapter — these tables are NOT defined in the migration files. Drizzle migrations (RLS policies, added columns, indexes) ALTER these tables and assume they already exist. On a fresh database, migrations fail because the tables they reference have not been created yet.
Use drizzle-kit push --force to create all tables from the Drizzle schema definition, then run migrations to register them:
cd apps/apiDATABASE_URL="<your-production-url>" bunx drizzle-kit push --forceDATABASE_URL="<your-production-url>" bun run db:migrate
The --force flag skips interactive confirmation prompts. The db:migrate step registers all existing migration files as "already applied" so they are not re-run on the next deploy.
Option B: Let Better Auth bootstrap tables
Start the application once with a valid DATABASE_URL to let Better Auth create its tables at runtime, then run migrations:
# Start the API locally against production (or let Vercel deploy once — the build will fail on migrations, but Better Auth tables will exist)DATABASE_URL="<your-production-url>" bun run dev# Then apply migrationsDATABASE_URL="<your-production-url>" bun run db:migrate
Option A is preferred because it is a single atomic step and does not require running the app.
Neon database branching creates a copy-on-write snapshot of the parent branch (production). If production is empty, every preview branch inherits that empty state and migrations fail with missing-table errors. Production must be initialized first before preview branches work correctly.
Important: If you see migration errors like relation "users" does not exist in preview deploys or production builds, your database has not been initialized. Run drizzle-kit push --force against the production database as described above.
This requires Vercel team SSO authentication to access any deployment URL that is not a custom domain. Since preview deployments have no custom domain, they are always gated behind SSO login.
Vercel supports a x-vercel-protection-bypass header that CI workflows can use to access protected preview URLs without SSO. Generate a bypass secret in the Vercel dashboard under Settings > Deployment Protection > Protection Bypass for Automation, then pass it as a header in health check requests: