Lesson 2 — From green repo to a live production URL
Wire Vercel, Neon, env validation, and preview-deploy protection onto the starter, then walk the launch checklist to ship the production URL the rest of the chapter targets.
The last few chapters built a shipping discipline: small reviewable PRs, a CI gate that goes green before merge, Vercel deploying every push against a per-PR Neon branch, and the expand-migrate-contract cadence for changing a schema you cannot take offline. This project puts all of it to work on the invoices SaaS you have grown across the course — a URL-state list, soft delete, and version-based concurrency, now behind a Better Auth sign-in. Your job has two parts. First, ship it to a live *.vercel.app URL, where the git push is the deploy and production is just an alias over an immutable build. Then fix a schema anti-pattern it ships with: a single total numeric(12,2) NOT NULL column fuses the line subtotal and the tax, with no way to read them apart. You split it into separate subtotal and tax columns through three reviewed PRs — expand, migrate, contract — against a live database, with the running app and the live schema never once incompatible. The chapter closes by rehearsing the production rollback against the most dangerous of those PRs, so the move is in your hands before an incident demands it.
The inspector is what makes the migration visible: a read-only panel, provided in full, that probes the live schema and live data, so every claim the cadence makes is something you can see rather than take on faith.
A few ideas recur in every lesson of this chapter.
main; a merge to main deploys to production against the production database. No one ever clicks “deploy.”pnpm db:migrate runs inside the build against the PR’s Neon branch, so a failed migration fails the build. You merge only once the rehearsal checklist is green on that preview.git revert on main, rehearsed against the contract PR. The alias recovers the code at once, but a forward-only migration does not roll back with it.flowchart LR
subgraph rehearsal["Rehearsal stage — per PR"]
direction LR
push["git push<br/>(PR branch)"]
preview["preview deployment<br/>Neon branch off main"]
build["pnpm db:migrate<br/>&& next build"]
gate{"green CI +<br/>rehearsal<br/>checklist?"}
push --> preview
preview --> build
build --> gate
end
merge["merge to main"]
prod["production deployment<br/>Neon main branch"]
gate -- "yes" --> merge
merge --> prod
class push,merge action
class preview,build step
class gate check
class prod production
classDef action fill:#1f2937,stroke:#94a3b8,color:#f8fafc
classDef step fill:#dbeafe,stroke:#1d4ed8,color:#111
classDef check fill:#fef3c7,stroke:#b45309,color:#111,stroke-width:2px
classDef production fill:#dcfce7,stroke:#15803d,color:#111,stroke-width:2px The starter is the full invoices app; most of the tree is code you already know and never touch. The bolded files are the only ones the migration touches: the money column, the surfaces that read or write it, and the runbook stubs you fill as you go. The inspector ships complete, so you watch the migration rather than build it.
total only, TODO(L3/L4/L5) markersdb + dbUnpooledwithTenant + tenantDb facadetotal; TODO(L4) dual-read coalesce, TODO(L5) drop totaltotal; TODO(L4) dual-write, TODO(L5) contractcombinedAmount helper, you create it in PR 2requireOrgUserResult<T> shapeTODO(L4) split inputs, TODO(L5) retire combined/api/health db ping@t3-oss/env-nextjs boundary, fails the build on a missing varLesson 2 — From green repo to a live production URL
Wire Vercel, Neon, env validation, and preview-deploy protection onto the starter, then walk the launch checklist to ship the production URL the rest of the chapter targets.
Lesson 3 — PR 1 (Expand): add the nullable subtotal and tax columns
Ship an additive-only migration that adds subtotal and tax as nullable columns, and confirm the unchanged app stays healthy against the expanded schema.
Lesson 4 — PR 2 (Migrate): dual-write, backfill, dual-read
Add the dual-write to the actions, the coalesce fall-through to the queries, the idempotent backfill, and the NOT NULL promotion, all while production keeps serving.
Lesson 5 — PR 3 (Contract): drop the old column, promote the new pair
Drop total, remove every legacy reference, and land production on the target schema with the migration’s safety guarantees intact.
Lesson 6 — Rollback rehearsal and the schema caveat
Roll back to the previous deployment against the contract PR to see why an alias rollback does not undo a migration, then write the durable runbook.
This lesson ends with the starter running locally against a Docker Postgres. No accounts or deploy yet: the Vercel project, the Neon integration, and the real values come next lesson. Every key in .env.example ships with a working local placeholder, so nothing here needs an external account to boot.
Get the starter codebase from the project repository, under Chapter 100/start/.
Install dependencies.
pnpm installStart the local Postgres container, copy the environment template, then migrate and seed the database.
docker compose up -dcp .env.example .envpnpm db:migrate && pnpm db:seedStart the dev server.
pnpm devThe local environment keys, all pre-filled in .env.example:
| Variable | Purpose |
|---|---|
DATABASE_URL / DATABASE_URL_UNPOOLED | Pooled and unpooled Postgres connections, identical locally; the split matters on Neon, where migrations need the direct one. |
BETTER_AUTH_SECRET / BETTER_AUTH_URL | Dev auth secret and base URL. |
RESEND_API_KEY | Required by the env validator and launch checklist, but the project sends no email, so the placeholder is never called. |
SENTRY_DSN | A launch-checklist value, not a wired package; the placeholder is enough to boot. |
APP_URL | The app’s own base URL, http://localhost:3000 locally. |
NEXT_PUBLIC_APP_NAME / NEXT_PUBLIC_APP_URL | The two client-exposed values. |
Expected result. pnpm dev serves the app at http://localhost:3000. The seed creates two orgs and five users, all with the password inspector-password-12; sign in as alice@acme.test, an Acme admin and a good default. The invoices at /invoices and the inspector at /inspector both read the seeded total column, since you have not split it yet.