Kubernetes multi-tenancy: namespace isolation, ResourceQuota, and LimitRange

Running multiple teams or environments on a single Kubernetes cluster saves infrastructure cost, but without explicit boundaries one namespace can starve every other. This guide walks through provisioning a tenant namespace with ResourceQuota for aggregate caps, LimitRange for per-container defaults, NetworkPolicy for network isolation, RBAC for API-level access control, and Pod Security Standards for runtime restrictions.

Table of contents

Goal

At the end of this guide you will have a fully provisioned tenant namespace where resource consumption is capped, network traffic is denied by default, API access is scoped to one team, and runtime privileges are restricted. The same pattern applies whether the tenant is a team, an environment (staging, production), or an internal product.

Prerequisites

  • A Kubernetes cluster running v1.30 or later with kubectl access and permissions to create namespaces, ResourceQuotas, LimitRanges, NetworkPolicies, Roles, and RoleBindings
  • A CNI plugin that enforces NetworkPolicy (Calico, Cilium, or Antrea). Without one, NetworkPolicy objects are accepted by the API server but have zero effect on traffic. Flannel and kubenet do not enforce them.
  • Familiarity with resource requests and limits. If the difference between a request and a limit is unclear, read that article first; the short version is that requests drive scheduling, limits drive runtime enforcement.

What namespaces actually isolate

Namespaces provide logical segmentation of API resources within a single control plane. They scope names (two namespaces can each have a Service called api), they scope RBAC Roles and RoleBindings, and they scope ResourceQuotas, LimitRanges, and NetworkPolicies.

That is all they do.

Namespaces do not isolate network traffic by default. Every pod can reach every other pod across every namespace without restriction. They do not isolate node-level resources (workloads from different namespaces share the same nodes). They do not isolate the host kernel. A namespace alone is a label, not a fence. Everything on this page exists to turn that label into actual isolation.

Step 1: create the tenant namespace

kubectl create namespace team-payments
kubectl label namespace team-payments \
  team=payments \
  cost-center=FIN-200

The team and cost-center labels are not required by Kubernetes, but they give NetworkPolicy namespaceSelector rules something to match on and make cost-attribution tooling (like Kubecost) useful. Since Kubernetes 1.21+ the cluster automatically adds kubernetes.io/metadata.name: team-payments to every namespace, so you do not need to set that label manually.

Expected output:

namespace/team-payments created
namespace/team-payments labeled

Step 2: apply a LimitRange

A LimitRange constrains individual containers and pods, not namespace aggregates. It serves two purposes here: injecting default resource requests and limits for containers that omit them, and setting a ceiling so one container cannot claim 64 GiB of memory in a namespace capped at 20 GiB. For a focused reference of every LimitRange and ResourceQuota field, see ResourceQuota and LimitRange: enforce namespace resource limits.

Deploy the LimitRange before the ResourceQuota. The order matters. When a ResourceQuota exists for cpu or memory, the admission controller rejects any pod that does not specify resource requests. LimitRange defaults are injected before the quota check, so having the LimitRange in place prevents existing workloads from breaking when the quota lands.

# limitrange.yaml
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: team-payments
spec:
  limits:
  - type: Container
    default:          # injected as limits when the container specifies none
      cpu: 500m
      memory: 512Mi
    defaultRequest:   # injected as requests when the container specifies none
      cpu: 100m
      memory: 128Mi
    max:              # ceiling per container
      cpu: "4"
      memory: 8Gi
    min:              # floor per container
      cpu: 50m
      memory: 64Mi
kubectl apply -f limitrange.yaml

Expected output:

limitrange/default-limits created

Two things to watch for. First, keep exactly one LimitRange per namespace. Multiple LimitRange objects in the same namespace produce non-deterministic default injection, and debugging which defaults win is not a productive way to spend an afternoon.

Second, when default and defaultRequest are identical (say both 500m CPU), every pod that omits resource specs gets Guaranteed QoS, which affects eviction priority under node pressure. If you want Burstable QoS as the default (the more common choice for general workloads), set defaultRequest lower than default. For a full explanation of QoS classes, see resource requests and limits.

The request-exceeds-limit trap

If a developer specifies requests.cpu: 700m without a limit, the LimitRange injects limits.cpu: 500m from the default field. Validation then fails because the request (700m) exceeds the limit (500m). The error looks like this:

spec.containers[0].resources.requests: Invalid value: "700m":
must be less than or equal to cpu limit

The developer did not set a limit. The LimitRange did. The fix: always specify both requests and limits in the pod spec, or ensure the LimitRange default values are at least as high as defaultRequest.

