Vercel taught a generation of developers that every pull request deserves its own URL. Push a branch, get a *.vercel.app link in the PR comments, click through a live version of the change — it feels free, because nobody on the team ever sees the meter running.
But the meter is running: per-seat Pro plans, build minutes, bandwidth, function executions. The moment your backend outgrows serverless functions and you ask "what would this cost on machines we own?", the preview URL stops being a feature checkbox and becomes an architecture question with five moving parts.
Here is the whole answer up front, because this post is a recipe with a bill attached, not a meditation:
| Moving part | Self-hosted answer | What it costs |
|---|---|---|
| Build + trigger | CI builds the branch image on PR open/sync | Build minutes you already pay for |
| Ephemeral runtime | One Kubernetes namespace per PR (preview-pr-123) | Shares existing nodes; ~250m CPU / 512Mi RAM requested per preview |
| Routing | Wildcard DNS + one wildcard TLS cert | One DNS record, one certificate, zero per-PR work |
| Data | Seeded database, copy-on-write branch, or shared staging | From ~$0 marginal to cents per preview-hour |
| Teardown | Delete namespace on PR close + TTL sweeper for orphans | Negative cost — this is where the savings live |
The per-preview formula: one namespace's requests + one database choice + zero new certificates, times N open PRs, bounded by a quota and deleted the hour the PR closes. Everything below prices each term and shows the lifecycle automation that makes it real. All prices below are examples at the time of writing — check current list prices before budgeting.
Why teams price the exit from hosted previews
Hosted previews are priced as an extension of someone else's platform, which is exactly right until your production also wants to leave. A compact survey of the current landscape:
| Platform | Preview story | Where the meter runs |
|---|---|---|
| Vercel | Automatic preview URL per PR, best-in-class DX | Pro ~$20/seat/month plus usage (bandwidth, executions) |
| Netlify | Deploy Previews from PRs/MRs, kept until deleted | Build minutes and bandwidth tiers |
| Render | Preview environments for PRs against your branches | Per-service pricing follows each preview |
| Railway | Usage-based services from ~$5/month in credits | No first-class preview deploys — you wire it yourself |
| Azure Static Web Apps | Pre-production environments per app | Free tier caps you at three alongside production |
None of this is a complaint — for a frontend, staying is rational. The exit math starts working when the workload is a stateful backend, a GPU-adjacent service, or a fleet of AI-agent sandboxes that bills badly on per-execution pricing. At that point you already operate Kubernetes for production; the question is what ten or fifty preview copies cost on the same fleet, and the answer is mostly "a scheduling problem, not a spending problem."
The lifecycle: open, sync, close
Every working implementation converges on the same state machine, whether it is hand-rolled GitHub Actions plus Helm or a packaged tool. The August 2026 community walkthrough that wires Actions, Helm, Ingress, and ExternalDNS together is representative: PR opened → namespace preview-pr-123 created, app deployed, URL commented back; new push → same URL redeployed; PR closed → namespace deleted.
Okteto's Kubernetes preview guide makes the key performance observation: namespace creation is nearly instant and containers are ready in seconds when the image cache is warm. That is dramatically cheaper and faster than per-PR VMs — and the reason the namespace (not the cluster) is the right isolation unit.
In practice the controller looks like this:
- PR opened / labeled. A webhook (or a labeled-event filter, so docs-only PRs skip the fleet) triggers the pipeline with the PR number and head SHA.
- Build once. CI builds the branch image and pushes one tag, e.g.
app:pr-123-<sha>. Previews must never rebuild what CI already built. - Render + apply. Helm or Kustomize stamps the PR number into a namespace, deployment image tag, and ingress host, then applies. Branch names get sanitized to DNS labels first: lowercase,
/→-, truncated to 63 characters —feature/add-oauthbecomespr-123-feature-add-oauth, never the raw branch. - Comment the URL back. A bot posts
https://pr-123.preview.onbex.coon the PR (the same role Uffizzi's GitHub Action and the ArgoCD PR-bot pattern play in packaged setups). - PR synchronized. Same namespace, new image tag, rolling update. The URL is stable across pushes — reviewers bookmark it once.
- PR closed or merged. Delete the whole namespace. One
kubectl delete namespacereaps deployments, services, ingresses, PVCs, and secrets together, which is the entire reason for namespace-per-PR over label-per-PR: teardown is one atomic operation with no inventory to reconcile.
Heavier isolation exists for teams that need it — vCluster provisions a virtual cluster per PR, and Uffizzi packages multi-tenancy plus templating into an internal-developer-platform layer. Both are real answers when previews need cluster-admin-equivalent powers or hard tenant boundaries. For the common case — one team's web service plus workers — a namespace with a quota is the whole requirement, at roughly none of the control-plane cost.
A minimal namespace with guardrails ships alongside every preview:
apiVersion: v1
kind: Namespace
metadata:
name: preview-pr-123
labels:
preview.onbex.co/pr: "123"
preview.onbex.co/ttl: "72h" # sweeper reaps anything older, see belowand a quota in the same namespace caps what one preview can consume:
apiVersion: v1
kind: ResourceQuota
metadata:
name: preview-quota
namespace: preview-pr-123
spec:
hard:
requests.cpu: "2"
requests.memory: 4Gi
persistentvolumeclaims: "2"The quota is the scheduling math made enforceable: no single preview can eat the fleet, and the platform team gets a number — 2 CPUs, 4 GB — to multiply by N when capacity planning.
TLS: one wildcard certificate, never one per PR
Per-PR subdomains plus per-PR certificates is the trap. Let's Encrypt allows roughly 50 certificates per registered domain per week; a team opening sixty PRs in a busy week would rate-limit its own production renewals. The fix is a single wildcard certificate issued once via DNS-01 and referenced by every preview Ingress:
- One
ClusterIssuer(Cloudflare, Route53, any DNS-01 provider) and oneCertificatefor*.preview.onbex.co, stored as a Secret cert-manager renews automatically. - Every preview Ingress sets
tls.secretNameto that shared secret (or copies it per namespace with a reflector) and its ownhost: pr-123.preview.onbex.co. - ExternalDNS or a wildcard
*.previewA-record points the whole subdomain space at the ingress controller once — no per-PR DNS writes on the hot path.
New previews then need zero certificate and zero DNS operations: apply the Ingress and the URL is live over HTTPS. This is the highest-leverage thirty minutes in the entire project.
Data is the hard part: three options with honest prices
Stateless previews are a solved problem; the database is where implementations diverge. Three options cover the space, and the right one depends on what the preview must prove:
| Option | How it works | Per-preview cost | Fidelity | Gotcha |
|---|---|---|---|---|
| Seeded database in the namespace | Postgres container + seed/migration job on deploy | ~$0 marginal (shares node RAM, ~256Mi requested) | Synthetic data only | Seed drift: production-shaped bugs never reproduce |
| Copy-on-write branch (Neon-style) | branches create --name pr-123 from production; isolated endpoint per PR | ~$0 marginal storage (copy-on-write); compute scales to zero at ~$0.106/CU-hour — a preview touched a few hours a week lands in the cents | Real production shape | Cold start of 1–3s after idle; branch sprawl without teardown |
| Shared staging database | All previews point at one staging DB with tenant-ish separation | $0 marginal | Realistic but shared | Cross-PR interference: one migration breaks everyone's preview |
Two non-negotiables cut across all three. First, never branch production data with live PII into a preview without anonymization — the Cloud Posse reference architecture states it as a general rule: production data does not belong in non-production environments, and copy-on-write branching makes violating that rule dangerously easy. Branch from a sanitized snapshot or seed, not from raw production.
Second, the branch lifecycle must be owned by the same close-event that deletes the namespace. Neon-style branches are "virtually free" per branch the way namespaces are virtually free per PR — which is to say they accumulate into real money the moment teardown stops being automatic.
For most teams the honest default is seeded data for frontend-heavy PRs and a copy-on-write branch for migration-touching PRs, chosen per PR by label. That two-tier policy is itself a cost control: the expensive option only runs where its fidelity is load-bearing.
The scheduling math at N = 5, 20, and 50
Now the term the TODO item actually promised: what N previews do to a fleet that also runs production. Fix a typical preview size — requests of 250m CPU / 512Mi RAM, limits of 1000m / 1Gi, one seeded Postgres at 250m / 256Mi — plus a small production footprint of 4 CPUs / 16 GB requested. (Your numbers will differ; the method is the deliverable.)
| Open previews (N) | App requests (CPU / RAM) | + data (seeded) | Fleet requested vs. a 6-core / 64 GB box |
|---|---|---|---|
| 5 | 1.25 CPU / 2.5 GB | +1.25 CPU / 1.25 GB | ~6.5 CPU / ~20 GB — fits one box with production |
| 20 | 5 CPU / 10 GB | +5 CPU / 5 GB | ~14 CPU / ~31 GB — needs a second box or smaller previews |
| 50 | 12.5 CPU / 25 GB | +12.5 CPU / 12.5 GB | Dedicated preview pool territory; production must not share fate |
Three mechanisms keep this civilized on a Cluster-API-managed fleet:
- Requests are the currency, limits are the ceiling. Size preview requests small — they drive scheduling and the table above. Let bursty previews share the slack up to their limits; the fleet bin-packs on requests while limits absorb the demo-day spike.
- PriorityClasses encode who dies first. Production gets
high-priority, previews getpreview-lowwith preemption enabled. When a node fills, the scheduler evicts previews, never production — a preview OOMKill is a Slack message, a production one is an incident. - Quotas bound the blast radius. The per-namespace
ResourceQuotafrom above caps each preview at 2 CPUs / 4 GB no matter what the Helm chart asks for, so the N=50 row degrades into Pending pods (visible, debuggable) instead of starving production (invisible, catastrophic).
Compare against the alternative the team already knows: a long-lived staging environment costs the N=1 row forever, 24/7, whether or not anyone is looking at it. Ten previews at typical PR lifetimes (open ~2 days, active business hours) consume fewer fleet-hours than one permanent staging box — ephemerality is the cost control, which is why teardown deserves its own section rather than a bullet.
Teardown: the feature that pays for the project
Stale preview environments are the cloud-bill equivalent of leaving the lights on: individually trivial, collectively the line item. Practitioners warn about exactly this failure mode — ephemeral environments burn real money when cleanup is manual, and teams routinely apply less resourcing rigor to preview tiers than to production, so the waste hides. The practitioners' consensus is blunt: the cheapest preview is the one deleted the minute its PR closes.
Two layers, both required:
- Synchronous delete on close. The PR-closed webhook deletes the namespace (and the database branch, and removes the PR comment). This handles the 95% case the same second it becomes garbage.
- A TTL sweeper for the other 5%. Webhooks get missed — force-pushes during close, pipeline outages, the demo PR someone keeps open for three weeks. A nightly CronJob lists namespaces carrying the
preview.onbex.co/ttllabel past their deadline and deletes them, posting a warning comment 24 hours before. Label-based selection means production namespaces can never match the selector, which is worth stating explicitly in the runbook.
Measure it, too: a dashboard counting live preview namespaces, their age histogram, and weekly fleet-hours consumed turns "previews feel expensive" into a number the team can argue with. The first time the sweeper reaps a forgotten month-old preview with a GPU-adjacent sidecar, it pays for its own development time.
The honest shape of the answer
Preview environments per pull request are not a product you buy so much as four automations and one discipline: build-once images, namespace-per-PR lifecycle, a wildcard certificate, a per-PR data policy, and teardown with a sweeper behind it.
The capacity math says a team with ten open PRs fits previews alongside production on hardware it already owns. The cost math says the database branch, not the compute, is the line to watch. And operations experience says the sweeper matters more than the provisioner — because every preview system ever built provisions enthusiastically and cleans up reluctantly.
Running this on machines you own is the bet behind Bex.co — the open-source, AI-native Render alternative where a git push gets you a running HTTPS service, preview URLs included, on your own fleet. Star the repo on GitHub or deploy your first app today.
Sources: Vercel/Railway/Render/Netlify preview pricing via vendor docs and community comparisons (2025–2026); GitHub Actions + Helm + Ingress + ExternalDNS walkthrough (Medium, Aug 2026); Okteto preview-environments guide; vCluster ephemeral-PR-environment guide; Uffizzi open-source platform; Neon branching and usage-based pricing (~$0.106/CU-hour compute, ~$0.35/GB-month storage, scale-to-zero); PlanetScale free-tier elimination context (2024); Cloud Posse ephemeral-database-seeding design decision; Codefresh/Octopus unlimited-preview-environments teardown guidance; Speedscale Kubernetes-preview-environments cost analysis; Hacker News "staging is dead" ephemeral-cost discussion; cert-manager DNS-01 wildcard certificate guides.



