ProductHow it worksPricingBlogDocsLoginFind Your First Bug
A Kubernetes cluster diagram showing multiple isolated preview namespaces, one per pull request, with several scaled to zero replicas and a proxy waking one on request
ToolingKubernetesPreview Environments

Kubernetes Preview Environments: Architecture, Cost, and Scale-to-Zero

Simon Mullen
Simon MullenVP of Engineering at Autonoma

Kubernetes preview environments are on-demand, production-like, ephemeral copies of an application, one per pull request or branch, that spin up when a PR opens and tear down when it merges, giving each change its own isolated namespace and URL. On Kubernetes, the hard part is not spinning them up. It is idle cost and operating the control plane underneath them.

Every platform team that has shipped namespace-per-PR previews eventually opens the AWS bill and asks the same question: why does this cost so much when nobody is looking at most of these environments? The answer is almost never the compute request per pod. It is that a preview environment provisioned Monday morning for a PR that gets three hours of real review time sits fully allocated, database and all, until someone remembers to close it, or until it merges four days later. Kubernetes will happily run that namespace at full replica count for 96 hours to serve 3 hours of traffic, and your cloud bill does not know the difference between "in use" and "forgotten."

This post is not a buyer's guide for a testing tool. If you are a platform or infrastructure engineer trying to make Kubernetes-based previews both economical and reliable, this is written for you: the isolation model to pick, what idle previews actually cost, and the architecture (not a cron job) that fixes it.

This is the infrastructure surface Autonoma's PreviewKit is built to own: per-PR Kubernetes previews, scale-to-zero, routing, teardown, and the verification that runs once the preview is live. The rest of this article shows the DIY architecture underneath that managed product, because the same failure modes decide whether you should operate it yourself or let the platform carry it.

What Kubernetes preview environments are, and why per-PR beats shared staging

A preview environment is a running copy of your application, deployed automatically for a specific branch or pull request, reachable at its own URL, that reflects exactly the code in that PR. On Kubernetes the natural unit of isolation is the Namespace: a controller watches your Git provider's webhooks, and on pull_request: opened it provisions a new namespace, deploys your services into it (via raw manifests, Helm, or Kustomize), wires up an Ingress, and reports the live URL back to the PR.

The alternative most teams start with is a single shared staging environment that every open branch deploys to in turn. It works until two PRs need staging at the same time, at which point one of them silently overwrites the other's data, migrations collide, and someone spends an afternoon figuring out why a completely unrelated feature suddenly renders blank. Per-PR isolation ends that class of bug entirely: each PR's environment has its own database, its own config, its own state, and nobody else is stomping on it. The tradeoff is that you now have N environments running instead of one, which is exactly where the idle-cost problem below comes from.

The control loop itself, the piece that watches webhooks and turns a pull_request: opened event into a running namespace, is not the hard part. You can write a reasonable version of it in an afternoon with a GitHub App and kubectl apply. Teams that don't want to own even that much of the surface area reach for a managed platform instead. PreviewKit, Autonoma's managed preview-environment product, runs the per-PR provisioning loop as part of the same control plane that handles wake-on-request and the verification pass later in this article. Shipyard, for example, also runs the provisioning loop as a hosted service so a platform team doesn't have to maintain the controller itself. The genuinely hard part, on either the homegrown or the managed path, is everything downstream of "the namespace exists": keeping N of them cheap and reliable once N stops being a small number.

Three isolation architectures: namespace-per-PR, vCluster, or a separate cluster

Not every team isolates previews the same way, and the three common approaches sit on a real tradeoff curve between isolation strength, cost, and how much you have to operate.

Diagram plotting namespace-per-PR, vCluster, and separate cluster against isolation strength and cost

Isolation strength rises with cost and operational complexity; namespace-per-PR is the recommended default for application testing.

Namespace-per-PR is a Kubernetes Namespace scoped to one pull request, sharing the host cluster's API server, control plane, and node pool with every other preview. It is the cheapest option by a wide margin and the one most teams should default to, because Kubernetes RBAC, ResourceQuotas, and NetworkPolicies give you real isolation between namespaces for the vast majority of application-level testing. The full mechanics of that pattern, labeling conventions, resource quotas, and RBAC scoping, are covered in namespace-per-PR mechanics on Kubernetes.

vCluster goes a layer deeper: each preview gets a virtual Kubernetes control plane (its own API server, its own etcd or SQLite backing store) running as workloads inside the host cluster. That buys you isolation the namespace model cannot give you: a PR can install its own CRDs, test cluster-scoped RBAC changes, or run its own operator without touching the host cluster's objects. It costs more, both in resources (an API server per preview) and in operational surface (you're now running a fleet of virtual control planes).

A fully separate cluster per PR is the strongest isolation and the worst economics. You get a real, physically separate control plane with no shared blast radius at all, but you're now provisioning and tearing down entire clusters on a PR cadence, at cluster boot times measured in minutes and cluster costs measured in dollars per hour whether anyone is looking at it or not. It's the right call for testing cluster-lifecycle tooling itself (cluster autoscalers, CNI plugins, admission controllers), and the wrong call for testing application code.

ApproachIsolation strengthCostOperational complexity
Namespace-per-PRGood for app-level testingLowestLow
vClusterStrong, own API serverMedium to highMedium to high
Separate clusterStrongest, full isolationHighestHigh

For most application teams, namespace-per-PR is the right starting point, and the isolation ceiling you'll actually hit is rarely "a PR broke another PR's namespace." It's "this whole fleet of namespaces is running 24/7 and nobody's watching most of them."

The idle-cost problem

Here's the math nobody runs until the bill forces the conversation. Say a preview environment gets genuinely used, someone reviewing the PR, clicking through the app, running manual QA, for three hours across its lifetime. If that namespace stays provisioned at full replica count for the full 24 hours it exists before merge or close, you paid for 24 hours of compute, storage, and networking to get 3 hours of value. That's roughly 87% waste, and it compounds: a team running 40 open PRs a day with an average lifetime of a day and a half is running dozens of fully-provisioned application stacks, most of them idle at any given moment.

This isn't a hypothetical. When we pulled a snapshot of our own preview infrastructure, 568 of 590 managed workloads, 96%, were asleep at once. That number holds up across teams because preview lifetimes and human attention spans don't scale together: a PR that takes four days to merge doesn't get four days of continuous review, it gets a burst of activity when it's opened, another when a reviewer leaves comments, and another right before merge. Everything in between is pure idle time that Kubernetes has no built-in opinion about.

The instinctive fix is to make previews cheaper per-hour: smaller instance types, spot capacity, tighter resource requests. Those help at the margins, and you should still do them, but they don't touch the real problem, which is that the namespace is billed for existing, not for being used. Shrinking the hourly rate on a stack that runs 24 hours a day at 13% utilization still leaves you paying for the other 87%. The only fix that actually addresses idle cost is not running the workloads at all when nobody's asking for them, and then bringing them back fast enough that nobody notices the gap.

Scale-to-zero as an architecture, not a cron hack

The crude version of this fix is a CronJob that scales every preview namespace's deployments down at 7pm and back up at 8am. It's easy to write and it's wrong in almost every way that matters: it doesn't account for the reviewer in a different timezone hitting the preview at 9pm, it doesn't scale down a namespace that's been idle since 10am if it's not yet 7pm, and it wakes every namespace back up at 8am whether or not anyone's using it that day. Time-based scaling optimizes for the clock. What you actually want is scaling that tracks usage: wake on request when someone shows up, sleep on idle when nobody does, regardless of what time it happens to be.

Flow diagram showing a request hitting the wake proxy, which restores replicas on an idle namespace before serving the request

The wake proxy detects the idle namespace, restores replicas from the saved annotation, then serves the request once pods are ready.

