ProductHow it worksPricingBlogDocsLoginFind Your First Bug
A Kubernetes preview environment namespace shown asleep at zero replicas, waking as a single request arrives and pods spin back up in dependency order
ToolingKubernetesScale to Zero

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

Simon Mullen
Simon MullenVP of Engineering at Autonoma

Scale to zero for Kubernetes previews: we cut our Kubernetes preview-environment bill 67%, from $892 a day to $295 a day (about $218,000 a year), by scaling idle previews to zero with a small open-source Go proxy that wakes them on the next request, with no change to how the team works. The mechanism: after an idle timeout of 30 minutes, every workload in the namespace scales to zero replicas; the next request wakes it and holds the connection open until pods are ready.

Previews are one of the best things we ever shipped internally, and quietly one of the most wasteful. Every pull request gets its own live, isolated Kubernetes namespace: full stack, real services, a URL you can hand to a reviewer or a designer. Nobody misses a shared staging environment. Nobody argues about whose branch is deployed where.

For a long time we did not look very hard at what all of that cost. Then we did, and the answer surprised us enough to fix it.

The bill, before and after

We pulled our own AWS billing for the preview cluster and split it the way AWS already splits it: EC2-Instances (compute, the worker nodes actually running preview pods) and EC2-Other (the storage and network costs that scale with node count: EBS volumes, NAT gateway traffic, the overhead of running more nodes than any given hour needs). Averaged across a normal month, before scale-to-zero:

Stacked bar chart comparing Autonoma's daily Kubernetes preview-environment cost before and after scale-to-zero: $892 a day before, made of $749 compute and $143 storage/network, versus $295 a day after, made of $238 compute and $57 storage/network

Scale-to-zero cut the daily preview bill from $892/day to $295/day; compute fell from $749/day to $238/day and storage/network fell from $143/day to $57/day.

Here is the same cluster, same workloads, after we turned scale-to-zero on:

BeforeAfterDelta
Compute (EC2-Instances)$749/day$238/day-68%
Storage/network (EC2-Other)$143/day$57/day-60%
Total$892/day$295/day-67%
Annual run-rate~$325,000~$108,000-$218,000

Nothing about the workloads changed. Same services, same PRs, same team. The only difference is that most of that compute now spends most of its life at zero replica count instead of running idle and fully billed.

Why previews are idle almost all the time

The number that actually got our attention was not the bill. It was a snapshot of every managed preview workload at a single point in time, mid-afternoon on an ordinary weekday: 568 of 590 workloads, 96%, were sitting idle. Not slow, not underutilized: doing nothing. No requests, no traffic, no background jobs. Just resident pods holding onto CPU and memory that Kubernetes had already reserved for them.

That number makes sense once you think about how a PR actually gets used. An engineer opens a PR, a preview spins up, a reviewer clicks around for ten minutes, someone rechecks it after CI runs, and then the PR sits open waiting on review, on a merge queue, on somebody's vacation. A preview environment's useful life is a handful of short bursts spread across days. The other 23-plus hours of a given day it is a fully provisioned, fully billed, fully idle copy of your stack, running for an audience of nobody.

Why not just delete idle environments

The obvious fix is a TTL: a cron job that tears down any preview past a certain age, or untouched for a certain number of minutes. We tried variations of this before scale-to-zero, and it solves the cost problem by creating a worse one. Delete the namespace and the preview URL a reviewer bookmarked yesterday now 404s. Reopen the PR and someone has to remember to re-provision it, sit through a full cold provisioning cycle, and hope nothing about the underlying infrastructure drifted in the meantime.

Manual scaling has a narrower failure mode, but a real one: it does not survive stateful services cleanly. Scale a stateless API deployment to zero and back and nothing is lost. Scale a database pod down without coordinating what depends on it, and every workload that assumed the database was always there starts throwing connection errors the moment traffic resumes, because nothing ordered bringing dependencies back up correctly.

We wanted the cost profile of deletion without the destructive part: keep the namespace, the PVCs, the config, everything that makes a preview a preview, and get rid of only the running pods when nobody is actually using them.

The mechanism: scale to zero, wake on request

The tool we built for this is a small reverse proxy that sits in front of each preview namespace. We call it Gatekeeper, not to be confused with open-policy-agent/gatekeeper, the unrelated, CNCF-graduated policy controller of the same name; ours has nothing to do with policy enforcement. Gatekeeper watches request traffic to a namespace and, after a configurable idle timeout (we run 30 minutes), scales every managed Deployment and StatefulSet in that namespace to zero replicas. Before it does, it records each workload's current replica count on a Kubernetes annotation, so scaling back up restores the namespace to exactly the state it was in rather than some default guess.

The interesting part is what happens on the next request to a sleeping preview. Gatekeeper intercepts it, restores replica counts from the saved annotation, and holds the connection open while pods start: pulling images if needed, running through readiness probes, doing whatever startup work the app normally does. Once the target pod reports ready, Gatekeeper proxies the original request through and gets out of the way. This is the same wake-on-request pattern Knative's activator uses for serverless containers, applied here to an ordinary namespace of Kubernetes workloads instead of a single scale-to-zero-aware service. WebSocket upgrades and streaming responses pass through the same way: Gatekeeper does not buffer or rewrite the body, it just delays the handshake until something is listening on the other end.

Making wake reliable

A single-service preview wakes in about 5 seconds: one deployment, one image pull (usually cached), one readiness probe. Most of our previews are not single-service. A 16-workload preview (API, workers, a queue, a cache, a couple of internal services, a database) has real startup ordering constraints, and waking everything at once produces exactly the failure mode you would expect: the app container starts before the database is accepting connections, throws on its first query, and either crash-loops or serves errors until something restarts it.

Gatekeeper handles this with dependency-ordered wake. A workload declares what it depends on through an annotation, and Gatekeeper wakes the graph in waves: the database first, then the services that depend on it once the database reports Ready, then the app layer on top. That 16-workload preview wakes in three dependency waves, about 150 seconds end to end. Not fast, but it is 150 seconds once, on the first request after a preview goes idle, not a tax on every request after.

Diagram of dependency-ordered wake: the first request triggers wave one waking the database, then wave two waking the services that depend on it, then wave three waking the app layer on top, and the request is served after about 150 seconds on the first hit only

Gatekeeper wakes a multi-service preview in dependency waves: the database first, then its dependents, then the app layer, so nothing starts before what it relies on is ready.

The other reliability problem is failing fast on pods that will never come up. A reviewer opens a PR from three weeks ago, the preview is asleep, Gatekeeper tries to wake it, and the image referenced in that old deployment spec was garbage-collected off the registry weeks ago. Waiting a full startup timeout for a pod that is never going to become ready is a bad experience for exactly the case where someone is already annoyed: an abandoned PR nobody remembered to close. Gatekeeper lists pod status on every wake attempt and gives up early on anything in a genuinely unrecoverable state, a crash loop, a bad image reference, while continuing to wait normally on pods that are still legitimately starting. That distinction turns a hit on a dead preview into a 1-2 second failure instead of a multi-minute hang.

None of this matters if Gatekeeper itself is a single point of failure sitting in front of every preview in the cluster. We run it as a leader-elected, 3-replica deployment coordinated through a Kubernetes Lease, so a standby takes over within about 15 seconds if the leader pod dies. We wrote up the leader-election controller pattern against the Lease API separately if you want to build the HA piece yourself, along with a networking gotcha we hit along the way: a default-deny NetworkPolicy that silently breaks the Lease API calls a leader-elected controller depends on, worth checking before you copy this.

The honest tradeoff

None of this is free, and we do not want to pretend it is. The first request to a sleeping preview pays the full wake cost: about 5 seconds for a simple single-service preview, up to a couple of minutes for a large multi-service stack working through dependency waves. If you are the reviewer who hits that first request, you notice the cold start.

Every request after that is normal. Gatekeeper's proxy overhead once a namespace is awake is single-digit milliseconds, indistinguishable from a normal ingress hop. The cost is entirely front-loaded onto the one request that happens to arrive first.

That is the right trade for a preview environment: wait once, on the request that happens to go first, instead of paying for 23 idle hours of compute so that no request ever has to wait. It is the wrong trade for a latency-sensitive production service, where a customer hitting a cold path is not an acceptable outcome under any circumstances. Gatekeeper is a previews-and-internal-tools tool. We run it in front of preview namespaces and a handful of low-traffic internal dashboards. We do not run it in front of anything customer-facing, and we would not recommend it there. Two smaller caveats worth knowing before you adopt this yourself: stateful databases occasionally crash-loop on a cold wake and need a manual restart, and previews on custom domains need their own edge and DNS wiring, since Gatekeeper's wake hook rides on the wildcard Ingress we already run for every preview, not on arbitrary domains.

