ProductHow it worksPricingBlogDocsLoginFind Your First Bug
A single labeled and annotated Kubernetes namespace routable within milliseconds, next to a sprawl of stale zombie namespaces left over from merged pull requests
ToolingKubernetesPreview Environments

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

Simon Mullen
Simon MullenVP of Engineering at Autonoma

Namespace-per-PR gives every pull request its own Kubernetes namespace, an isolated slice of the cluster with its own workloads, quotas, and network rules, created when the PR opens and deleted when it merges; it is the most common way to run preview environments and a lightweight form of kubernetes namespace as a service. The pattern breaks down when namespace sprawl and zombie environments pile up faster than anyone tears them down.

A namespace per PR sounds simple until you have 500 of them.

Kubernetes makes creating a namespace trivial: one manifest, one API call, done in milliseconds. That simplicity is the trap. Every team that adopts namespace-per-PR previews, or per-team namespace-as-a-service, proves the happy path first: open a PR, get a namespace, close the PR, watch it disappear. The hard part shows up later, once that loop has run a few thousand times and half the namespaces in the cluster belong to PRs nobody remembers opening.

That is the surface PreviewKit, Autonoma's managed preview-environment product, is built to operate: namespace creation, routing, quotas, network policy, teardown, scale-to-zero, and the verification pass all tied to the PR lifecycle instead of split across one-off scripts.

What Kubernetes namespace-as-a-service means

A Kubernetes namespace is a scoping boundary, not a sandbox. It gives you a named slice of the cluster to attach quotas, RBAC rules, and network policies to, where object names (Deployments, Services, ConfigMaps) don't collide with the same names in another namespace. That's the entire contract. It says nothing about the underlying nodes, the kernel, or the API server, all shared with every other namespace in the cluster.

Namespace-as-a-service automates that scoping boundary so nobody runs kubectl create namespace by hand. A controller creates the namespace, attaches the standard set of policies, and tears it down on a trigger. The trigger differs by use case but the mechanism is identical: namespace-per-PR uses "PR opened" and "PR merged or closed," per-team namespace-as-a-service uses "team onboarded" and "offboarded," per-tenant multi-tenancy uses "signed up" and "churned." Same primitive, three lifecycles wrapped around it.

Namespace-per-PR became the default preview-isolation model because the trigger already exists in your Git provider. A PR opening and closing is an event you don't have to invent. Per-team and per-tenant namespace-as-a-service have to build that event source themselves, which is one reason the pattern shows up in previews first.

In PreviewKit, Autonoma uses that PR event as the control-plane boundary: open the PR and the namespace becomes routable; merge or close it and the namespace disappears with its routes. The same lifecycle is also where the managed scale-to-zero and test execution hooks attach, so the namespace is not only created, but kept cheap and verified while it exists.

Patterns that work

The pattern that scales best is discovery by label, not a central registry. We learned this building Gatekeeper, our open-source scale-to-zero engine for Kubernetes previews (not to be confused with open-policy-agent/gatekeeper, the unrelated CNCF policy controller). Namespaces are discovered by a label; routing rules live in an annotation on the same object. There is no central config object for multiple writers to fight over. Label a namespace correctly and it's routable within milliseconds. Delete it and its routes vanish with it.

RBAC scoping is the second pattern. A Role and a RoleBinding are namespace-scoped by design; a ClusterRole is cluster-scoped, bound namespace-by-namespace through a RoleBinding that references it. Generate a RoleBinding alongside every new namespace, scoping the CI job's service account to exactly that namespace. Skip this and CI credentials end up with cluster-wide reach they only ever needed in one namespace.

ResourceQuota and LimitRange solve different problems. ResourceQuota caps the aggregate CPU, memory, and object count for the whole namespace; once attached, Kubernetes requires every pod to declare resource requests and limits, or the pod gets rejected outright. LimitRange sets defaults and min/max bounds per container, so a developer who forgets a memory limit gets a sane default instead of an unbounded pod. Attach both and the cluster degrades gracefully under a burst of PRs instead of one bad Deployment starving every other preview on the node.

Network isolation is the pattern teams skip until something goes wrong. A NetworkPolicy is namespace-scoped and additive, allowing traffic that matches a rule and denying everything else, but only if the CNI actually enforces it. The default-deny pattern, one policy blocking all ingress except an explicitly allowed set, keeps one PR's preview from talking to another PR's database.

Namespace primitiveWhat it buys youThe gotcha
Labeled, annotated namespaceDiscoverable and routable within millisecondsOne label controls it, no central config
ResourceQuotaCaps aggregate CPU, memory, objectsRequires requests/limits on pods or blocks creation
LimitRangeSets per-container defaults and boundsDoesn't cap the namespace total, only per-object
RBAC RoleBindingScopes CI credentials to one namespaceRoleBinding is namespace-scoped, not cluster-wide
NetworkPolicy (default-deny)Blocks cross-namespace pod trafficNo-op without a CNI that enforces it

The pitfalls

Namespace sprawl is the first failure mode, and it's boring precisely because it isn't a dramatic outage. It's a namespace list that scrolls for three screens, most of them stale PR namespaces from branches merged, abandoned, or force-pushed into oblivion weeks ago. The controller that's supposed to delete them on PR close missed the merge event, or the webhook fired mid-deploy, or someone closed the PR from a script that skips the lifecycle hook the UI triggers. Nobody notices a zombie namespace. It sits there, consuming its ResourceQuota allocation, until a cost report asks why the cluster is provisioned for 400 concurrent PRs when the team opens 40 a week.

Diagram showing two deletion paths for preview namespaces, a fast PR-close webhook and a periodic reconciliation job that lists labeled namespaces and checks whether each PR is still open, both converging on an idempotent namespace delete that prevents sprawl

Treat the webhook as the fast path and the reconciliation job as the guarantee that catches every missed event.

Teardown races are the second failure mode, and the one that actually pages someone. The moment a preview namespace's ownership changes hands, say from an in-namespace controller to a shared central router, the transition has to be atomic. Deleting the namespace's exact-host Ingress, so traffic falls through to the central wildcard Ingress, and tearing down the old in-namespace controller have to happen in the same step. If they don't, there's a window where two controllers both believe they own the namespace. One is idle and scales the workloads to zero. The other is actively serving a reviewer looking at the preview right now. The reviewer sees a dead page. Nobody touched the code; two control loops just disagreed about who was in charge.

A namespace is a scoping boundary, not a security boundary. RBAC and NetworkPolicy decide who can reach what. They don't decide who can escape the kernel.

The idle cost is the pitfall that's easy to ignore because it doesn't break anything, it just burns money. A namespace running around the clock, even when nobody has opened its preview URL in six hours, gets billed like it's serving production traffic. On our own preview cluster, 568 of 590 running workloads (96%) were sitting idle at a single point in time, and scaling them to zero on an idle timeout cut our preview bill 67% (about $218,000 a year), a mechanism detailed in how we cut our Kubernetes preview bill 67% with scale-to-zero.

The last pitfall is a mismatch between what people expect a namespace to guarantee and what it actually guarantees. A namespace is a soft boundary, sharing the kernel, the nodes, and the API server with every other namespace in the cluster. RBAC and NetworkPolicy control who can reach what over the API and the network, but a container escape or a kernel-level exploit doesn't care which namespace it started in. That's fine for isolating one trusted team's PR from another's. It is not enough for genuinely untrusted or hostile workloads. When you need that stronger guarantee, the next step up is a virtual control plane per tenant (vCluster) or a fully separate cluster, covered in our isolation-architecture breakdown for Kubernetes preview environments.

Wiring it into per-PR previews

The lifecycle maps onto the two events a PR provider gives you. On PR open, a controller creates the namespace, attaches its label and annotation, applies the ResourceQuota and LimitRange, creates the scoped RoleBinding, and applies the default-deny NetworkPolicy, all before the preview URL is worth sharing.

Diagram of the namespace-per-PR lifecycle, a PR-opened webhook triggering a controller that provisions a namespace with a label and annotation, ResourceQuota, LimitRange, scoped RoleBinding, and default-deny NetworkPolicy, making it routable within milliseconds, then deleting the namespace and its routes when the PR merges or closes

One PR event provisions a fully policied namespace and another tears it down, routes included.

The infrastructure underneath a working per-PR preview runs deeper than the namespace manifest alone, covered in per-PR environments: six layers.

On PR merge or close, the controller deletes the namespace, and because the routing annotation lived on that object, the routes disappear in the same call. This is also where a proactive testing layer earns its place: four agents, Planner, Executor, Reviewer, and Diffs Agent, run a full end-to-end pass against each namespace's preview before it's merged, the pairing detailed in per-PR preview environments with tests included. The full provisioning sequence, from PR event to routable namespace, is broken down in the preview environment provisioning lifecycle.

How Autonoma runs namespace-per-PR previews

Everything in this article, labeling and annotating namespaces correctly, sizing a ResourceQuota without starving a burst of PRs, scoping RBAC per namespace, tearing down atomically so two controllers never fight over ownership, is ongoing platform work. None of it is a one-time setup. It's a maintenance surface that grows with every new service, every new team, and every quota tweak someone makes at 11pm to unblock a release. A team that ships this once for a hackathon demo and a team that runs it in production for two years are solving very different problems, even though the Kubernetes objects involved are identical.

PreviewKit, Autonoma's managed preview-environment product, operates that namespace lifecycle out of the box. On PR open it creates the labeled, annotated namespace, attaches the ResourceQuota, LimitRange, RoleBinding, and default-deny NetworkPolicy described above, and applies the same atomic-cutover teardown discipline our own preview cluster depends on, so a namespace never gets caught between two controllers mid-handoff. On PR merge, close, or a stale-branch reconciliation pass, it deletes the namespace and confirms the delete actually completed, which is the sprawl-prevention piece most DIY setups skip because it only matters once you've been running the system for months.

The scale-to-zero behavior that keeps idle namespaces from burning budget is the same Gatekeeper engine referenced earlier, running as production infrastructure inside PreviewKit rather than as a cron job someone has to babysit. It watches the same label-and-annotation surface: a namespace goes idle, Gatekeeper scales its workloads down and records the replica counts on an annotation, and the next request wakes it back up to exactly the state it was in. That's the mechanism behind the 67% cut to our own preview bill, running unattended across however many namespaces are open on a given day, whether that's 40 or 400.

The testing layer is what turns an isolated namespace into a verified one, and it's the part a namespace controller alone doesn't give you. Once PreviewKit has a namespace routable, four agents run their full pass against it: the Planner reads the codebase and plans test cases from the actual routes and components, not from a QA backlog; the Executor drives the live application inside that namespace, including any database-state setup the test scenario needs; the Reviewer classifies each result as a real bug, an agent error, or a plan mismatch, so a flaky run doesn't get reported as a regression; and the Diffs Agent updates the suite on the next PR's code diff, adding, deprecating, or adjusting test cases as the application changes. Namespace isolation gives every PR its own blast radius. The testing layer gives every PR its own verification pass inside that blast radius, without anyone hand-writing a test plan, recording a flow, or clicking through the app to check that it still works.

A team can build the namespace mechanics in this article themselves, and plenty do. Labels, annotations, ResourceQuota, RBAC, and a default-deny NetworkPolicy are well-understood primitives with well-documented APIs. What's harder to see going in is how much of the work is ongoing rather than one-time: quota tuning as usage patterns shift, RBAC scope creep as the cluster and the team both grow, teardown edge cases that only surface at PR volume nobody load-tested for, and a scale-to-zero layer that has to be reliable enough that nobody thinks twice about whether a preview will wake up.