Step 3: apply a ResourceQuota

ResourceQuota caps aggregate resource consumption across the entire namespace. It is enforced at admission time by the ResourceQuota admission controller (enabled by default in most distributions). Any request that would push usage above the hard limit is rejected with HTTP 403 Forbidden.

# resourcequota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-payments-quota
  namespace: team-payments
spec:
  hard:
    requests.cpu: "10"
    requests.memory: 20Gi
    limits.cpu: "20"
    limits.memory: 40Gi
    pods: "100"
    services: "10"
    persistentvolumeclaims: "5"
kubectl apply -f resourcequota.yaml

Expected output:

resourcequota/team-payments-quota created

Verify usage immediately:

kubectl describe resourcequota team-payments-quota -n team-payments

You should see Used: 0 for every resource. If existing pods are already running in the namespace, their resource usage is reflected here but they are not evicted; quota is not retroactive.

One behaviour catches people off guard. Creating a Deployment that exceeds quota does not fail the Deployment itself. The Deployment object is created successfully. Only the Pod creation within it fails. kubectl get deployment shows the resource, but kubectl describe deployment or kubectl get events -n team-payments reveals the 403 error. Always check events when a deployment looks healthy but has zero ready replicas.

Step 4: lock down network traffic

Without NetworkPolicy, pods in team-payments can reach pods in every other namespace and vice versa. A default-deny policy closes that gap.

# deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: team-payments
spec:
  podSelector: {}   # selects all pods in the namespace
  policyTypes:
  - Ingress
  - Egress

This blocks all traffic in both directions. That includes DNS. Apply a DNS egress rule immediately or the namespace breaks:

# allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: team-payments
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
      podSelector:
        matchLabels:
          k8s-app: kube-dns
    ports:
    - protocol: UDP
      port: 53
    - protocol: TCP
      port: 53
kubectl apply -f deny-all.yaml -f allow-dns.yaml

Expected output:

networkpolicy.networking.k8s.io/default-deny-all created
networkpolicy.networking.k8s.io/allow-dns created

From here, add specific ingress and egress rules for the flows the tenant's workloads actually need. The NetworkPolicy guide covers ingress rules, egress rules, and namespace-scoped selectors in detail.

Silent failure mode. If your cluster uses Flannel or kubenet, these NetworkPolicy objects exist in the API but do nothing. Verify your CNI enforces policies before relying on them. A quick test: deploy a pod in a different namespace and try to curl a service in team-payments. If the connection succeeds after the deny-all policy is applied, your CNI is not enforcing.

Step 5: scope RBAC to the namespace

RBAC is the foundational control-plane isolation layer. Without it, any team member with kubectl access can read or modify resources across namespaces, and every other isolation mechanism on this page can be bypassed.

# role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: developer
  namespace: team-payments
