Skip to main content

Every Pull Request Gets Its Own URL: The Real Recipe and Cost Math for Preview Environments on Self-Hosted Kubernetes

12 min readDora NodaDora Noda
Share
On this page

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 partSelf-hosted answerWhat it costs
Build + triggerCI builds the branch image on PR open/syncBuild minutes you already pay for
Ephemeral runtimeOne Kubernetes namespace per PR (preview-pr-123)Shares existing nodes; ~250m CPU / 512Mi RAM requested per preview
RoutingWildcard DNS + one wildcard TLS certOne DNS record, one certificate, zero per-PR work
DataSeeded database, copy-on-write branch, or shared stagingFrom ~$0 marginal to cents per preview-hour
TeardownDelete namespace on PR close + TTL sweeper for orphansNegative 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:

PlatformPreview storyWhere the meter runs
VercelAutomatic preview URL per PR, best-in-class DXPro ~$20/seat/month plus usage (bandwidth, executions)
NetlifyDeploy Previews from PRs/MRs, kept until deletedBuild minutes and bandwidth tiers
RenderPreview environments for PRs against your branchesPer-service pricing follows each preview
RailwayUsage-based services from ~$5/month in creditsNo first-class preview deploys — you wire it yourself
Azure Static Web AppsPre-production environments per appFree 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:

  1. 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.
  2. 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.
  3. 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-oauth becomes pr-123-feature-add-oauth, never the raw branch.
  4. Comment the URL back. A bot posts https://pr-123.preview.onbex.co on the PR (the same role Uffizzi's GitHub Action and the ArgoCD PR-bot pattern play in packaged setups).
  5. PR synchronized. Same namespace, new image tag, rolling update. The URL is stable across pushes — reviewers bookmark it once.
  6. PR closed or merged. Delete the whole namespace. One kubectl delete namespace reaps 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:

yaml
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 below

and a quota in the same namespace caps what one preview can consume:

yaml
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 one Certificate for *.preview.onbex.co, stored as a Secret cert-manager renews automatically.
  • Every preview Ingress sets tls.secretName to that shared secret (or copies it per namespace with a reflector) and its own host: pr-123.preview.onbex.co.
  • ExternalDNS or a wildcard *.preview A-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:

OptionHow it worksPer-preview costFidelityGotcha
Seeded database in the namespacePostgres container + seed/migration job on deploy~$0 marginal (shares node RAM, ~256Mi requested)Synthetic data onlySeed 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 centsReal production shapeCold start of 1–3s after idle; branch sprawl without teardown
Shared staging databaseAll previews point at one staging DB with tenant-ish separation$0 marginalRealistic but sharedCross-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
51.25 CPU / 2.5 GB+1.25 CPU / 1.25 GB~6.5 CPU / ~20 GB — fits one box with production
205 CPU / 10 GB+5 CPU / 5 GB~14 CPU / ~31 GB — needs a second box or smaller previews
5012.5 CPU / 25 GB+12.5 CPU / 12.5 GBDedicated 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 get preview-low with 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 ResourceQuota from 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:

  1. 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.
  2. 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/ttl label 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.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex