Skip to main content

Ephemeral Preview Environments on Kubernetes: A Namespace-Per-PR Recipe for a Git-Push PaaS

9 min readDora NodaDora Noda
Share

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.

yaml
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=true

That 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:

yaml
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 PRsRequested CPURequested memoryNodes needed (CCX23)Approx. monthly cost
10 (small team)10 vCPU10Gi1~€45
25 (active team)25 vCPU25Gi2~€90
50 (busy monorepo)50 vCPU50Gi4~€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.

yaml
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: 53

That'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:

yaml
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: OnFailure

Every 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.

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