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 primitive | What it buys you | The gotcha |
|---|---|---|
| Labeled, annotated namespace | Discoverable and routable within milliseconds | One label controls it, no central config |
| ResourceQuota | Caps aggregate CPU, memory, objects | Requires requests/limits on pods or blocks creation |
| LimitRange | Sets per-container defaults and bounds | Doesn't cap the namespace total, only per-object |
| RBAC RoleBinding | Scopes CI credentials to one namespace | RoleBinding is namespace-scoped, not cluster-wide |
| NetworkPolicy (default-deny) | Blocks cross-namespace pod traffic | No-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.
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.
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.
Giving every PR a clean, quota'd, isolated namespace that reliably tears down is real platform work. If you would rather not build and maintain it, PreviewKit, Autonoma's managed preview-environment product, gives each PR its own environment out of the box. Grab 20 min with a founder
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.
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.
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.