The architecture that gets this right is a wake-on-request proxy sitting in front of every preview namespace. It tracks inbound traffic per namespace, and after a configurable idle window (we run 30 minutes) it scales every Deployment and StatefulSet in that namespace to zero replicas, first recording each workload's current replica count on an annotation so it can restore exactly the topology that was running before. The namespace now costs nothing but storage. The next request to that preview's URL hits the proxy instead of a live pod. The proxy restores replicas from the saved annotation, holds the connection open while pods start and pass their readiness checks, then proxies the request through once the stack is actually ready. Websocket upgrades and streaming responses pass through the same way. It's the same pattern Knative's activator uses for scale-from-zero serving, applied at the namespace level instead of the single-service level.

We built this as Gatekeeper (not to be confused with the unrelated CNCF open-policy-agent/gatekeeper admission controller, a naming collision that trips people up in search results, not a shared codebase). It's an MIT-licensed, roughly 25 MB static Go binary that runs as a reverse proxy in front of preview namespaces, configured entirely through environment variables. Applying it to our own preview infrastructure cut average daily AWS spend by 67%, taking run-rate cost down by roughly $218,000 a year; the full breakdown, including the workload-dependency wake ordering that gets a 16-service preview back up in about 150 seconds, lives in the full scale-to-zero case study. Running the proxy itself reliably at scale (we consolidated from one Gatekeeper per namespace to a single 3-replica, leader-elected install managing every preview namespace in the cluster) is its own problem, covered in making the controller highly available with the Kubernetes Lease API.

The tradeoff worth stating plainly: the first request to a sleeping preview is slow while it wakes, anywhere from about 5 seconds for a simple single-service preview up to a couple of minutes for a large multi-service stack waking in dependency order. Every request after that is normal, single-digit-millisecond proxy overhead. That's the right trade for a preview environment, where waiting once beats paying for 23 idle hours. It is the wrong trade for a latency-sensitive production service, and Gatekeeper is explicitly a previews-and-internal-tools tool, not something we'd point at production traffic.

The Kubernetes cost and scale tool landscape

Scale-to-zero for preview namespaces sits inside a broader ecosystem of tools that watch Kubernetes spend and elasticity, most of which are solving adjacent but different problems.

ToolWhat it doesWhen to pick it
KubecostCost allocation, showback/chargeback, forecastingNeed per-team or per-namespace billing visibility
OpenCostOpen-source cost allocation engine (CNCF)Want Kubecost's model without the paid tiers
Cluster AutoscalerAdds/removes nodes to fit pending podsStable, well-understood node scaling, slower
KarpenterFast, bin-packing-aware node provisioningNeed faster, more efficient node scaling
Knative ServingRequest-driven autoscaling for HTTP servicesScaling individual stateless services to zero
KEDAEvent-driven scaling off queues, metricsScaling workloads off non-HTTP event sources

Kubecost and OpenCost tell you what your preview namespaces cost; they don't make the idle ones cheaper on their own. Cluster Autoscaler and Karpenter decide how many nodes your cluster runs; they react to pod scheduling pressure, which only drops once your preview workloads have already scaled down, not before. Knative and KEDA can scale individual workloads to zero, but neither natively understands "this whole namespace, and everything in it, together, in dependency order," which is the actual shape of a preview environment. That's the gap a namespace-aware wake-on-request proxy like Gatekeeper fills: it's not competing with any of these six tools, it's solving the layer they all leave for someone else to handle.

Networking and lifecycle

Getting traffic to a preview namespace reliably is its own small pile of infrastructure. Each preview typically gets a subdomain (pr-1234.previews.yourdomain.com), which means wildcard DNS pointed at your ingress controller and a wildcard TLS certificate, usually via cert-manager and a DNS-01 challenge, provisioned once and reused rather than issued per PR.

