Preview environments are the dev loop
Every place I've worked plumbed everything to one shared dev environment. One database, one deploy, everyone's half-finished work landing in the same place. The database slowly filled with test accounts named asdf and orders for negative dollars, until someone declared bankruptcy and refreshed it. Then it filled up again. I've triggered more than one of those refreshes myself.
Exactly one company I worked at had per-feature environments, and they were narrow: a preview for the GraphQL schema and nothing else. The app, the database, the auth flow, all of it still pointed at shared dev. The one thing they isolated was the one thing that was easy to isolate.
Lionshead does the opposite. Every pull request gets its own running app AND its own database. Not a mock. Not a shared dev instance with a "please don't touch" Slack message. A real Next.js deploy talking to a real Postgres database that was branched, schema and data, from the parent in under a second and will be destroyed the moment the PR closes.
The point isn't fancy infrastructure. The point is that local, preview, and production are a stack, not a substitution. Local is where I write the code. Production is where users live. Preview is the rung in between, where a change proves it actually runs before anyone trusts it. Kill that middle step and every review becomes "looks good to me" against a diff, or a push-and-pray to shared dev. The preview environment is where the dev loop closes.
Here's what happens when I open a PR.
Neon branch, created or refreshed. A copy-on-write branch named preview/pr-<number> is cut from the database's parent branch. Copy-on-write means it's not a dump-and-restore; Neon clones the storage layer by reference and only diverges on write, so the clone itself is effectively instant regardless of how much data is in the parent. The step is idempotent by branch name, so every new commit on the PR refreshes the same branch instead of piling up new ones.
Atlas applies the PR's schema. Lionshead manages Postgres schema declaratively with Atlas. Against the fresh branch, atlas schema apply reads the desired schema, diffs it against what's live, and applies the difference. The branch now has exactly the schema this PR proposes, running against real Postgres, not a SQLite stand-in or a mock that lies to you about what a foreign key does.
Vercel builds and deploys against that branch. The build runs with the per-PR DATABASE_URL injected, so anything that touches the database at build time sees this PR's branch. Then vercel deploy --prebuilt ships the artifacts with the same URL wired in at runtime. The reviewer gets a link to a live app, and that app is talking to this PR's database and no one else's.
A sticky comment carries the link. One PR comment, updated in place on every commit, holds the latest preview URL and a short per-commit history. No comment spam, one place to click.
On close, the branch is deleted. When the PR merges or closes, a cleanup job deletes the Neon branch and confirms it's actually gone. The junkyard never accumulates, because the junkyard is thrown away every time.
All of this lives in one reusable workflow, lionshead-vercel-preview-deploy.yml, that every product repo calls. There's a hard-won detail buried in it worth surfacing: it's a single job, not three, because GitHub Actions refuses to pass a step output between jobs when the value looks like a secret. A Postgres connection string contains a password, so a clean "branch job hands the URL to a deploy job" design gets the URL silently scrubbed to an empty string. The fix is unglamorous: keep the URL inside one job. Most of the good infrastructure decisions I make look like that.
What it costs
Nothing worth mentioning. Neon is on the free plan. Vercel just moved to the $20/month Team plan. That's the whole bill for this capability.
The reason it's free is the same reason it's fast. Neon bills for storage and compute, not for the number of branches, and an idle branch scales its compute to zero. A per-PR branch that nobody is actively hitting costs effectively nothing while it sits there, and it shares the parent's storage by reference until it's written to. So "a database per pull request" sounds extravagant and turns out to be a rounding error.
What it has actually caught
I'll be honest about this the way I was honest about the security tooling: most bad migrations never reach a PR, because I run migrations against a local Docker Postgres before I push. The preview branch isn't where I catch a typo'd column.
Where it earns its keep is the full-workflow bugs that are miserable to test locally. Sign-up and sign-in against Firebase, for instance, are genuinely hard to exercise on a laptop; on a real preview URL with a real database, I can watch an account get created and see exactly what we persisted, including the times we captured a wrong or incomplete value. And I can watch all of that happen without dumping a single piece of that test garbage into the shared dev database.
That's the quieter, bigger win. In every corporate project I've seen, the dev database degrades into a landfill of fake accounts and abandoned test data, until it looks nothing like production and stops being useful for real testing. Per-PR branches give the trash somewhere to live and die. The messy test data stays on the ephemeral branch that gets deleted on merge, and dev stays clean enough to keep nudging it toward looking like prod. Cleaner dev means more focused development, which means bugs that show up in testing are more likely to be real.
Why almost nobody does this
I've said before that I haven't seen this pattern at solo scale anywhere else, and I still haven't. I've seen it exactly once at corporate scale.
The reason isn't cost or difficulty. It's that the enabling primitive is rare. Plenty of databases advertise "branching," but they don't mean the same thing. PlanetScale's branching is built for schema changes; its branches don't clone your data. Supabase spins up an empty database and replays your migrations and seed script. Both are useful, neither gives you a full copy of real, production-shaped data per pull request. Neon's copy-on-write branch does, in about a second, for free, and I have not found another managed provider that does the same. Most people simply don't know that per-PR data isolation this cheap is even on the menu.
The gotchas (because there are always gotchas)
Two of these bit me last week, so they're fresh.
The 10-branch cap. The Neon free plan caps a project at ten branches. I opened several PRs in quick succession, left a few in draft, and every one of them dutifully cut a branch. I hit the ceiling, and preview deploys started failing across the board. The fix was to be stricter about which PRs earn a database: filter the workflow so draft and not-yet-ready PRs don't provision a branch until they're actually up for review. The capability was never the problem. My discipline about triggering it was.
Orphaned branches from a skipped cleanup. The nastier one. A concurrency-rule interaction was canceling the cleanup job mid-run when a late push raced a merge, so the branch-delete never executed and branches leaked, which of course marched me straight back into the 10-branch cap. The fix was giving the cleanup job its own concurrency group that never cancels in progress, plus making the delete step actually authenticate instead of failing silently while reporting green. A teardown, once it starts, has to finish. That one cost me an afternoon and a good deal of "why do I have eleven branches when I have three open PRs."
It's slow, though not the part you'd guess. Full transparency: the preview environment is by far the longest thing on the PR. The deploy job runs about five minutes, and the whole preview pipeline lands closer to ten once you count waiting on sibling checks. I pulled the step timings across recent runs to see where those five minutes actually go, and almost none of it is the database. Creating or refreshing the Neon branch takes 10 to 15 seconds on a cold create and effectively nothing when it's just refreshing an existing one; the copy-on-write clone is instant, and the seconds that do pass are spent standing up the branch's compute endpoint, not copying data. Applying the schema with Atlas adds another 10 to 15 seconds, or nothing when there's no diff. The remaining four-plus minutes are all Vercel: about 20 seconds to install the CLI, then three and a half to four minutes for the Next.js production build, then about 25 seconds to upload the artifacts. So the database work tops out around half a minute and is usually far less. Building and shipping a whole app per PR is the slow part, and it would be slow with or without the branch. Everything else on the PR finishes while the build is still going. I think that's an acceptable price for a real running app on every change, but I won't pretend it's instant.
None of these are reasons not to do it. They're the tax you pay once, learn from, and encode into the workflow so the next repo inherits the fix instead of rediscovering the bug. Which, if you've read the last post, you'll know is a lesson I keep having to learn.
What's next
The preview deploy needs secrets, a Vercel token, a Neon key, to do any of this. For a long time that meant long-lived tokens sitting in repository secrets, which is exactly the kind of thing the security post frets about. The next note in this sub-series is how that got fixed: OIDC through Doppler and GitHub Actions, and how short-lived, federated credentials killed the long-lived token entirely.
In this series: Building Lionshead
- building-lionshead
- ci-cd
- databases