launch-quickly

Deployment

Vercel + Neon, in the order that actually works.

This guide was written by performing this deployment — a Fleet app went from local scaffold to a healthy production URL following exactly these steps, and anything the deploy did not require is not in here.

The shape: Neon holds the database, Vercel runs the app, and the app connects as a non-owner role through the pooler while migrations run as the owner over the direct endpoint. Order matters twice, and both orderings are called out below.

1. Create the database

npx neonctl projects create --name my-app --region-id aws-us-east-2
npx neonctl connection-string --project-id <the-new-id>

That connection string is the direct endpoint, connecting as the owner (neondb_owner). Derive the pooled endpoint by adding -pooler to the first host label:

ep-abc-123.c-4.us-east-2.aws.neon.tech          # direct — DDL, migrations
ep-abc-123-pooler.c-4.us-east-2.aws.neon.tech   # pooled — the app at runtime

2. Provision BEFORE migrating

DATABASE_ADMIN_URL="<direct-url>" \
APP_DB_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=')" \
pnpm db:provision

This creates the app role the request path connects as — a role that owns nothing, because a table's owner bypasses its own RLS policies and every tenancy guarantee in this template depends on the app not being the owner. The script verifies that property and refuses if it does not hold.

It must run before the first migration: it grants default privileges, which apply only to tables created afterwards. Provision an already-migrated database and the script will tell you, but the fix is per-table grants by hand — do it in the right order instead.

Run it against the direct endpoint. DDL through a transaction pooler is unreliable, and the script rejects a pooler URL for that reason.

3. Migrate

DATABASE_ADMIN_URL="<direct-url>" pnpm db:migrate

Note what this implies for later: Vercel builds do not run migrations. Every schema change you deploy needs this command run against production — before or immediately after the deploy — or production serves code that queries a column that does not exist. Wire it into CI when that gets old.

4. Create the Vercel project and its environment

npx vercel link --yes --project my-app --scope <your-team-slug>

(--scope is required in non-interactive mode when your account belongs to more than one team — the CLI lists the choices if you omit it.)

Then the environment. Two flags matter: production, and --no-sensitive — a sensitive variable cannot be read back by vercel build, which surfaces as the build failing env validation with a [SENSITIVE] placeholder rather than anything explaining itself.

echo -n "postgresql://app:<APP_DB_PASSWORD>@<pooled-host>/neondb?sslmode=require" \
  | npx vercel env add DATABASE_URL production --no-sensitive
echo -n "<direct-url>" | npx vercel env add DATABASE_ADMIN_URL production --no-sensitive
openssl rand -base64 32 | tr -d '\n' | npx vercel env add BETTER_AUTH_SECRET production --no-sensitive
echo -n "https://my-app.vercel.app" | npx vercel env add NEXT_PUBLIC_APP_URL production --no-sensitive

DATABASE_URL is the app role over the pooler; DATABASE_ADMIN_URL the owner over the direct endpoint. The app serves traffic on the first and never DDLs over it.

Auth needs real OAuth credentials before anyone can sign in: create a GitHub (and/or Google) OAuth app whose callback URL is https://my-app.vercel.app/api/auth/callback/github, and set AUTH_GITHUB_ID / AUTH_GITHUB_SECRET the same way. Until then the site deploys and renders — sign-in is the only door that stays shut.

5. Deploy and verify

npx vercel deploy --prod --yes
curl https://my-app.vercel.app/api/health

The deploy is not done when Vercel says so. It is done when /api/health returns {"ok":true,"database":"reachable",...} — that is a real round-trip from the deployed function through the pooler to your database as the app role. A 503 here with database: "unreachable" means the connection string, the pooler, or the role — nothing else in the app will work either, and this one URL tells you before a user does.

Two operational rules, both learned the hard way:

  • To pick up changed env vars, deploy current main. Never vercel redeploy a past deployment — the list is not ordered the way you expect, and redeploying "the latest" once rolled production back weeks.
  • Rate limiting reports "rateLimit":"memory" until Upstash is configured — functional, but per-instance. Set UPSTASH_REDIS_* before you have traffic worth abusing.

What this looked like for real

The first production deploy of this guide's source app: Neon project created, provision verified "app cannot bypass row-level security", 16 migrations applied, seven env vars set, one deploy — and /api/health answered {"ok":true,"database":"reachable","rateLimit":"memory"} on the first try. The only placeholder left was OAuth, which is the one step that needs a human with a browser.

On this page