The gotcha worth calling out here, because it's easy to get burned by: a broad 0.0.0.0/0 egress allow with RFC1918 ranges carved out via except can get silently reinterpreted as denying traffic to those excepted ranges instead of just excluding them from the allow, depending on how your CNI compiles NetworkPolicy into actual dataplane rules. On the AWS VPC CNI specifically, that's bitten teams whose API server lives in an RFC1918 range, meaning the "excepted" block ends up blocking calls the workload needs to make back to the Kubernetes API. Calico, one of the most widely deployed NetworkPolicy engines and a reasonable choice on any CNI-flexible cluster, compiles negated CIDR ranges more predictably, but "more predictable" is not "identical," and the only safe move on any CNI is to test the compiled behavior directly rather than trust the YAML reads correctly. The full failure mode and the fix are in Kubernetes NetworkPolicy egress gotchas; the short version is to verify your CNI's actual compiled behavior for except clauses before shipping a policy that looks correct on paper.

Beyond networking, the rest of the lifecycle is mostly cleanup discipline: a TTL on every preview namespace so a PR that never gets closed doesn't run forever, a sweep job that finds and deletes zombie namespaces whose PR closed without the teardown webhook firing, and, for teams already running GitOps, an Argo CD ApplicationSet or Flux Kustomization generator that treats "one PR, one namespace" as just another instance of the pattern each tool already handles well.

Testing against a preview environment

Once a preview environment exists at a stable URL, the next question is usually whether anything actually verified it before the PR merged. Plenty of teams stop at "the preview built successfully," which confirms the app compiled and started, not that the flows inside it still work. If the preview runtime is PreviewKit, that verification is part of Autonoma's managed product rather than a second system bolted onto the URL: four agents run against the live preview on every PR. A Planner agent reads the codebase to derive test cases for the flows that PR touches, an Executor agent drives the actual UI against the running preview, a Reviewer agent classifies each result as a real bug, an agent error, or a test-plan mismatch, and a Diffs Agent updates the test suite on the next PR based on what the code diff changed. The infrastructure still has to wake and route correctly; the difference is that the preview environment and the test pass share one control plane.

Operating scale-to-zero, a highly available leader-elected controller, egress policy that actually does what it looks like on paper, and namespace sprawl cleanup is real, ongoing infrastructure work, whether or not you ever add a testing layer on top of it. A team that would rather not run all of that themselves is exactly who managed preview infrastructure exists for.

Build vs buy

If you build this yourself, here's the honest tally of what you're actually signing up to operate long-term, not just to stand up once: a wake-on-request scale-to-zero proxy and its idle-detection logic; a highly available, leader-elected controller so the proxy itself doesn't become the single point of failure for every preview in the cluster; egress NetworkPolicies that you've verified actually compile the way you think they do on your specific CNI; a namespace-sprawl and zombie-cleanup sweep that runs reliably even when webhooks get missed; wildcard DNS and TLS automation that survives cert rotation without a 2am page; and a GitOps integration that doesn't fight your existing Argo or Flux setup. None of that is exotic, all of it is real, and all of it needs someone on the team to own it in six months, not just this quarter, when the person who built it has moved on to the next project and whoever's left is debugging a wedged proxy from the on-call rotation.

PreviewKit is Autonoma's managed preview-environment product: the namespace-per-PR provisioning, wildcard DNS and TLS, and Gatekeeper's scale-to-zero all run as part of the managed control plane, so the platform team that would otherwise own this infrastructure doesn't have to. Gatekeeper itself stays open source under the MIT license precisely so teams who do want to run it themselves, on their own self-hosted preview infrastructure, can. The two are not the same offer: Gatekeeper is the engine, PreviewKit is the managed platform it runs inside, and which one fits depends entirely on whether operating that infrastructure is a good use of your team's time. For teams weighing that build-vs-buy call directly, managed preview environments without the infra walks through the tradeoff in more depth.

FAQ

