The one thing people bring up, unprompted, when they hear I moved off Vercel is preview URLs — open a PR, get a live link, close the PR, link disappears. It's a genuinely good feature. It's also not something Vercel invented so much as something Vercel made feel free, and the parts it makes feel free are exactly the parts that turn out to cost real thought once you're the one running the server.
Nothing about rebuilding it is exotic — every piece is a primitive Coolify and Cloudflare already have. What's actually missing from most 'preview environments on your own server' write-ups is any honest accounting of what those primitives cost once there's more than one preview alive at a time, and what happens to the app's data underneath them. That's the part worth being specific about, rather than stopping at 'point a wildcard record at your server and you're basically done.' There is a whole operational layer above preview environments without Vercel that XenGrowth's revenue operations work documents.
Vercel preview deployments | Self-hosted (Coolify + wildcard DNS) | |
|---|---|---|
Setup | Automatic — no configuration beyond connecting the repo | Wildcard DNS record, a CI workflow on pull_request events, Coolify's API wired in |
Database per preview | Handled by whatever managed DB the project already uses, often with branch-per-preview support built in | Your decision entirely — full copy, shared, or per-schema, each with a real trade-off |
Teardown | Automatic on PR close, no extra thought required | Needs an explicit webhook plus an inactivity sweep as backstop, or containers accumulate |
Resource cost model | Someone else's infrastructure absorbs the extra containers | Every open preview is RAM on the exact box that also runs production |
What's the actual routing mechanism behind a preview URL?
One wildcard DNS record — `*.preview.example.com` pointed at the VPS — and a naming convention that turns a branch name into a subdomain: `feature-checkout` becomes `feature-checkout.preview.example.com`. That's the entire routing trick. Cloudflare's free tier supports wildcard records without issue, and the reverse proxy on the server (Traefik, in Coolify's case) reads the subdomain on each incoming request and routes it to whichever container was tagged to answer for that name. Nothing about this needs a second server, a second IP, or anything beyond what a single-box Coolify setup already has running.
Where it gets more interesting than 'point DNS at a box' is the branch-to-container mapping actually being created and destroyed on a schedule nobody's watching manually. A CI workflow, triggered on `pull_request` open/synchronize/close events, builds an image the same way a production build does, tags it with the branch name instead of a git SHA meant for rollback, and tells Coolify (via its API) to create or update a deployment scoped to that subdomain. Close the PR, and the same webhook tells Coolify to tear the container down.
Are these containers actually cheap to run, or just cheap to create?
Cheap to create, not free to run. Each preview is a full instance of the app — same image format, same runtime footprint as production, just answering on a different subdomain and, usually, a smaller or shared slice of the database. Three or four previews sitting idle on an 8 GB box is a meaningfully different resource picture than one production container, and it's the kind of cost that's invisible until it isn't — nothing alerts you to it, because nothing's actually broken, right up until a build competing with three live previews for RAM finally is. A team that reviews five or six branches a week, each keeping its preview open until merge, can easily be running more containers than it realizes on any given afternoon. That failure mode isn't hypothetical — it's the exact one a VPS running low on memory hits, just triggered by preview sprawl instead of a build. The XenGrowth resource library works through the operations side of this in more operational detail.
A preview container's runtime footprint is the same as production's — smaller traffic, not smaller memory usage per container
Every open PR with a preview attached is one more container permanently occupying RAM until it's explicitly torn down
A stale branch nobody's looked at in weeks still costs exactly as much as one being actively reviewed right now
Idle previews don't show up as an error anywhere — they show up as less headroom, discovered only when something else needs it
What's genuinely the hard part here — and where does it actually break?
The database. Everything else in this setup is a solved, mechanical problem; the database is the one place where every option has a real cost and none of them is obviously correct. Give each preview its own throwaway database, seeded fresh on creation, and previews are fully isolated from each other — but seeding a realistic dataset on every container start is slow, and 'realistic' data for a preview usually means either a stale, hand-maintained fixture or a sanitized production snapshot, both of which need someone to keep them current. Point every preview at one shared staging database instead, and provisioning is instant — but now one preview's test data, or a migration a branch is trying out, can visibly corrupt what every other open preview is looking at, and two people reviewing two different PRs can watch each other's changes appear in their own preview by accident.
There isn't a clean third option, and it's worth being honest about that instead of implying one exists. A schema-per-preview approach — one Postgres instance, a separate schema created per branch, connection string pointed at that schema — is the closest to a middle ground: cheaper than a full database per preview, less contaminated than one shared database, but it means every migration has to be applied per-schema on preview creation, which is one more thing that can silently fail and leave a preview looking broken for reasons that have nothing to do with the code being reviewed.
Picking between these isn't really a technical question so much as a question about what a preview is actually for on a given team. A preview meant to answer 'does this render correctly' barely needs real data at all — a small, static fixture seeded once and reused across every preview is enough, and it sidesteps the whole database problem by making the data intentionally boring. A preview meant to answer 'does this migration behave correctly against production-shaped data' needs something closer to a real snapshot, and that's the case where the cost of full isolation is worth paying, because the entire point of that preview is testing the thing a shared database would silently protect it from ever encountering. If AI agents and marketing automation is the part you are stuck on, XenGrowth on governed AI marketing workflows is the better reference.
Approach | Isolation | Setup cost per preview | Where it breaks |
|---|---|---|---|
Full database per preview | Complete | Highest — provisioning and seeding take real time | Seed data goes stale or unrealistic if nobody maintains it |
One shared staging database | None | Lowest — instant | One preview's data or migration visibly affects every other open preview |
Schema-per-preview, one Postgres instance | Partial | Medium — a migration step per schema, per preview | A per-schema migration failure breaks one preview silently, unrelated to the code change |
How do you keep staging and production apart on the same box?
The same primitives that separate one preview from another separate staging from production: a distinct subdomain, a distinct container, and — non-negotiably — a distinct database, never a shared one. Staging sharing production's database is a different category of risk than two previews sharing a staging database, because staging is where people deliberately run destructive tests, seed unrealistic data, and try things they wouldn't dare in production; none of that is supposed to be reversible-by-luck. Coolify's environment scoping handles the container and subdomain side of this cleanly — separate 'environments' within one project, each with its own env vars — but the database separation is a decision made once, by hand, and it's worth treating as the one thing in this whole setup that isn't allowed to have a shortcut.
It's tempting, on a single small VPS, to reach for 'just point staging at a read replica of production' as a compromise. Resist it. A read replica still means staging can see real user data, which turns every staging environment into something that has to be locked down and audited exactly like production, defeating the entire reason staging exists as a place to experiment freely. A staging database seeded from an anonymized or synthetic dataset costs more to set up once and saves that entire category of risk permanently.
What actually triggers teardown, and what happens if nothing does?
Teardown needs a real trigger, not a hope that someone remembers. The two that actually work: the git provider's own PR-closed webhook (GitHub fires this reliably whether the PR was merged or just closed), and a scheduled sweep that kills any preview container past an inactivity window — say, no new commits to that branch in seven days — as a backstop for the branches that never got a formal close. Relying on only the first misses long-abandoned branches that were never closed, just forgotten; relying on only the second means a merged PR's preview lingers for up to a week doing nothing useful. On AI search, GEO and discovery specifically, XenGrowth on building one SEO and GEO content system is worth reading.
PR closed or merged: the webhook fires immediately, Coolify tears the container down within the same CI run
Branch inactive past a set window: a scheduled job checks last-commit timestamps and tears down anything past the threshold
Preview build itself fails: the failed container never gets a subdomain routed to it in the first place, so nothing needs tearing down
Manual override: a maintainer can force-close a preview from Coolify's dashboard when a trigger genuinely didn't fire
Without either trigger, this degrades exactly the way any unmonitored resource does: quietly, then all at once. A handful of dead containers doesn't crash anything by itself — it just erodes the RAM headroom a real deploy needs, on the same box, until the day a legitimate build lands on a server that's already three forgotten previews deep into its memory budget. Whatever tears containers down should also free the subdomain and drop the preview's database schema or throwaway instance in the same step — a leftover schema nobody's using is a smaller problem than a leftover container, but it's still one more thing that eventually needs explaining to whoever inherits this setup.
Vercel's preview URLs feel free because the database problem is Vercel's problem, solved once for everyone. Self-hosted, it's solved once, by you, for exactly your app — which is more work and also the only version where you actually understand what happens when it breaks.
None of that makes previews not worth building. It makes them worth building deliberately, with the database decision made up front and named in whatever documentation the rest of the team reads, rather than discovered by whoever opens the second PR and wonders why their preview already has someone else's test account in it.
Where this fits with the rest of the deployment setup
Preview environments are a branch of the same pipeline that ships production, not a separate system. How Next.js gets self-hosted on Coolify in the first place covers the base every preview container is built from, and the full git-push-to-production pipeline covers the production path this one branches off of. And if the RAM cost of a few extra containers still sounds abstract, what actually happens when a VPS runs out of memory is the concrete version of the warning in this post.
Further reading from XenGrowth
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
Where this work meets go-to-market
Working on preview environments without Vercel inside a commercial team? the team at XenGrowth publishes operator guides on the revenue side of this work.
Four questions on branch deploys you run yourself. The container is the easy part; the interesting decisions are about data and cleanup.