A namespace controller is a weekend project. A namespace controller that never sprawls, never races on teardown, and stays cheap at 400 concurrent PRs is a multi-quarter one.

The difference between the DIY path and PreviewKit isn't which primitives get used, both reach for the same labels, quotas, and RoleBindings described earlier. The difference is who carries the maintenance surface once the system is live. DIY means your team owns the reconciliation job that catches missed webhooks, owns the atomic-cutover logic that prevents the teardown race, owns the idle-timeout tuning that keeps the bill down, and owns paging itself when any of those three drift out of sync with a Kubernetes version bump or a CNI upgrade. PreviewKit means those three concerns, sprawl prevention, atomic teardown, and scale-to-zero, are already running as production infrastructure behind every namespace it creates, and the testing layer runs against the same namespace without a second integration to maintain.

Operating quota'd, isolated, reliably-torn-down namespaces well is real ongoing platform work. If those practices are core to your platform strategy, build them. If they are the tax you pay before product teams can review and merge safely, Autonoma and PreviewKit are the managed path: every PR gets the namespace lifecycle, scale-to-zero, teardown guarantees, and E2E verification without your team owning another controller.

FAQ

Namespace-as-a-service is the pattern of automating the creation, configuration, and deletion of Kubernetes namespaces so a human never runs kubectl create namespace by hand. A controller creates the namespace, attaches the standard set of RBAC rules, ResourceQuota, LimitRange, and NetworkPolicy, and tears it down when a trigger fires. The trigger differs by use case: namespace-per-PR uses PR opened and PR merged or closed, per-team namespace-as-a-service uses team onboarded and offboarded, and per-tenant multi-tenancy uses signup and churn. It is the same underlying primitive, a namespace, wrapped in different lifecycles depending on who or what the namespace belongs to.

A controller listens for the PR-opened webhook from GitHub or GitLab and creates a namespace scoped to that PR, typically named or labeled with the PR number or branch name. On creation it attaches a label the routing layer uses for discovery, a routing annotation with the preview's hostname, a ResourceQuota and LimitRange to bound resource usage, a RoleBinding scoping CI credentials to that namespace only, and a default-deny NetworkPolicy. The namespace becomes routable within milliseconds of the label and annotation landing, because there is no separate central registry that also needs to be updated.

Namespace sprawl happens when the deletion side of the lifecycle is less reliable than the creation side: a missed webhook, a force-push that never fires a clean merge event, or a manual namespace someone created for debugging and forgot about. The fix is to make deletion idempotent and independent of a single event source. Pair the PR-close webhook with a periodic reconciliation job that lists all namespaces carrying the per-PR label, checks whether the corresponding PR is actually still open against the Git provider's API, and deletes any namespace whose PR is closed, merged, or no longer exists. Treat the webhook as the fast path and the reconciliation job as the guarantee.

A namespace is a soft boundary: RBAC and NetworkPolicy control who can reach what over the Kubernetes API and the network, but every namespace in a cluster still shares the same kernel, the same nodes, and the same API server. That is enough isolation for running one trusted team's PRs next to another trusted team's PRs. It is not enough for running genuinely untrusted or hostile workloads side by side, where a container escape or a kernel-level exploit does not respect namespace boundaries. When you need that stronger guarantee, the next step up is a virtual control plane per tenant (vCluster) or a fully separate cluster.

Delete the namespace object itself rather than deleting its contents piece by piece. Kubernetes garbage-collects everything inside a namespace, Deployments, Services, ConfigMaps, Secrets, when the namespace is deleted, and if the routing annotation lived on the namespace object, the routes disappear in the same call. The part that actually causes incidents is not the delete call, it is ownership handoff: if a preview is moving from one controller to another (for example, from an in-namespace controller to a shared central router) the old controller's teardown and the new routing rule have to land in the same atomic step, or there is a window where both controllers believe they own the namespace and one of them scales down a preview the other is actively serving.

Related articles

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