Try it (and when not to build it yourself)

Gatekeeper is open source under the MIT license, on GitHub at github.com/Autonoma-AI/gatekeeper. It ships as a single, roughly 25MB static Go binary on a distroless base image, uses tens of megabytes of RAM, configures entirely through environment variables, and includes a runnable end-to-end example so you can see the wake flow against a real namespace before pointing it at anything real. If your Kubernetes preview bill looks anything like ours did, this is genuinely something you can run yourself.

We should be honest about what "run it yourself" actually means, though. The proxy itself is small. Getting the whole picture right, dependency-ordered wake that does not crash-loop your database, an HA controller that survives a leader dying without leaving every namespace stuck asleep, network policy that does not quietly block the Lease API calls the controller needs, DNS and edge routing for anything outside the wildcard Ingress, is ongoing operational work, not a weekend project. That is the actual tradeoff between the two poles: run Gatekeeper yourself and own that work, or do not.

We ended up doing both. Gatekeeper is what we publish standalone, and it is also the same scale-to-zero engine running inside PreviewKit, Autonoma's managed preview-environment product, as part of the production preview-cluster infrastructure we integrated it into in June 2026. Note that Gatekeeper's MIT license is separate from the Autonoma platform's own license (Business Source License 1.1, converting to Apache 2.0 in 2028); the engine is MIT regardless of which way you run it. If you would rather have someone else operate scale-to-zero, HA, and the networking around it, that is what PreviewKit is for, in the same spirit as managed preview environments without the infra or killing your staging environment altogether. If you would rather run it yourself, the code is the same code, and it is on GitHub.

FAQ

On our own Kubernetes preview cluster, scaling idle previews to zero cut the daily bill 67%, from $892/day to $295/day on average, about $218,000 a year (roughly $325,000/year down to $108,000/year run-rate). The saving is concentrated in compute: EC2-Instance costs dropped 68%, from $749/day to $238/day, because most preview workloads spend the vast majority of their time completely idle. A snapshot of our fleet found 568 of 590 managed workloads, 96%, idle at a single point in time. Results will vary by team based on how idle your previews actually are and how many hours a day they sit open, but the underlying pattern, previews used in short bursts and idle the rest of the time, is common to most PR-based preview setups.

A reverse proxy sits in front of each preview namespace and watches for the next incoming request. When one arrives at a sleeping preview, it restores each workload's replica count from an annotation recorded when the namespace went idle, waits for pods to report Ready, and then proxies the original request through, the same wake-on-request pattern Knative's activator uses. For previews with multiple interdependent services, wake happens in dependency-ordered waves, database first, then services, then the app layer, rather than all at once, so the app does not start before the database it depends on is ready.

For a simple single-service preview, about 5 seconds. For a large multi-service preview with real dependency ordering, our worst case is a 16-workload preview waking in roughly 150 seconds across three dependency waves. Every request after the wake completes is normal, with single-digit-millisecond proxy overhead. A hit on a dead preview, an abandoned PR whose image was garbage-collected, fails fast in about 1-2 seconds rather than hanging for a full timeout.

Yes, with care. Databases wake first in the dependency order so nothing downstream tries to connect before the database is accepting connections. The caveat worth knowing: a database occasionally crash-loops on a cold wake and needs a manual restart rather than resolving itself. Scaling a stateful workload to zero and back is safe for the data itself, nothing is deleted, only the running pod, but it is the piece of the wake sequence most likely to need attention.

Not for latency-sensitive, customer-facing services, no. The tradeoff is that the first request after an idle timeout pays the full wake cost, seconds to low minutes, which is an acceptable cost for a preview environment where the alternative is paying for compute nobody is using, but it is not acceptable for a production path where every request needs to be fast. This is a previews-and-internal-tools tool. We run it in front of preview namespaces and a handful of internal dashboards, not in front of anything customer-facing.

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 cluster diagram showing multiple isolated preview namespaces, one per pull request, with several scaled to zero replicas and a proxy waking one on request

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

Kubernetes preview environments explained: namespace-per-PR vs vCluster vs separate cluster, the real idle-cost math, and how scale-to-zero fixes it.

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.