67% of enterprises say they're investing in ephemeral environments this year, and the reason is simple: a reviewer who has to read a diff and imagine what it does is a reviewer who approves things they shouldn't. A live URL that expires when the PR closes has gone from "nice if it works" to something some compliance frameworks now expect as evidence a change was actually tested before merge.
Here's the recipe, the real cost of running it, and the guardrails that keep it from eating your cluster — in that order, because the recipe is the part everyone skips past to get to the demo screenshot.
The Recipe: ArgoCD ApplicationSet's PullRequest Generator
The de facto pattern in 2026 is an ArgoCD ApplicationSet with a pullRequest generator: it polls your Git provider's API for open PRs on a repo, and for each one it materializes an Application from a template. Close the PR, and ArgoCD deletes the Application — and everything it owns — automatically.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: preview-myapp
namespace: argocd
spec:
generators:
- pullRequest:
github:
owner: my-org
repo: myapp
tokenRef:
secretName: github-token
key: token
requeueAfterSeconds: 60
template:
metadata:
name: "myapp-pr-{{number}}"
spec:
project: previews
source:
repoURL: https://github.com/my-org/myapp.git
targetRevision: "{{head_sha}}"
path: deploy/preview
helm:
parameters:
- name: image.tag
value: "pr-{{number}}"
- name: ingress.host
value: "pr-{{number}}.previews.example.com"
destination:
server: https://kubernetes.default.svc
namespace: "preview-{{number}}"
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=trueThat namespace: preview-{{number}} line is the whole trick — every PR gets its own namespace, named after the PR number, created on demand. Inside it, the Helm chart at deploy/preview renders the usual three objects — Deployment, Service, Ingress — plus one that most quick-start guides leave out:
apiVersion: v1
kind: ResourceQuota
metadata:
name: preview-quota
namespace: "preview-{{number}}"
spec:
hard:
requests.cpu: "1"
requests.memory: 1Gi
limits.cpu: "2"
limits.memory: 2Gi
pods: "6"Without that quota, one PR that spins up a debug sidecar and forgets to bound it can starve every other preview sharing the node. With it, you've now got a knowable, per-PR resource footprint — which is what makes the next section possible to compute instead of guess at.
Where this lives on a git-push PaaS
On a platform like Bex, this ApplicationSet isn't something a customer writes by hand — it's a control-plane resource the platform owns, one per app, created the moment an App custom resource is registered. The customer's only interaction is the git push and the PR itself; the platform's reconciler is what turns a GitHub webhook into a namespace, a quota, and a live URL in the PR's checks tab. The recipe above is exactly what that reconciler renders — the only difference between "a team wires this up themselves" and "the PaaS just does it" is who owns the ApplicationSet and who's on call when it misbehaves.
What It Actually Costs
A ResourceQuota turns "how much does preview infra cost" from a shrug into arithmetic. Take the quota above — 1 CPU / 1Gi requested per preview, 2 CPU / 2Gi as the ceiling — and price it against Hetzner dedicated vCPU instances, since that's what a self-hosted PaaS is typically bin-packing onto.
A Hetzner CCX23 (4 vCPU, 16GB RAM, dedicated core) runs roughly €40–45/month post-2026-repricing. Using requested resources (what the scheduler actually reserves, not the burst ceiling) as the packing unit, one CCX23 fits about 12–14 preview namespaces before requests saturate the node's CPU:
| Concurrent open PRs | Requested CPU | Requested memory | Nodes needed (CCX23) | Approx. monthly cost |
|---|---|---|---|---|
| 10 (small team) | 10 vCPU | 10Gi | 1 | ~€45 |
| 25 (active team) | 25 vCPU | 25Gi | 2 | ~€90 |
| 50 (busy monorepo) | 50 vCPU | 50Gi | 4 | ~€180 |
Two things fall out of that table that don't show up in a single "here's what it costs us" anecdote. First, the curve is linear and cheap at realistic volumes — even a monorepo running 50 concurrent previews is a rounding error next to what those same previews would cost as fifty always-on staging-tier instances on a managed platform. Second, the number that actually drives cost isn't PR volume, it's PR lifetime: a repo where PRs average 18 hours open behaves completely differently from one where stale branches sit open for three weeks, because the quota is reserved for as long as the namespace exists, not as long as anyone's looking at it. That's the variable the guardrails section exists to control.
Render and Railway Give You This for Free — What You Own Instead
If you've used Render or Railway, none of the above should feel novel — that's the point. Push a branch, open a PR, get a live URL; close the PR, it disappears. Railway goes further with Focused PR Environments, which inspect the changed files in a monorepo and only redeploy the services that PR actually touches, cutting both build time and idle resource use. Render's version auto-provisions straight from a render.yaml blueprint and tears down on merge with no extra config.
The user-facing behavior is identical to the namespace-per-PR recipe above. What's different is who's holding the pager. On Render or Railway, the reconciliation loop — detect the PR, provision, route traffic, detect the close, tear down — is a managed feature you're renting. On a self-hosted, Kubernetes-based PaaS, that loop is code you (or your platform vendor) run, on a cluster you own. You get the ApplicationSet YAML instead of a checkbox — which also means you get the failure modes: an ApplicationSet generator that stalls because a GitHub token expired, a preview namespace that doesn't get created because CreateNamespace=true silently failed a webhook admission check, an Application stuck in OutOfSync because the target revision moved out from under it. None of that is exotic — it's the same class of problem as running any other controller — but it's the tradeoff a self-hosted PaaS is explicitly signing up for in exchange for owning the machines the previews run on.
Guardrails: Network Policies, Quota Caps, and TTL Teardown
The ResourceQuota from the recipe caps how big one preview can get. Two more controls cap how long it lives and what it can reach — without them, "busy repo's previews starve the shared cluster" isn't a hypothetical, it's just a matter of which stale branch does it first.
Network policy: deny by default, allow DNS. Every preview namespace should start from zero trust between namespaces — a bug in PR #482's app has no business being able to reach PR #481's database, let alone anything in production.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: "preview-{{number}}"
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53That's the whole policy: deny all ingress and egress except DNS resolution to kube-system. Everything else — reaching the ingress controller, talking to a shared preview database — gets its own explicit allow rule, which is more YAML than a single "allow everything" policy but means a compromised preview pod has nowhere to pivot to.
TTL teardown: don't trust PR close events alone. ArgoCD deletes the Application when a PR closes — but branches get abandoned without ever being formally closed, CI can fail to fire the webhook, and a generator's requeueAfterSeconds window means there's always some lag. A belt-and-suspenders CronJob catches what the event-driven path misses:
apiVersion: batch/v1
kind: CronJob
metadata:
name: preview-ttl-reaper
namespace: argocd
spec:
schedule: "0 * * * *"
jobTemplate:
spec:
template:
spec:
serviceAccountName: preview-reaper
containers:
- name: reaper
image: bitnami/kubectl:1.31
command:
- /bin/sh
- -c
- |
for ns in $(kubectl get ns -l app.kubernetes.io/preview=true -o name); do
created=$(kubectl get $ns -o jsonpath='{.metadata.creationTimestamp}')
age_hours=$(( ($(date +%s) - $(date -d "$created" +%s)) / 3600 ))
if [ "$age_hours" -gt 72 ]; then
kubectl delete $ns
fi
done
restartPolicy: OnFailureEvery preview namespace is labeled at creation (app.kubernetes.io/preview=true), and anything older than 72 hours — the TTL a team decides fits their review cadence — gets deleted regardless of whether ArgoCD ever heard a "PR closed" event. This is the control that actually answers the cost question from the section above: cap lifetime at 72 hours and even a repo that never closes its PRs on time can't accumulate more than three days' worth of open-preview cost.
Combine the three controls — quota bounds size, network policy bounds blast radius, TTL bounds lifetime — and "namespace per PR" stops being a demo trick and becomes something you can run against a repo with real PR volume without babysitting it.
Where This Goes Next
None of the three guardrails above are Kubernetes-specific ideas — they're the same size/blast-radius/lifetime constraints any multi-tenant system needs, just expressed as a ResourceQuota, a NetworkPolicy, and a CronJob because that's the vocabulary Kubernetes gives you. As Kubernetes' own scheduling and validation primitives keep absorbing work that used to require bespoke controllers — sharded list/watch for control planes serving more tenants, CEL-based declarative validation instead of hand-rolled admission webhooks — the amount of custom reconciler code a platform has to write and operate for something like namespace-per-PR previews keeps shrinking. The direction of travel favors whoever owns the cluster, not whoever's renting API access to one: the guardrails get cheaper to run every release, while the managed-platform version stays exactly as expensive as its pricing page says.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with the same PR-preview experience described above built on Cluster API instead of rented. Star the repo on GitHub or deploy your first app today.