Deployment Guide
This guide walks you through deploying your Stack application to production using Vercel and Neon PostgreSQL.
Locally, pnpm dev runs the Dev database — a Postgres the stack starts for your project — and reconciles it with your config as you edit. Production runs on a PostgreSQL server you provision, migrated from a committed migrations/ directory with prisma db migrate. These are deliberately two different workflows (see ADR-0003 and ADR-0063). This guide covers the production loop.
Prerequisites
Before deploying, make sure you have:
- A Vercel account (free tier works)
- A Neon account (free tier includes 10GB storage)
- Your Stack application working locally
- Git repository (GitHub, GitLab, or Bitbucket)
Overview
The deployment process involves:
- Setting up a production database (Neon PostgreSQL)
- Configuring environment variables (pooled app URL + direct migration URL)
- Deploying to Vercel
- Applying database migrations
- Verifying your deployment
Total time: ~10-15 minutes for first deployment
How database connections flow
postgresql is the only provider, and there is no driver adapter to install or construct: the generated .opensaas/context.ts builds the runtime client from the committed prisma/contract.json, and takes its connection from the stack's own URL lookup. Nothing in opensaas.config.ts names a connection string.
That lookup — resolveDatabaseUrl() — reads, in order:
DIRECT_DATABASE_URLDATABASE_URL- the dev-database state file written by a running
opensaas dev - otherwise it throws
DatabaseUrlUnresolvedError, naming both remedies
DIRECT_DATABASE_URL wins deliberately, so a schema command reaches a direct connection rather than a pooler that cannot run DDL (ADR-0003). Two functions are exported from @opensaas/stack-core: resolveDatabaseUrl, which throws, and findDatabaseUrl, which does not — the latter is what the generated prisma.config.ts calls, because that file is evaluated for every Prisma command including the offline ones.
There are still two consumers, and on serverless Postgres they want different strings:
- The running app builds its client from the resolved URL. On a platform like Vercel that should be the pooled connection, so serverless functions share connections instead of exhausting the limit.
- The Prisma CLI (
prisma migration plan,prisma db migrate) reads its connection from the generatedprisma.config.ts, which calls the same lookup.
Set DATABASE_URL to Neon's pooled URL and DIRECT_DATABASE_URL to Neon's direct URL. Because the lookup prefers DIRECT_DATABASE_URL, the app process would then also take the direct string — which is fine for a long-lived server and wrong for serverless. See Binding your own pool below for the fix; where there is no pooler at all, set DATABASE_URL only and neither problem exists.
The Database escape
Setting either variable is the escape: the lookup takes the environment branch, and no Dev database starts. In production that is the only branch there is — a deployment with neither variable set gets an error naming both remedies, never a silent in-process database. Locally you set neither, and pnpm dev runs the Dev database instead; set one to develop against a Postgres of your own for parity or contention.
Provisioning an extension your config declares
A declared extension pack (pgvector, for instance) is a committed Extension contract space in migrations/, and Prisma runs CREATE EXTENSION IF NOT EXISTS from it on every path — the Dev database, CI, and prisma db migrate in production. Your job is provisioning, not DDL: make the extension available on the server, and either let the migrating role create it or pre-create it. An extension that is already installed is detected and skipped, so a DBA who runs CREATE EXTENSION vector once is not in conflict with the migration.
Cost: pgvector needs a privilege you may not have
pgvector is not a trusted extension, so CREATE EXTENSION requires superuser, or a provider grant on the migrating role — otherwise a DBA has to pre-create it. Neon, Supabase and RDS grant it to the app role; a locked-down, self-hosted Postgres does not, and on that server your deploy needs a human with rights the migration does not have. Plan for that before the first release, not during it. Where the server does not have the extension available at all, the migration fails with Prisma's own error naming the failing space, the missing control file and SQL state 58P01, and the app's own migration is untouched — every apply runs in one transaction. See ADR-0065.
Step 1: Create Production Database
Using Neon PostgreSQL
Neon provides serverless PostgreSQL with automatic scaling and a generous free tier.
Sign in to Neon Console
- Go to console.neon.tech
- Create an account or sign in
Create a New Project
- Click "New Project"
- Choose a name (e.g.,
my-app-production) - Select a region close to your users
- Choose Postgres version (16 recommended)
- Click "Create Project"
Get Connection Strings
- After creation, Neon shows your connection string
- It looks like:
postgresql://username:password@ep-xxx.region.aws.neon.tech/dbname?sslmode=require - Neon provides two flavours — copy both:
- Pooled connection (recommended for serverless): use this for
DATABASE_URL(the app) - Direct connection: use this for
DIRECT_DATABASE_URL(migrations)
- Pooled connection (recommended for serverless): use this for
Enable Connection Pooling (Recommended)
- In your Neon project dashboard, go to "Settings" → "Connection Pooling"
- Pooling is on by default; the pooled string typically contains
-poolerin the host - Use the pooled connection string for
DATABASE_URL - Use the direct connection string for
DIRECT_DATABASE_URL
Step 2: Check Your db Block
There is nothing to switch. Your local and production databases are both Postgres, so the db block is the same in both, and it names no connection string at all:
import { config } from '@opensaas/stack-core'
export default config({
db: { provider: 'postgresql' },
lists: {
// ... your lists
},
})
The complete set of keys on db is provider, idField, extensions, client, schemas, timestamps, keystoneCompat and prismaGeneratorOptions. There is no url, no directUrl, and no prismaClientConstructor — the Config API reference describes each key.
Binding your own pool
db.client.pg is the one hook for taking over how the runtime connects. It is a lazy factory, not an instance: the config is loaded by the CLI and by tooling that never issues a query, and none of that should open a connection. The generated context calls it at most once, under its client singleton.
Reach for it when the resolved URL is the wrong one for the app process — the pooled-app / direct-CLI split above — or when you want a driver of your own, like Neon's WebSocket pool on a serverless runtime:
<!-- doc-check: excuses="TS2307 '@neondatabase/serverless', TS2307 'ws'" reason="third-party packages this repo does not install" -->
import { config } from '@opensaas/stack-core'
import { Pool, neonConfig } from '@neondatabase/serverless'
import ws from 'ws'
export default config({
db: {
provider: 'postgresql',
client: {
pg: () => {
neonConfig.webSocketConstructor = ws
return new Pool({ connectionString: process.env.DATABASE_URL })
},
},
},
lists: {
// ... your lists
},
})
An explicit pool wins outright, including over the Dev database's own binding — so leave db.client.pg unset locally, or gate it on the environment. pnpm dev warns rather than silently rebinding.
Under the hood, the generated context spreads resolveRuntimeConnection(config.db.client) into its client construction. That function is exported from @opensaas/stack-core/client if you ever need to build a client outside the generated bundle; pair it with import contractJson from '../prisma/contract.json' with { type: 'json' }, exactly as .opensaas/context.ts does. Do not hand-roll an equivalent.
Regenerate
pnpm generate
This rewrites the Contract module and the generated bundle, and regenerates the project-root prisma.config.ts. That file is CLI-only, never read by the running app, and you don't edit it:
<!-- doc-check: excuses="TS2307 'prisma/config', TS2307 '@prisma/orm-postgres/config'" reason="Prisma's own CLI config entry points, which this repo does not depend on" -->
// ⚠️ GENERATED FILE - DO NOT EDIT
// Generated by 'opensaas generate' from opensaas.config.ts.
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { definePrismaConfig } from 'prisma/config'
import { defineConfig } from '@prisma/orm-postgres/config'
import { findDatabaseUrl } from '@opensaas/stack-core'
// The Prisma CLI evaluates this file without loading a .env of its own, and
// `process.loadEnvFile` throws when the file is absent.
const envFile = join(import.meta.dirname, '.env')
if (existsSync(envFile)) process.loadEnvFile(envFile)
export default definePrismaConfig({
orm: defineConfig({
contract: './prisma/contract.ts',
output: './prisma',
extensions: [],
db: { connection: findDatabaseUrl() },
}),
})
extensions gains one entry per pack your config declares under db.extensions.
Step 3: Configure Environment Variables
Local Production Testing (Optional)
Create a .env.production.local file in your project root to test against the production database before deploying:
# .env.production.local
# Pooled connection — used by the app
DATABASE_URL="postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/dbname?sslmode=require"
# Direct connection — used by Prisma CLI (migrations / Studio)
DIRECT_DATABASE_URL="postgresql://user:pass@ep-xxx.region.aws.neon.tech/dbname?sslmode=require"
Why two URLs?
DATABASE_URL(pooled): the connection your app uses at runtime. Bind it withdb.client.pgwhereDIRECT_DATABASE_URLis also set. Pooling lets serverless functions share connections instead of exhausting the database's limit.DIRECT_DATABASE_URL(direct): the connection the Prisma CLI uses formigration plan/db migrate. Migrations need a direct, non-pooled connection. The generatedprisma.config.tsreads it asDIRECT_DATABASE_URL ?? DATABASE_URL.
Environment Variables for Better Auth (If Using)
If you're using @opensaas/stack-auth, add the auth variables. BETTER_AUTH_URL (and NEXT_PUBLIC_APP_URL) must be your deployed URL in production:
# .env.production.local
# Database
DATABASE_URL="postgresql://user:pass@ep-xxx-pooler.region.aws.neon.tech/dbname?sslmode=require"
DIRECT_DATABASE_URL="postgresql://user:pass@ep-xxx.region.aws.neon.tech/dbname?sslmode=require"
# Better Auth
BETTER_AUTH_SECRET="your-random-secret-here"
BETTER_AUTH_URL="https://your-app.vercel.app"
NEXT_PUBLIC_APP_URL="https://your-app.vercel.app"
# OAuth providers (optional)
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
Generate a secure secret:
openssl rand -base64 32
Environment variable checklist
| Variable | Used by | Value | Required |
|---|---|---|---|
DATABASE_URL | App (runtime client) | Neon pooled connection string | Always |
DIRECT_DATABASE_URL | Prisma CLI | Neon direct connection string | Always (Postgres) |
BETTER_AUTH_SECRET | Better Auth | openssl rand -base64 32 | If using auth |
BETTER_AUTH_URL | Better Auth | Your deployed URL (e.g. https://your-app.vercel.app) | If using auth |
NEXT_PUBLIC_APP_URL | Client | Your deployed URL | If using auth |
GITHUB_CLIENT_ID | Better Auth (OAuth) | From your GitHub OAuth app | If using GitHub |
GITHUB_CLIENT_SECRET | Better Auth (OAuth) | From your GitHub OAuth app | If using GitHub |
GOOGLE_CLIENT_ID | Better Auth (OAuth) | From your Google OAuth client | If using Google |
GOOGLE_CLIENT_SECRET | Better Auth (OAuth) | From your Google OAuth client | If using Google |
Step 4: Author the First Migration
Before your first deploy, plan the initial migration from your config and commit it.
Planning needs a database to plan against, and this step runs outside the dev loop, so take the Database escape for it: set DIRECT_DATABASE_URL (or DATABASE_URL) at a Postgres you are willing to have a planning connection opened against — the Neon project from Step 1, a local Docker Postgres, or a throwaway Neon branch. Do not point it at production data you cannot replace.
export DIRECT_DATABASE_URL="postgresql://..."
pnpm generate
pnpm migrate
pnpm migrate runs prisma migration plan, which writes a migration package into the migrations/ directory at your project root. Commit that whole directory to Git. It is your schema history, and it holds more than your app's own space: pnpm generate seeds an Extension contract space into it for every extension pack your config declares, and the production release step replays all of them together.
Local dev vs production.
pnpm devreconciles the Dev database with your config directly, andopensaas db updatehands that loop your consent for a change it is waiting on. That path has no migration history and can drop data on a schema change, so it is not a route to production — there is noopensaas db migrate, andopensaas migrateis an unrelated project-analysis assistant. Production always migrates from the committedmigrations/directory with the Prisma CLI.
Refs stay put. Once
migrations/appholds a migration package, the dev loop never moves the committed refs (migrations/app/refs/db.json), sopnpm migratekeeps planning from your migration history. Before the first package exists the loop does write thedbref, which is what the firstpnpm migrateplans its baseline from.
Step 5: Deploy to Vercel
You have two options: Vercel CLI (faster) or Vercel Dashboard (more visual).
Option A: Deploy with Vercel CLI (Recommended)
- Install Vercel CLI
npm install -g vercel
- Login to Vercel
vercel login
- Deploy from Project Root
vercel
Follow the prompts:
- "Set up and deploy?" → Yes
- "Which scope?" → Choose your account/team
- "Link to existing project?" → No (first time) or Yes (subsequent deploys)
- "What's your project's name?" → Enter name or press Enter
- "In which directory is your code located?" → Press Enter (current directory)
- "Want to override settings?" → No
- Add Environment Variables
vercel env add DATABASE_URL production
# Paste your pooled connection string
vercel env add DIRECT_DATABASE_URL production
# Paste your direct connection string
# If using Better Auth:
vercel env add BETTER_AUTH_SECRET production
vercel env add BETTER_AUTH_URL production
vercel env add NEXT_PUBLIC_APP_URL production
- Deploy to Production
vercel --prod
Option B: Deploy with Vercel Dashboard
- Push to Git
git add .
git commit -m "Initial deployment"
git push origin main
Import Project in Vercel
- Go to vercel.com/new
- Click "Import Project"
- Select your Git repository
- Click "Import"
Configure Project
- Framework Preset: Next.js (should auto-detect)
- Root Directory:
./(leave default) - Build Command:
pnpm build(or leave default) - Output Directory:
.next(leave default) - Install Command:
pnpm install(or leave default)
Add Environment Variables
- Click "Environment Variables"
- Add each variable:
DATABASE_URL→ Your pooled connection stringDIRECT_DATABASE_URL→ Your direct connection stringBETTER_AUTH_SECRET→ Your random secret (if using auth)BETTER_AUTH_URL→https://your-app.vercel.app(update after first deploy)NEXT_PUBLIC_APP_URL→https://your-app.vercel.app(if using auth)- Add any OAuth credentials if using social login
Deploy
- Click "Deploy"
- Wait for build to complete (~2-3 minutes)
- Your app will be live at
https://your-app.vercel.app
Step 6: Apply Migrations to Production
Run your committed migrations against the production database. Because prisma.config.ts resolves DIRECT_DATABASE_URL ?? DATABASE_URL, prisma db migrate automatically uses the direct connection.
pnpm migrate:deploy
pnpm migrate:deploy runs prisma db migrate, applying every committed package in order — your app's space and each extension space — without prompting. It never plans new migrations, so it's safe to run repeatedly and in CI.
Running migrations as part of the build (recommended)
To apply migrations automatically on every Vercel deploy, add prisma db migrate to the build command. The starter templates' build script is pnpm generate && next build; extend it to:
{
"scripts": {
"build": "pnpm generate && prisma db migrate && next build"
}
}
Ensure DIRECT_DATABASE_URL is set in your Vercel environment variables so the build-time migration uses the direct connection.
Alternative — apply migrations manually before deploying:
# Locally, against the production DB (DIRECT_DATABASE_URL set)
pnpm migrate:deploy
# Then deploy
vercel --prod
Verify Database Setup
Prisma 8 ships no Studio. Inspect the deployed database with any Postgres client pointed at the direct connection string:
psql "$DIRECT_DATABASE_URL"
Step 7: Verify Deployment
Visit Your App
- Go to your Vercel deployment URL
- You should see your app running
Test Database Connectivity
- If you have an admin UI (
/admin), try creating a record - Verify it landed, with
psql "$DIRECT_DATABASE_URL" - Check for any console errors
- If you have an admin UI (
Smoke-check access control
- Confirm anonymous vs. authenticated behaviour matches local: e.g. anonymous visitors only see published records, and owner-only rows/fields stay protected. Access control runs identically in production.
Check Vercel Logs
- Go to your project in Vercel Dashboard
- Click "Deployments" → Select latest deployment → "Functions"
- View logs for any errors
Confirm Better Auth URLs (If Using)
- Go to Vercel Dashboard → Your Project → Settings → Environment Variables
- Ensure
BETTER_AUTH_URLandNEXT_PUBLIC_APP_URLare your production URL (e.g.,https://your-app.vercel.app) - Redeploy:
vercel --prodor push to Git
Continuous Deployment
Automatic Deployments
Once connected to Git, Vercel automatically deploys:
- Production: Pushes to
mainbranch →your-app.vercel.app - Preview: Pull requests →
your-app-git-branch.vercel.app
Deploy Workflow
# Make changes locally; `pnpm dev` regenerates and reconciles the Dev database
pnpm dev
# When you change the schema, plan a migration to ship
pnpm migrate
# Commit and push (including the new migrations/ packages)
git add .
git commit -m "Add new feature"
git push
# Vercel deploys; the build step runs `prisma db migrate`
# (if you added it to the build command) using DIRECT_DATABASE_URL
Database Migrations in CI/CD
For schema changes, migrations are applied via prisma db migrate — either in the Vercel build command (see Step 6) or as an explicit step before deploy:
# In CI, against the production DB (DIRECT_DATABASE_URL set)
pnpm migrate:deploy
db migrate only applies existing committed packages; it never plans new ones, which makes it safe to run in an automated pipeline.
Authoring Migrations in CI / Agent Environments
Applying migrations is what prisma db migrate already does non-interactively. Planning a new one needs a database to plan against — prisma migration plan resolves its origin through the db ref in the committed migrations/ directory and the connection prisma.config.ts gives it — so point DIRECT_DATABASE_URL at a disposable Postgres (a CI service container, Docker, or a throwaway Neon branch) and run:
pnpm generate
pnpm migrate
Commit everything the command writes under migrations/, the refs included. It is the same output an interactive local run produces.
Production Considerations
Database discovery in production
Set DATABASE_URL (or DIRECT_DATABASE_URL, which takes precedence) explicitly in the build and runtime environments that need a database. With NODE_ENV=production, Stack never discovers the Dev database by searching the working directory for .opensaas/dev-db.json. A missing production URL produces the missing-connection error; offline commands using findDatabaseUrl() continue to receive undefined.
To exercise a production build against the local Dev database, run:
pnpm build
pnpm exec opensaas dev -- next start
The dev loop exports its exact, absolute state-file path in OPENSAAS_DEV_DATABASE_STATE_FILE before generation and reconciliation, so both Prisma subprocesses and the app inherit it, even when the loop itself starts with NODE_ENV=production. The previous value is restored when the loop stops. This preserves the Dev database's single-connection pool and contract-marker behavior without making the deployment trace follow project-root discovery. A custom local runner may set this variable itself, or pass an absolute stateFile to resolveDatabaseUrl()/findDatabaseUrl(). The state must describe a live sidecar on the same machine. Relative paths, missing files and stale state are ignored without falling back to the working directory. An explicitly configured database URL always wins.
OPENSAAS_DEV_DATABASE_STATE_FILE is for a locally served production build, not a production database connection. Do not copy .opensaas/dev-db.json into a deployment; configure the deployment's Postgres URL instead. Normal development continues to discover the Dev database from the project root.
Bundling the Generated .opensaas bundle
opensaas generate emits a Generated bundle under .opensaas/ — context.ts, types.ts, lists.ts and the rest — that your app imports through getContext, alongside the Contract module and its emitted contract.json. The host build (next build) is responsible for compiling this bundle and file-tracing it into the serverless output. Two things make that work, and the first is automatic.
1. The bundle is loadable by your bundler out of the box. The generator emits relative imports with explicit .ts extensions, so the bundle resolves identically under tsx, vitest, a plain Node process, and a bundler — without you adding a resolve.extensionAlias. This is the default output; there is no generator flag (see ADR-0008).
The one consumer requirement is a single tsconfig line: because the bundle's relative imports carry .ts extensions, the project that type-checks it must set allowImportingTsExtensions: true in its compilerOptions. This is compatible with Next's noEmit (TypeScript only allows the flag when it isn't emitting, which Next apps already satisfy), so next build's type-check step accepts the .ts specifiers instead of failing with TS5097 (An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled):
// tsconfig.json
{
"compilerOptions": {
"noEmit": true, // already set in Next apps
"moduleResolution": "bundler", // (or node16 / nodenext)
"allowImportingTsExtensions": true, // <-- required to type-check the .opensaas bundle
},
}
Projects scaffolded with create-opensaas-app already have this flag set, so newly-created apps build without any extra step.
2. Import the bundle statically. Reach the bundle through a normal static import so next build compiles and traces it:
// Supported
import { getContext } from '@/.opensaas/context'
Do not push the bundle out of the compile graph with a webpackIgnored dynamic import(). A bundler does not follow an ignored dynamic import, so the bundle's files never get traced and go missing from the serverless output (you'll see a runtime "Cannot find module" on Vercel even though local dev works):
<!-- doc-check: excuses="TS2307 './.opensaas/context'" reason="the block is the webpackIgnore anti-pattern the prose tells the reader to avoid, and the relative specifier is the point of it" -->
// Avoid
const { getContext } = await import(/* webpackIgnore: true */ './.opensaas/context')
Scope. The stack only owns "emit a bundler-loadable context entry." What your own
opensaas.config.tspulls in is your app's architecture — if it lazilyimport()s workflow modules that reach heavy deps (e.g.xero-node,twilio), trace or externalize those yourself the same way.
Database Connection Pooling
Serverless functions (like Vercel) create many database connections. Use connection pooling to avoid exhausting your database limits.
Neon provides built-in pooling:
- Use the pooled connection string for
DATABASE_URL(the app's runtime client) - Use the direct connection string for
DIRECT_DATABASE_URL(migrations) - Bind the pooled string with
db.client.pgso the app's client takes it rather than the direct one the lookup prefers
Tuning the runtime's own pool. Where the runtime opens its own pool from the resolved URL, db.client.poolOptions is handed to it as-is. It applies only when no pg factory is given — an explicit pool is yours to configure.
Environment Variables Management
Never commit secrets to Git:
- Add
.env*.localto.gitignore(already done) - Use Vercel's environment variable UI
- For team projects, use 1Password, AWS Secrets Manager, or similar
Environment-specific variables:
- Development: neither variable set —
pnpm devruns the Dev database - Production: Vercel Dashboard or
vercel env(DATABASE_URL+DIRECT_DATABASE_URL) - Preview: Can inherit from Production or set separately
Database Backups
Neon provides automatic backups:
- Point-in-time recovery (PITR) available on paid plans
- Free tier: Daily snapshots retained for 7 days
Manual backups:
# Export database to SQL file (use the direct connection)
pg_dump "postgresql://...@ep-xxx.region.aws.neon.tech/dbname?sslmode=require" > backup.sql
# Restore from backup
psql "postgresql://...@ep-xxx.region.aws.neon.tech/dbname?sslmode=require" < backup.sql
Monitoring & Logging
Vercel provides:
- Real-time function logs
- Web Analytics (free)
- Speed Insights
Database monitoring:
- Neon Console shows connection count, storage, CPU usage
- Set up alerts for connection limits
Application monitoring:
Security Checklist
Before going live:
- [ ] All secrets are in environment variables (not hardcoded)
- [ ]
BETTER_AUTH_SECRETis cryptographically random - [ ] Database connection uses SSL (
sslmode=require) - [ ]
DATABASE_URLis the pooled URL;DIRECT_DATABASE_URLis the direct URL - [ ] Access control rules are tested and working
- [ ] CORS is configured if using external APIs
- [ ] Rate limiting is configured (consider Vercel's built-in protection)
- [ ] Better Auth session duration is appropriate for your app
- [ ] OAuth redirect URLs are whitelisted in provider settings
Troubleshooting
"Can't reach database server"
Symptoms: Prisma can't connect to Neon database
Solutions:
- Verify
DATABASE_URLis correct (copy from Neon Console) - Check
sslmode=requireis in the connection string - Ensure the Neon project is not paused (happens on free tier after inactivity)
- Test the migration connection directly:
psql "$DIRECT_DATABASE_URL" -c 'select 1'
"Too many connections"
Symptoms: Database refuses new connections
Solutions:
- Use the pooled connection string for
DATABASE_URL(the host usually contains-pooler) - Add
connection_limit=10to the connection string:typescriptpostgresql://...?sslmode=require&connection_limit=10 - Bind the pooled connection explicitly with
db.client.pg, so aDIRECT_DATABASE_URLset for migrations does not become the app's connection too - Upgrade your Neon plan for more connections
"Migration failed"
Symptoms: prisma db migrate errors
Solutions:
- Ensure
DIRECT_DATABASE_URLis set to the direct (non-pooled) connection — migrations must not run over the pooler - Check that the migration packages under
migrations/at your project root are committed and correct — the refs and every extension contract space, not only the app space - There is no failed-migration state to clear by hand, and no
migrate resolveto run: an apply runs in one transaction, so a failedprisma db migrateleaves the database where it started. Fix the cause, re-plan withpnpm migrateif the packages themselves need to change, commit, and runpnpm migrate:deployagain - If the error names an extension contract space and SQL state
58P01, the extension is not available on the server — see The Database escape for provisioning and the privilege the migrating role needs - For destructive changes, back up data first
"Authentication not working"
Symptoms: Better Auth login fails in production
Solutions:
- Verify
BETTER_AUTH_URLandNEXT_PUBLIC_APP_URLmatch your production domain - Check
BETTER_AUTH_SECRETis set in Vercel - Ensure OAuth redirect URLs are updated in provider settings:
- GitHub:
https://your-app.vercel.app/api/auth/callback/github - Google:
https://your-app.vercel.app/api/auth/callback/google
- GitHub:
- Check the browser console for CORS errors
"Build failed"
Symptoms: Vercel deployment fails during build
Solutions:
- Check the Vercel build logs for the specific error
- Ensure
pnpm generateruns successfully locally - If the build runs
prisma db migrate, confirmDIRECT_DATABASE_URLis set in Vercel - Verify all dependencies are in
package.json(not just devDependencies) - Check the Node.js version matches Vercel (use
.nvmrcorpackage.jsonengines field)
"Function execution timeout"
Symptoms: 504 errors on some requests
Solutions:
- Optimize slow database queries (add indexes)
- Use
Promise.all()for parallel operations - Consider background jobs for long-running tasks
- Upgrade your Vercel plan for a longer timeout (Pro: 60s, Hobby: 10s)
Getting Help
If you're stuck:
- Check Stack GitHub Issues
- Search Vercel Docs
- Check Neon Docs
Advanced Topics
Custom Domains
Add Domain in Vercel
- Go to Project Settings → Domains
- Add your custom domain (e.g.,
app.example.com) - Follow the DNS configuration instructions
Update Better Auth URL
- Update the
BETTER_AUTH_URLandNEXT_PUBLIC_APP_URLenvironment variables - Update OAuth redirect URLs in provider settings
- Update the
Multi-Environment Setup
For staging + production:
# Create a staging database in Neon
# Add its pooled + direct URLs to Vercel as staging environment variables
# Deploy to staging branch
git push origin staging
# Vercel auto-deploys to staging-your-app.vercel.app
# Promote to production
git checkout main
git merge staging
git push origin main
Using Different Database Providers
While this guide focuses on Neon, Stack works with any PostgreSQL provider, and there is nothing provider-specific in the config. The pattern is always the same: point DATABASE_URL at the connection the app should use and DIRECT_DATABASE_URL at the direct one migrations need. Only reach for db.client.pg when the app process needs a pool the resolved URL would not give it.
Supabase:
- Get connection strings from Project Settings → Database
- Use the pooler connection string for
DATABASE_URLand the direct one forDIRECT_DATABASE_URL
Railway:
- Provision a PostgreSQL plugin
- Copy the connection string from the Variables tab
- Use the same pooled/direct split as Neon
Render:
- Create a PostgreSQL database
- Use the external connection string for
DIRECT_DATABASE_URL(migrations) and a pooled connection forDATABASE_URL(app)
Docker Deployment (Self-Hosting)
For deploying to your own infrastructure, point DATABASE_URL at your Postgres instance and run pnpm migrate:deploy as part of your release process. Nothing else changes. If your Postgres is behind a pooler that cannot run DDL, set DIRECT_DATABASE_URL at the direct connection as well, and bind the pooled one with db.client.pg.
Next Steps
Now that your app is deployed:
- Configure a custom domain
- Add team members
- Review the Authentication guide for production auth details
- Revisit Access Control to harden your rules
Summary
You've successfully deployed your Stack application! Here's what you accomplished:
- Created a production PostgreSQL database on Neon
- Configured the pooled-app / direct-CLI environment variable split
- Planned and committed the first migration package
- Deployed to Vercel with automatic deployments
- Applied versioned migrations with
prisma db migrate - Verified your deployment and access control are working
Your app is now live and ready for users. Any pushes to your main branch will automatically deploy to production.