rules:
- apiGroups: ["apps"]
  resources: ["deployments", "replicasets"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
  resources: ["pods", "pods/log", "services", "configmaps"]
  verbs: ["get", "list", "watch"]
- apiGroups: [""]
  resources: ["pods/exec"]
  verbs: ["create"]    # allow kubectl exec for debugging
# rolebinding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: payments-developers
  namespace: team-payments
subjects:
- kind: Group
  name: team-payments-devs    # from your identity provider
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: developer
  apiGroup: rbac.authorization.k8s.io
kubectl apply -f role.yaml -f rolebinding.yaml

A namespace-scoped Role grants zero permissions outside team-payments. The team cannot list pods in team-orders, cannot read secrets in kube-system, and cannot delete the ResourceQuota you just created (unless you explicitly grant that verb on resourcequotas resources, which you should not).

For workload pods, use dedicated ServiceAccounts with automountServiceAccountToken: false unless the pod genuinely needs API access. The RBAC article covers service account patterns in depth.

Protecting the quota from namespace admins

If a tenant has the built-in admin ClusterRole bound in their namespace, they can delete the ResourceQuota and remove all limits. Prevent this with a ValidatingAdmissionPolicy (stable since Kubernetes 1.30) or a Kyverno/Gatekeeper policy that denies DELETE on ResourceQuota objects for non-cluster-admins.

Step 6: enforce Pod Security Standards

Pod Security Standards restrict what a pod can do at runtime: host namespaces, privileged containers, capability escalation. Applying them per namespace is a single label:

kubectl label namespace team-payments \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/warn=restricted

This enforces the baseline profile (blocks known privilege escalation vectors) and warns on violations of the stricter restricted profile so the team can migrate gradually. For a full walkthrough of all three profiles and enforcement modes, see the Pod Security Standards guide.

Expected output:

namespace/team-payments labeled

Step 7: verify the full stack

Run these checks to confirm every layer is in place:

# ResourceQuota applied
kubectl describe resourcequota -n team-payments

# LimitRange applied
kubectl describe limitrange -n team-payments

# NetworkPolicy rules present
kubectl get networkpolicy -n team-payments

# RBAC bindings scoped to namespace
kubectl get rolebinding -n team-payments

# Pod Security Standard labels
kubectl get namespace team-payments --show-labels | grep pod-security

Deploy a test pod to exercise the admission pipeline:

kubectl run test-pod --image=nginx:1.27 -n team-payments
kubectl describe pod test-pod -n team-payments

In the pod description, check that Requests and Limits are present (injected by the LimitRange). If the pod starts, your quota has room and the security profile allows it. Clean up:

kubectl delete pod test-pod -n team-payments

Rolling updates and quota headroom

During a RollingUpdate, Kubernetes creates new pods before terminating old ones (controlled by maxSurge). If the quota is set to exactly the steady-state resource consumption, the new pods temporarily push usage above the cap and the update gets stuck. The Deployment controller retries pod creation with exponential backoff, and it can take up to 16 minutes before the next attempt.

Three approaches:

  1. Add headroom. Set the quota 20-30% above steady-state usage. This is the simplest fix and works for most teams.
  2. Set maxSurge: 0. No new pods are created before old ones terminate, so usage never exceeds steady-state. The trade-off: rolling updates cause brief downtime.
  3. Automate quota bumps. Your CI/CD pipeline temporarily increases the quota before deploying and restores it after. More moving parts, but it keeps quotas tight outside deployment windows.

Monitoring quota utilisation

When a namespace hits its quota, new pods fail silently at the Deployment level. Proactive monitoring prevents surprise outages.

# Quick check across all namespaces
kubectl get resourcequotas -A

# Events for quota violations in the namespace
kubectl get events -n team-payments --field-selector reason=FailedCreate

If you run Prometheus with kube-state-metrics, the kube_resourcequota metric exposes usage vs. hard limits per namespace. A useful alert threshold:

kube_resourcequota{type="used"} / kube_resourcequota{type="hard"} > 0.85

This fires when any quota dimension hits 85% utilisation, giving the team time to either clean up or request a quota increase. The Prometheus monitoring guide covers kube-state-metrics installation and alerting rules.

Soft vs. hard multi-tenancy

Everything on this page implements soft multi-tenancy: a single shared control plane with namespace-level isolation enforced by RBAC, NetworkPolicy, quotas, and admission policies. The Kubernetes documentation calls this appropriate for trusted tenants within the same organisation.

For untrusted tenants (SaaS customers, external contractors, compliance-isolated workloads), namespaces are not enough. A compromised pod can still attempt kernel exploits against the shared host, and a misconfigured ClusterRoleBinding can leak access across namespaces. Hard multi-tenancy means giving each tenant their own API server through virtual clusters (vcluster) or dedicated physical clusters.

The decision point is trust. If the tenants are internal teams who do not intentionally try to escape their boundary, soft multi-tenancy with everything on this page is a defensible architecture. If a tenant breach must not be able to reach other tenants under any circumstances, you need hard isolation.

Decommissioning a tenant namespace

When a tenant namespace is no longer needed, kubectl delete namespace <name> should remove it cleanly: the namespace controller walks every API resource inside it, fires the relevant cleanup, and finally drains the namespace's own finalizer. In practice, on a tenant namespace with custom resources from operators, PVCs bound to cloud volumes, or admission webhooks, deletion can stall in Terminating for hours or days. When that happens, see Kubernetes namespace stuck in Terminating: how to find and fix the finalizer holding it for the diagnostic flow and the safe way to drain the blocking finalizer.

When to escalate

If you have applied all layers and are still seeing cross-tenant access or resource starvation, collect this information before engaging your platform team:

  • kubectl describe resourcequota -n <namespace> output
  • kubectl describe limitrange -n <namespace> output
  • kubectl get networkpolicy -n <namespace> -o yaml
  • kubectl get events -n <namespace> --field-selector reason=FailedCreate
  • CNI plugin name and version (kubectl get pods -n kube-system and check the CNI daemonset)
  • Pod Security Standard labels on the namespace (kubectl get namespace <name> --show-labels)
  • Cluster version (kubectl version)

Recurring server or deployment issues?

I help teams make production reliable with CI/CD, Kubernetes, and cloud—so fixes stick and deploys stop being stressful.

Explore DevOps consultancy

Search this site

Start typing to search, or browse the knowledge base and blog.