Skip to main content

Ephemeral Preview Environments Without a SaaS Bill: Namespace-per-PR on Owned Hardware

11 min readDora NodaDora Noda
Share
On this page

Every pull request gets a live URL — until the invoice arrives. Render bills preview environments exactly like production services, prorated by the second, and Vercel's Pro plan throttles you to a single concurrent build unless you pay $40 a month for each extra lane. The moment a team scales past a handful of pull requests a day, "every PR gets a preview" stops being a default and becomes a line item.

Here is the short version, with receipts below: for a typical 8-developer team opening 6 PRs a day, full-stack previews on Render's cheapest seats cost roughly $99 a month — and over $300 on Standard tiers. The same previews on an already-paid ~€45/month Hetzner box cost essentially nothing at the margin, because a preview sits idle for 95% of its life and idle is free on hardware you own. The catch is discipline: stale previews nobody tore down will eat your fleet exactly as silently as they eat your SaaS bill.

Setup (8 devs, 6 PRs/day, 26h avg. preview life)Monthly preview cost
Render, Starter seats (web + worker + Postgres)~$99
Render, Standard seats (web + worker + Postgres)~$329
Vercel Pro (frontend previews, 8 seats + build lanes)$160 base, throttled at 1 concurrent build
Owned Hetzner box (~€45/mo, already running prod)~$0 marginal

How the SaaS meter actually runs

Render's preview environments are the honest version of this pricing: every pull request against your Blueprint's tracked branch spins up a disposable copy of the whole environment — services, databases, environment groups — and "preview resources are billed just like regular Render services and are prorated by the second." There is no preview discount. A Starter web service ($7/month), a Starter worker ($7/month), and a Starter Postgres ($7/month) running as a preview cost the same per second as they do in production. Render does offer an expiry knob (expireAfterDays), and without it previews persist until someone deletes them, accumulating charges at the preview plan rate per service.

Vercel meters differently but throttles harder. A Pro workspace pays $20 per seat per month and gets one concurrent build; each additional concurrent build lane costs $40 a month. The platform allows 6,000 deployments a day on Pro, so the cap is rarely the count — it is the width of the pipe. A team that opens 6 PRs in a morning and pushes fixes to all of them queues behind a single build lane, and the fix is a per-lane subscription, not a one-time payment. Hobby is worse: 100 deployments a day and one concurrent build, which a single active developer can saturate.

Neither model is a scam. Both are usage pricing doing exactly what usage pricing does: charging you for provisioned capacity during the long stretches when a preview sits open in a tab, untouched, waiting for review. The average preview in most teams lives a little over a day — opened in the afternoon, reviewed the next morning, merged after lunch. That is roughly 26 hours of billed lifetime for perhaps 30 minutes of actual human attention.

The math for a typical team

Take the concrete team: 8 developers, 6 pull requests per working day, 22 working days a month, 26-hour average preview lifetime. That is 132 previews a month and 3,432 preview-instance-hours per service (132 × 26).

On Render Starter seats at $7/month each — $7 ÷ 730 hours ≈ $0.0096/hour:

Preview serviceInstance-hours/moCost/mo
Web (Starter)3,432~$33
Worker (Starter)3,432~$33
Postgres (Starter)3,432~$33
Total~$99

Step up to Standard seats — $25/month web and worker, $20/month Postgres — and the same arithmetic lands at roughly $329 a month. Note what is not in either total: production itself, the workspace fee, build minutes, or egress. This is previews alone.

Now the owned-hardware side. A Hetzner AX41-NVMe-class box — 6 cores, 64 GB RAM — runs roughly €40–50 a month flat. If that box already runs production and staging with headroom, where do 6 concurrent previews land? At any moment the team averages about 6–7 live previews (6 PRs/day × 26h ÷ 24h). Each preview wants roughly half a CPU, 512 MB for the app, another 512 MB for the worker, and ~1 GB for a small Postgres — call it 2 GB and 1.2 CPUs of requests. Six of those is ~12 GB of RAM and 7 requested CPUs against a box that has 64 GB and 6 cores.

The RAM fits trivially. The CPU only fits because of the fact that makes this whole comparison work: previews are idle almost all the time. Nobody load-tests a preview; it serves a reviewer clicking through three pages and then sits warm for another day. On Kubernetes with requests set sanely and limits capping bursts, idle previews consume nearly zero actual CPU, and the scheduler packs them into headroom production is not using. The marginal cost of the next preview on owned hardware is effectively zero — until concurrency grows past spare capacity, at which point the cost function is a step (a second box), not a slope.

Sensitivity check, so this is not one flattering data point:

PRs/dayRender Starter previews/moOwned box marginal
2~$33$0
6~$99$0
15~$247$0, then a step when headroom exhausts

At 2 PRs a day the SaaS bill is small enough that the ops work below may not pay for itself — the honest breakeven lives in the last section. At 15 PRs a day you are renting a second Hetzner box every month just to keep review tabs warm.

The wiring: PR webhook to namespace in four hops