A controller watches each preview namespace for inbound traffic. After a configurable idle window (30 minutes is a common default), it scales every Deployment and StatefulSet in that namespace to zero replicas, first recording each workload's replica count on an annotation so it can be restored exactly. A lightweight reverse proxy sits in front of the namespace. The next request triggers it to restore replicas from the saved annotation and hold the connection open while pods start, then proxy the request through once the pods are ready. This wake-on-request pattern is the same one Knative's activator uses for scale-from-zero serving.

Cost is driven almost entirely by idle time, not by the number of previews you run. A preview environment that gets three hours of real use a day but stays provisioned for all 24 is paying for roughly 87% waste. In our own audited AWS billing, adding scale-to-zero to preview infrastructure cut average daily spend from $892 to $295, a 67% reduction, with compute alone dropping from $749 to $238 per day. The fix is architectural (idle detection and wake-on-request), not a bigger discount.

Namespace-per-PR is the simplest and cheapest option: one Kubernetes Namespace per pull request, sharing the host cluster's control plane, API server, and node pool. It is the right default for most teams. vCluster adds a virtual control plane per environment inside the host cluster, which buys stronger isolation (each preview gets its own API server and CRDs) at meaningfully higher resource and operational cost. Reach for vCluster when a specific PR needs to test cluster-scoped resources, CRDs, or RBAC changes that namespace isolation cannot represent safely. A fully separate cluster per PR is rarely worth it outside of testing cluster-lifecycle tooling itself.

A controller listens for pull_request webhook events (opened, synchronize, closed) from your Git provider. On open, it creates a Namespace scoped to that PR, applies manifests or a Helm release into it, points an Ingress at a per-PR subdomain backed by wildcard DNS and TLS, and posts the live URL back to the PR. On merge or close, it tears the namespace down. The mechanics of that lifecycle, labeling, TTLs, and cleanup, are their own topic; the short version is that per-PR isolation replaces a single shared staging environment that multiple branches would otherwise fight over.

Not necessarily, and neither is built for the preview use case specifically. Knative Serving is a general request-driven autoscaler for stateless HTTP services; KEDA scales workloads off external event sources like queue depth. Both can scale to zero, but a preview environment is a whole namespace of interdependent services (a database, a cache, an app tier) that need to sleep and wake together in dependency order, which is a narrower problem than either tool was designed to solve. Purpose-built scale-to-zero proxies for previews, including our own open-source Gatekeeper, handle that namespace-level orchestration directly.

Related articles

A single labeled and annotated Kubernetes namespace routable within milliseconds, next to a sprawl of stale zombie namespaces left over from merged pull requests

Namespace-per-PR on Kubernetes: Patterns and Pitfalls (Namespace-as-a-Service)

Kubernetes namespace as a service: the patterns that make namespace-per-PR work, and the pitfalls (sprawl, teardown races, idle cost) that break it at scale.

A Kubernetes cluster diagram showing default-deny NetworkPolicy rules with a highlighted egress path blocked by an RFC1918 except rule shadowing a broad allow

Kubernetes NetworkPolicy Best Practices (+ Egress Gotcha)

Kubernetes NetworkPolicy best practices: default-deny, precise selectors, and the egress except rule that silently blocked our controller's API server calls.

A Kubernetes preview environment namespace shown asleep at zero replicas, waking as a single request arrives and pods spin back up in dependency order

How We Cut Our Kubernetes Preview Bill 67% ($218K a Year) with Scale-to-Zero

We cut our Kubernetes preview-environment bill 67% ($892/day to $295/day) by scaling idle previews to zero. The mechanism, the wake reliability work, and the tradeoffs.

A Kubernetes leader-elected controller with three replicas holding a coordination.k8s.io Lease, one active leader routing traffic and two standby replicas waiting to fail over

Leader Election in Kubernetes with the Lease API: A Production Controller Design

How Kubernetes leader election works with the Lease API, plus four production controller design choices most tutorials never mention.