The standard self-hosted shape is namespace-per-PR on a Cluster API-managed cluster, and every hop already exists as boring, maintained tooling:

  1. Branch webhook → CI build. A pull request webhook fires CI, which builds the PR head SHA and pushes an immutable image tag (app:pr-427-a1b2c3d). Nothing preview-specific yet — this is the same build you already run for tests.
  2. PR label → ArgoCD ApplicationSet. An ApplicationSet PR generator watches the repo for open PRs carrying a preview label and renders one ArgoCD Application per PR, parameterized by PR number and head SHA. The label is doing real work: docs-only PRs never get the label, never get a namespace.
  3. Application → pr-<n> namespace. The Application deploys the standard Helm chart or Kustomize overlay into a fresh namespace with a preview hostname (pr-427.preview.example.com) via wildcard DNS and the cluster ingress. Postgres comes from one of three strategies, cheapest first: a shared cluster with one database per PR, an ephemeral single-pod Postgres with an emptyDir or small PVC, or a full CloudNativePG instance for previews that need production-shaped data.
  4. Merge or close → prune. When the PR closes, the generator drops the Application and ArgoCD prunes the whole namespace. One controller owns creation and deletion, which is the entire reason this pattern beats a CI script that creates namespaces and hopes a later step deletes them.

The ApplicationSet sketch is small enough to show whole:

yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: pr-previews
  namespace: argocd
spec:
  generators:
    - pullRequest:
        github:
          owner: my-org
          repo: my-app
          labels: [preview]
  template:
    metadata:
      name: "pr-{{ .number }}"
    spec:
      destination:
        namespace: "pr-{{ .number }}"
      syncPolicy:
        syncOptions:
          - CreateNamespace=true

Teams running this in production add two refinements worth copying: expire the preview label itself on a TTL via a scheduled workflow so abandoned PRs lose their environment even if nobody closes them, and post per-PR cost attribution back to the PR (the ephemeractl project wires OpenCost numbers into a PR comment, which does more for teardown culture than any policy doc).

Sandboxed variants exist for teams that need stronger isolation than a namespace — vCluster's virtual clusters per PR, or request-level sandboxes in the Signadot style that share a baseline environment instead of duplicating it. They trade per-preview overhead against fidelity; for a typical web-plus-worker-plus-Postgres stack, a plain namespace is the sweet spot, and the exotic options only pay off past dozens of concurrent previews.

The discipline: TTL teardown or capacity rot

Here is the failure mode the SaaS bill was quietly protecting you from: on Render, a forgotten preview costs dollars, which someone eventually notices. On owned hardware, a forgotten preview costs capacity, which nobody notices until deploys start pending for lack of room. Same disease, quieter symptom. The treatment is a TTL on everything:

Teardown controlWhat it doesWhat rots without it
PR-close prune (ApplicationSet default)Deletes the namespace when the PR merges or closesNamespaces from merged PRs linger forever
TTL label expiry (e.g. 48h, refreshed on push)Kills previews on abandoned PRs nobody closesStale-review previews pile up over weekends and holidays
Image GC (kubelet + registry retention)Evicts PR-tagged images after the namespace diesNode disk fills with hundreds of pr-* image tags
DB-per-PR strategy with matching lifecycleDrops the per-PR database/schema with the namespaceOrphaned databases on the shared cluster grow unbounded
Hostname cleanup (external-dns or ingress-owned records)Removes pr-<n> DNS when the ingress goesDead preview URLs that 404 — or worse, get reissued to a later PR

Two gotchas deserve emphasis because they bite every team once. First, TTL must refresh on push: a PR under active review for three days must not have its environment reaped mid-review because the clock started at creation. Render's own expireAfterDays resets on every push for exactly this reason — copy that semantic. Second, PVCs do not always follow namespaces to the grave: check your storage class reclaim policy and your Postgres operator's finalizers, or every ephemeral Postgres leaves a small orphaned volume behind, and small volumes accumulate into a full disk at 3 a.m.

Get this right and previews become genuinely ephemeral: median namespace lifetime tracks PR lifetime, and a Monday-morning kubectl get namespaces shows only the PRs actually under review.

Where SaaS previews still win

Self-hosting previews is not universally correct, and the breakeven is concrete enough to state. Stay on SaaS previews if your team matches one of these:

  • Frontend-only previews. If the thing under review is a static site or a server-rendered frontend with no backing services, Vercel-style previews are brutally hard to beat: zero cluster to operate, global CDN, and a URL in the PR within a minute of push. Namespace-per-PR only earns its keep when the preview includes stateful services.
  • No baseline box. The owned-hardware math assumes a cluster that already exists for production. If you have no fleet, "spin up Kubernetes to save $99 a month on previews" is backwards — you would spend more operating the cluster than the previews cost.
  • Bursty, tiny teams. At 2 PRs a day the Render Starter total is ~$33 a month. That buys a lot of avoided yak-shaving. The self-hosted breakeven sits somewhere around 5+ PRs a day sustained, or any team already operating a cluster for other reasons.

The pattern to notice: SaaS previews win when previews are the only workload, and lose the moment they are one workload among many on capacity you already pay for. That is the same crossover as production hosting itself, just denominated in review tabs instead of requests per second.

Previews are a capacity problem wearing a billing costume

Vercel and Render turned "every PR gets a live URL" into a metered feature because on their infrastructure, idle preview time is inventory they cannot sell twice. On hardware you own, idle preview time is headroom you already bought — the cost of the next preview is zero right up until it is a whole second server. The engineering work is real but bounded: a webhook, an ApplicationSet, wildcard DNS, and a TTL reaper with push-refresh semantics. Build that once and previews go back to being a default instead of a line item — which is what they always should have been.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Preview environments are on the roadmap as a first-class primitive, not a billing tier. 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