
In this blog post, I will walk you through how to implement multi-tenancy on OpenShift using project templates, ResourceQuotas, and LimitRanges. Out of the box, OpenShift lets any authenticated user create projects and consume cluster resources with no upper bound. That works fine on a dev sandbox with three users. It falls apart the moment you onboard multiple teams to a shared production cluster, because the first team to deploy a memory-leaking Java app will starve every other tenant of resources.
This is the classic noisy neighbor problem, and it’s the core reason multi-tenancy controls exist.
In this guide, I’ll walk through how to configure OpenShift so that every new project is automatically provisioned with resource guardrails, using a custom project request template that injects ResourceQuotas, LimitRanges, and NetworkPolicies at creation time. No manual per-namespace setup. No hoping developers remember to set limits.
Everything here was tested on OCP 4.20 running on bare metal with OVN-Kubernetes as the network plugin.
Table of Contents
Implement Multi-Tenancy on OpenShift Using Project Templates, ResourceQuotas, and LimitRanges
Why Default OpenShift Multi-Tenancy Isn’t Enough
Before diving in, it’s worth understanding that multi-tenancy comes in two forms:
- Hard multi-tenancy which means complete isolation. Each tenant gets their own dedicated cluster. There’s zero shared infrastructure and zero risk of cross-tenant interference, but it’s expensive and operationally heavy.
- Soft multi-tenancy which means multiple tenants share a single cluster, isolated through logical boundaries like namespaces, RBAC, resource quotas, and network policies.
OpenShift provides soft multi-tenancy through projects. Projects are Kubernetes namespaces with extra metadata such as:
- RBAC role bindings
- annotations for display name, description, requesting user, SCCs, pod security standards.
When you run oc new-project, the API server provisions all of this from a built-in template.
The problem is that this default template includes zero resource constraints:
- No quotas
- No default container resource limits
- No network isolation between projects
Every project gets unlimited access to cluster compute, and every pod can talk to every other pod across namespaces.
Consider my demo-app project in my OCP 4.20 cluster:
oc describe project demo-app
Sample output;
Name: demo-app
Created: 2 weeks ago
Labels: kubernetes.io/metadata.name=demo-app
pod-security.kubernetes.io/audit=restricted
pod-security.kubernetes.io/audit-version=latest
pod-security.kubernetes.io/warn=restricted
pod-security.kubernetes.io/warn-version=latest
Annotations: kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"v1","kind":"Namespace","metadata":{"annotations":{},"name":"demo-app"}}
openshift.io/description=
openshift.io/display-name=
openshift.io/requester=system:admin
openshift.io/sa.scc.mcs=s0:c28,c12
openshift.io/sa.scc.supplemental-groups=1000780000/10000
openshift.io/sa.scc.uid-range=1000780000/10000
security.openshift.io/MinimallySufficientPodSecurityStandard=restricted
Display Name: <none>
Description: <none>
Status: Active
Node Selector: <none>
Quota: <none>
Resource limits: <none>
Look at the last two lines: Quota: <none> and Resource limits: <none>. This project has no guardrails whatsoever. The pods running here can consume as much CPU, memory, and storage as the node will give them.
Let’s also check the network isolation gap. I have two separate projects on this cluster, demo-app running a MySQL database and test-app running an Nginx pod:
oc get pods -n demo-app
NAME READY STATUS RESTARTS AGE
mysql-6d9765ddd8-wtngm 1/1 Running 0 4d
oc get pods -n test-app
NAME READY STATUS RESTARTS AGE
nginx-6b54667779-5hxf6 1/1 Running 0 4d
These are completely unrelated workloads in separate namespaces. They should have no business talking to each other. But watch what happens when the Nginx pod in test-app tries to reach the MySQL pod in demo-app directly by its pod IP:
Let’s get the MySQL pod IP:
oc get pod mysql-6d9765ddd8-wtngm -n demo-app -o jsonpath='{.status.podIP}{"\n"}'
10.128.0.28
Let’s try to connect to MySQL for the test-app project:
oc exec -n test-app nginx-6b54667779-5hxf6 -- curl -v --connect-timeout 3 10.128.0.28:3306
Sample output;
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying 10.128.0.28:3306...
* Connected to 10.128.0.28 (10.128.0.28) port 3306
* using HTTP/1.x
> GET / HTTP/1.1
> Host: 10.128.0.28:3306
> User-Agent: curl/8.14.1
> Accept: */*
>
* Request completely sent off
* Received HTTP/0.9 when not allowed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
* closing connection #0
curl: (1) Received HTTP/0.9 when not allowed
command terminated with exit code 1
From the output, ignore the curl error because MySQL doesn’t speak HTTP, so curl can’t parse the response. The important line is Connected to 10.128.0.28 (10.128.0.28) port 3306. The TCP connection went through. A pod in test-app just reached a pod in demo-app with zero resistance; no firewall, no policy check, nothing. In a multi-tenant cluster, this means a compromised pod has free lateral movement to any other pod across every namespace.
In a multi-tenant cluster, this default behavior creates several real-world risks:
- A runaway deployment in one namespace can exhaust node memory and trigger OOM kills across unrelated workloads.
- A developer who forgets to set resource requests causes the scheduler to overcommit nodes, degrading performance for everyone.
- Without network policies, a compromised pod in one namespace can reach services in every other namespace, a lateral movement goldmine.
- Object count sprawl (hundreds of ConfigMaps, secrets, PVCs) can quietly pressure etcd and degrade API server performance cluster-wide.
The fix is to replace the default project template with one that automatically provisions ResourceQuotas, LimitRanges, and NetworkPolicies every time a new project is created.
How OpenShift Project Creation Actually Works
Before diving into configuration, it helps to understand the flow.
When a user runs oc new-project <project-name> or clicks Create Project in the web console:
- The API server intercepts this request and checks the
project.config.openshift.io/clusterresource for aprojectRequestTemplatereference. - If no custom template is configured, the API server uses a hardcoded default that creates the Project object and an
adminRoleBinding for the requesting user. - If a custom template is configured, the API server processes that template instead, substituting parameters like
${PROJECT_NAME},${PROJECT_ADMIN_USER}, and${PROJECT_REQUESTING_USER}. - Every object defined in that template gets created in the new namespace, including any ResourceQuotas, LimitRanges, NetworkPolicies, or RoleBindings you’ve added.
This means you can inject arbitrary Kubernetes resources into every new project without writing an admission webhook or external controller. The mechanism is built into the platform.
Step 1: Export the Default Project Template
Let’s start by exporting the bootstrap template so you have a working base:
oc adm create-bootstrap-project-template -o yaml > project-request-template.yaml
The output looks something like this:
apiVersion: template.openshift.io/v1
kind: Template
metadata:
creationTimestamp: null
name: project-request
objects:
- apiVersion: project.openshift.io/v1
kind: Project
metadata:
annotations:
openshift.io/description: ${PROJECT_DESCRIPTION}
openshift.io/display-name: ${PROJECT_DISPLAYNAME}
openshift.io/requester: ${PROJECT_REQUESTING_USER}
creationTimestamp: null
name: ${PROJECT_NAME}
spec: {}
status: {}
- apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
creationTimestamp: null
name: admin
namespace: ${PROJECT_NAME}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: admin
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: ${PROJECT_ADMIN_USER}
parameters:
- name: PROJECT_NAME
- name: PROJECT_DISPLAYNAME
- name: PROJECT_DESCRIPTION
- name: PROJECT_ADMIN_USER
- name: PROJECT_REQUESTING_USER
This is the bare-bones default, a Project object and an admin RoleBinding. Nothing else. Now you need to extend it.
Step 2: Add a ResourceQuota to Project Template
A ResourceQuota sets hard caps on the aggregate resources a namespace can consume. It operates at the project level, not the container level, meaning it limits the total sum of all pod requests and limits across the entire namespace.
A ResourceQuota manifest has two key parts:
- standard Kubernetes metadata (
apiVersion,kind,metadata) and - the
spec.hardfield, which is a flat key-value map where each key is a resource name and each value is the maximum allowed quantity.
OpenShift supports three categories of resources you can constrain under spec.hard:
- Compute resources: aggregate CPU and memory across all pods in the namespace. These use the
requests.<resource>andlimits.<resource>format (e.g.,requests.cpu,limits.memory). - Storage resources: total storage consumption and number of PersistentVolumeClaims. Includes
requests.storage,persistentvolumeclaims, and per-StorageClass variants likegold.storageclass.storage.k8s.io/requests.storage. - Object counts: the number of Kubernetes objects that can exist in the namespace. Includes
pods,services,configmaps,secrets,replicationcontrollers, and OpenShift-specific types likeopenshift.io/imagestreams.
Here is a sample ResourceQuota we are using in this guide:
- apiVersion: v1
kind: ResourceQuota
metadata:
name: ${PROJECT_NAME}-quota
namespace: ${PROJECT_NAME}
spec:
hard:
pods: "20"
requests.cpu: "4"
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16Gi
persistentvolumeclaims: "10"
requests.storage: 50Gi
configmaps: "30"
secrets: "30"
services: "10"
Where:
- pods: “20”: A reasonable ceiling for a single team’s namespace. Enough for a typical microservices deployment (5-10 services with 2 replicas each), but prevents a misconfigured HPA from spawning 500 pods. Adjust based on your cluster capacity and number of tenants.
- requests.cpu: “4” / limits.cpu: “8”: The gap between requests and limits is intentional. Requests determine scheduling guarantees, the scheduler won’t place pods unless nodes have 4 cores worth of headroom for this namespace. Limits allow bursting up to 8 cores. This 2:1 ratio is a reasonable starting point for general workloads. If you’re running latency-sensitive services, tighten the ratio. For batch workloads, you can widen it.
- requests.memory: 8Gi / limits.memory: 16Gi: Memory is not compressible like CPU. When a container exceeds its memory limit, it gets OOM-killed. A 2:1 ratio here is more aggressive, you’re betting that not all containers will hit their memory limits simultaneously. On production clusters with memory-hungry JVM workloads, I’d recommend tightening this to 1.5:1 or even 1:1.
- persistentvolumeclaims: “10” / requests.storage: 50Gi: This prevents a single tenant from claiming excessive storage. This is particularly important if you’re running OpenShift Data Foundation (ODF/Ceph), where storage exhaustion affects the entire cluster. Note that
requests.storageis the sum of storage requests across all PVCs in the namespace. So with these values, a tenant can create up to 10 PVCs, but their combined storage requests cannot exceed 50Gi. To control individual PVC sizes, use the LimitRange PVC section. - configmaps: “30” / secrets: “30”: These objects count quotas protect etcd. Every object stored in etcd consumes memory and adds to compaction time. A namespace that creates thousands of ConfigMaps can degrade API server responsiveness for the whole cluster.
Step 3: Add a LimitRange to Project Template
While ResourceQuota sets project-level totals, a LimitRange sets per-container and per-pod defaults and bounds. Crucially, it automatically injects default requests and limits into containers that don’t specify them, which solves the quota enforcement issue mentioned above.
Like any other Kubernetes resource, a LimitRange manifest has the standard metadata fields (apiVersion, kind, metadata). The core of the configuration lives under spec.limits, which is an array. Each entry under the spec.limits targets a specific resource type using the type field. OpenShift supports the following types:
- Container: Defines resource constraints and defaults for individual containers within a pod. This is the most commonly used type and the only one that automatically injects default values.
- Pod: Defines constraints on the total resources used across all containers within a single pod (e.g., CPU, memory).
- PersistentVolumeClaim: Defines constraints on the storage size for each individual PersistentVolumeClaim (PVC).
- openshift.io/Image: Defines the maximum size for images pushed to the internal registry.
- openshift.io/ImageStream: Defines constraints on the number of image tags and image references allowed in an image stream.
Each type supports a different set of fields.
Here is the sample LimitRange config:
- apiVersion: v1
kind: LimitRange
metadata:
name: ${PROJECT_NAME}-limits
namespace: ${PROJECT_NAME}
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 256Mi
max:
cpu: "2"
memory: 4Gi
min:
cpu: 10m
memory: 32Mi
maxLimitRequestRatio:
cpu: "10"
- type: Pod
max:
cpu: "4"
memory: 8Gi
min:
cpu: 10m
memory: 32Mi
- type: PersistentVolumeClaim
min:
storage: 1Gi
max:
storage: 20Gi
Where:
- default: Applied as the
limitsfor any container that doesn’t specify its own. Setting this to 500m CPU and 512Mi memory is a sane baseline. It prevents forgotten containers from consuming unbounded resources while being generous enough for most lightweight services. - defaultRequest: Applied as the
requestsfor containers without explicit requests. At 100m CPU and 256Mi memory, this is low enough that pods schedule easily, but high enough that the scheduler makes meaningful placement decisions. - max / min: Hard boundaries. No single container can request more than 2 CPU or 4Gi memory, and no container can request less than 10m CPU or 32Mi memory. The max prevents a single container from monopolizing a node’s resources. The min prevents developers from setting nonsensically low requests (like 1m CPU) that create scheduling illusions.
- maxLimitRequestRatio: cpu: “10”: This limits overcommit at the container level by capping the ratio between a container’s CPU limit and its CPU request at 10:1. For example, a container requesting 100m CPU can have a limit no higher than 1000m, and a container requesting 500m can have a limit no higher than 5000m (5 CPU). Without this, a developer could set requests to 1m and limits to 8 CPU, effectively gaming the scheduler while consuming very little in the eyes of the ResourceQuota.
- PersistentVolumeClaim: The PVC limits prevent developers from requesting excessively large or small volumes. A 1Gi minimum avoids creating tiny volumes that waste metadata overhead, and a 20Gi maximum prevents a single PVC from consuming a disproportionate chunk of your storage pool.
The LimitRange and ResourceQuota resources work together because:
- LimitRange ensures every individual container plays by the rules (has requests/limits set, within acceptable bounds).
- ResourceQuota ensures the entire namespace doesn’t exceed its allocated share of cluster resources.
Without LimitRange, developers who forget to set limits will get rejected by the quota. Without ResourceQuota, a developer can spin up 100 containers that each respect the LimitRange but collectively overwhelm the cluster. As such, you need both.
Step 4: Add Default NetworkPolicies
Resource isolation without network isolation is incomplete. By default, every pod in an OpenShift cluster can reach every other pod, including pods in other namespaces. As we demonstrated earlier, this is a security gap.
A NetworkPolicy controls traffic flow to and from pods using three key fields under spec:
- podSelector: selects which pods in the namespace the policy applies to. An empty selector (
{}) means all pods in the namespace. - policyTypes: declares whether the policy governs
Ingress(incoming traffic),Egress(outgoing traffic), or both. - ingress / egress: These are the rules defining allowed traffic. Each rule can filter by
from(source) orto(destination) using:podSelector(pods by label),namespaceSelector(namespaces by label), oripBlock(CIDR ranges).
The key thing to understand is that once you create a NetworkPolicy that selects a pod, the pod’s behavior changes from “default-allow” (allowing all traffic) to “default-deny” (blocking all traffic) for the policy types you define. Only the traffic explicitly allowed by the rules in the policy will be permitted.
As a best practice, you typically start by creating a “blanket deny” policy that blocks all traffic, and then you add specific rules to allow the necessary traffic for your services or applications.
Here are the sample NetworkPolicies we are using to create a deny-by-default posture with controlled exceptions:
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-ingress
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress: []
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-same-namespace
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- podSelector: {}
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-openshift-ingress
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
policy-group.network.openshift.io/ingress: ""
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-openshift-monitoring
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
network.openshift.io/policy-group: monitoring
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-kube-apiserver-operator
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: openshift-kube-apiserver-operator
podSelector:
matchLabels:
app: kube-apiserver-operator
policyTypes:
- Ingress
The logic here:
- deny-all-ingress: Blocks all incoming traffic to every pod in the namespace. This is the baseline.
- allow-from-same-namespace: Pods within the same project can still talk to each other. Without this, your app’s frontend can’t reach its backend.
- allow-from-openshift-ingress: Allows the OpenShift router (ingress controller) to reach pods that have Routes. Without this, your Routes stop working.
- allow-from-openshift-monitoring: Allows Prometheus to scrape metrics from pods in the namespace.
- allow-from-kube-apiserver-operator: Allows the API server operator’s webhook health checks to reach pods. Without this, you may see webhook validation failures.
This gives you project-level network isolation while preserving all platform functionality. Tenants that need cross-namespace access (e.g., a shared database namespace) can add additional policies as needed.
Step 5: Load the Template and Configure OpenShift
With all the objects added, your complete template is ready. Remember the default template we exported in Step 1 using oc adm create-bootstrap-project-template? We’ve now extended it with our ResourceQuota, LimitRange, and NetworkPolicies.
Here is what our updated project-request-template.yaml looks like with everything assembled:
cat project-request-template.yaml
Sample updated template:
apiVersion: template.openshift.io/v1
kind: Template
metadata:
creationTimestamp: null
name: project-request
objects:
- apiVersion: project.openshift.io/v1
kind: Project
metadata:
annotations:
openshift.io/description: ${PROJECT_DESCRIPTION}
openshift.io/display-name: ${PROJECT_DISPLAYNAME}
openshift.io/requester: ${PROJECT_REQUESTING_USER}
creationTimestamp: null
name: ${PROJECT_NAME}
spec: {}
status: {}
- apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
creationTimestamp: null
name: admin
namespace: ${PROJECT_NAME}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: admin
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: ${PROJECT_ADMIN_USER}
- apiVersion: v1
kind: ResourceQuota
metadata:
name: ${PROJECT_NAME}-quota
namespace: ${PROJECT_NAME}
spec:
hard:
pods: "20"
requests.cpu: "4"
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16Gi
persistentvolumeclaims: "10"
requests.storage: 50Gi
configmaps: "30"
secrets: "30"
services: "10"
- apiVersion: v1
kind: LimitRange
metadata:
name: ${PROJECT_NAME}-limits
namespace: ${PROJECT_NAME}
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 256Mi
max:
cpu: "2"
memory: 4Gi
min:
cpu: 10m
memory: 32Mi
maxLimitRequestRatio:
cpu: "10"
- type: Pod
max:
cpu: "4"
memory: 8Gi
min:
cpu: 10m
memory: 32Mi
- type: PersistentVolumeClaim
min:
storage: 1Gi
max:
storage: 20Gi
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-ingress
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress: []
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-same-namespace
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- podSelector: {}
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-openshift-ingress
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
policy-group.network.openshift.io/ingress: ""
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-openshift-monitoring
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
network.openshift.io/policy-group: monitoring
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-kube-apiserver-operator
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: openshift-kube-apiserver-operator
podSelector:
matchLabels:
app: kube-apiserver-operator
policyTypes:
- Ingress
parameters:
- name: PROJECT_NAME
- name: PROJECT_DISPLAYNAME
- name: PROJECT_DESCRIPTION
- name: PROJECT_ADMIN_USER
- name: PROJECT_REQUESTING_USER
Now load it into the openshift-config namespace:
oc create -f project-request-template.yaml -n openshift-config
Then tell OpenShift to use it by editing the cluster-level project configuration:
oc edit project.config.openshift.io/cluster
Add the projectRequestTemplate reference under spec:
apiVersion: config.openshift.io/v1
kind: Project
metadata:
...
name: cluster
...
spec:
projectRequestTemplate:
name: project-request
After saving, the openshift-apiserver pods will restart to pick up the change. You can watch the rollout:
oc get pods -n openshift-apiserver -w
Wait until all pods are back in Running state with 2/2 ready containers. This typically takes 1-2 minutes.
NAME READY STATUS RESTARTS AGE
apiserver-59d69f4775-cnt84 2/2 Running 0 4m
apiserver-59d69f4775-pdlx2 2/2 Running 0 6m
apiserver-59d69f4775-xbmhx 2/2 Running 0 1m
Step 6: Test the Onboarding Flow
Create a test project:
oc new-project tenant-test
Now verify that all your template objects were provisioned:
Check ResourceQuota
oc get resourcequota -n tenant-test
NAME REQUEST LIMIT AGE
tenant-test-quota configmaps: 2/30, persistentvolumeclaims: 0/10, pods: 0/20, requests.cpu: 0/4, requests.memory: 0/8Gi, requests.storage: 0/50Gi, secrets: 3/30, services: 0/10 limits.cpu: 0/8, limits.memory: 0/16Gi 48s
Check LimitRange:
oc get limitrange -n tenant-test
NAME CREATED AT
tenant-test-limits 2026-03-25T16:10:51Z
Check NetworkPolicies:
oc get networkpolicy -n tenant-test
NAME POD-SELECTOR AGE
allow-from-kube-apiserver-operator <none> 2m45s
allow-from-openshift-ingress <none> 2m46s
allow-from-openshift-monitoring <none> 2m45s
allow-from-same-namespace <none> 2m46s
deny-all-ingress <none> 2m46s
Verify the LimitRange is injecting defaults by deploying a pod without resource specs:
oc run test-pod --image=registry.access.redhat.com/ubi9/ubi-minimal:latest \
-n tenant-test --command -- sleep 3600
Check that defaults were injected:
oc get pod test-pod -n tenant-test -o jsonpath='{.spec.containers[0].resources}' | jq .
You should see the default requests and limits from your LimitRange applied automatically.
{
"limits": {
"cpu": "500m",
"memory": "512Mi"
},
"requests": {
"cpu": "100m",
"memory": "256Mi"
}
}
Clean up:
oc delete project tenant-test
Quota Sizing: One Size Doesn’t Fit All
The quota values I used above are a reasonable starting point, but real clusters have tenants with very different resource profiles. A team running a single-pod cronjob doesn’t need the same quota as a team running a 12-replica microservices mesh.
There are a few approaches to handle this:
Tiered Quotas
Define multiple project templates for different tenant tiers (small, medium, large) and apply them based on the tenant’s needs. Since the projectRequestTemplate can only reference one template at a time, you’d either:
- Use the default template for the most common tier and manually adjust quotas post-creation for others.
- Disable self-provisioning entirely and use a GitOps pipeline or automation to create projects with the appropriate template.
ClusterResourceQuotas
OpenShift provides ClusterResourceQuota, which enforces quotas across multiple projects. This is useful when a single team owns several namespaces (dev, staging, production) and you want to cap their total resource usage:
oc create clusterquota team-alpha \
--project-annotation-selector openshift.io/requester=team-alpha \
--hard pods=60,requests.cpu=12,requests.memory=24Gi
This ensures that team-alpha’s combined usage across all their projects doesn’t exceed the defined limits, regardless of how they distribute workloads.
Monitoring and Right-Sizing
Setting quotas is not a one-time event. Quotas that are never reviewed drift in one of two directions: either teams hit their limits and deployments start failing silently, or quotas are set so generously they become meaningless. Both outcomes defeat the purpose.
You can use OpenShift’s built-in monitoring to track actual usage against quota limits. Navigate to Observe > Metrics in the OpenShift web console and use the Metrics Explorer to run PromQL queries. Alternatively, if your cluster is integrated with an external Grafana instance, you can build a dashboard from these queries.
The kube_resourcequota metric exposes both the hard limit and current usage for every quota in the cluster. To calculate CPU utilization as a percentage per namespace:
(
kube_resourcequota{type="used", resource="requests.cpu"}
/
kube_resourcequota{type="hard", resource="requests.cpu"}
) * 100
Run the same pattern for limits.memory, pods, and requests.storage by swapping the resource label.
If a team consistently uses:
- < 30%: Quota is overprovisioned. Reclaim headroom, reallocate to other tenants.
- 30–70%: Healthy operating range. No action needed.
- 70–85%: Getting close, watch it. Alert the team, discuss growth plans
- > 85%: High risk of hitting the wall. Expand quota or have the team optimize.
- 100%: Quota is blocking deployments. Pods pending, PVCs failing, act immediately.
Alerting before teams hit the wall
Don’t wait for a team to open a ticket saying their deployment is stuck. You can set a PrometheusRule to fire before that happens:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: quota-utilization-alerts
namespace: openshift-monitoring
spec:
groups:
- name: quota.rules
rules:
- alert: NamespaceQuotaCPUHigh
expr: |
(
kube_resourcequota{type="used", resource="requests.cpu"}
/
kube_resourcequota{type="hard", resource="requests.cpu"}
) * 100 > 85
for: 10m
labels:
severity: warning
annotations:
summary: "Namespace {{ $labels.namespace }} CPU quota above 85%"
description: "CPU request utilization is {{ $value | humanize }}%. Consider expanding quota or reviewing workloads."
Create equivalent rules for limits.memory and pods. The for: 10m prevents noise from brief spikes, it only fires if the condition holds for 10 consecutive minutes.
Finding over-requested workloads
High quota utilization isn’t always a signal to increase limits. Sometimes teams over-request resources relative to what they actually use. This query surfaces the gap between requested CPU and actual consumption at the namespace level:
sum by (namespace) (
rate(container_cpu_usage_seconds_total{container!=""}[5m])
)
/
sum by (namespace) (
kube_pod_container_resource_requests{resource="cpu"}
)
A value well below 1.0 means the namespace is requesting significantly more CPU than it uses. This is the conversation to have with a team before granting a quota increase, often the fix is right-sizing requests, not expanding the quota.
Controlling Self-Provisioning
By default, all authenticated users can create projects via the self-provisioner ClusterRole.
oc adm policy who-can create projectrequests
The key line in the output is this: Groups: system:authenticated:oauth.
That single line means every user who can log into the cluster can provision a new project and with it, consume quota, create network-isolated namespaces, and spin up workloads.
You can also confirm the role itself:
oc describe clusterrole self-provisioner
Name: self-provisioner
Labels: <none>
Annotations: openshift.io/description: A user that can request projects.
rbac.authorization.kubernetes.io/autoupdate: true
PolicyRule:
Resources Non-Resource URLs Resource Names Verbs
--------- ----------------- -------------- -----
projectrequests [] [] [create]
projectrequests.project.openshift.io [] [] [create]
oc describe clusterrolebinding self-provisioner
Name: self-provisioners
Labels: <none>
Annotations: rbac.authorization.kubernetes.io/autoupdate: true
Role:
Kind: ClusterRole
Name: self-provisioner
Subjects:
Kind Name Namespace
---- ---- ---------
Group system:authenticated:oauth
In a production multi-tenant cluster, you may want to restrict this so only designated users or groups can create projects.
To disable self-provisioning entirely:
- Remove self-provisioning from all authenticated users group.
oc adm policy remove-cluster-role-from-group self-provisioner system:authenticated:oauth - Prevent the cluster from auto-restoring this binding on upgrade
oc annotate clusterrolebinding self-provisioners rbac.authorization.kubernetes.io/autoupdate=false --overwrite - Create a group for users who should be able to create projects
oc adm groups new project-creators - Grant self-provisioner ClusterRole to that group only
oc adm policy add-cluster-role-to-group self-provisioner project-creators - Add specific users to the group
oc adm groups add-users project-creators developer1 developer2
You can also set a custom message that users see when they’re denied project creation:
oc edit project.config.openshift.io/cluster
...
spec:
projectRequestMessage: "To request a new project, contact the platform team at [email protected] or submit a ticket in ServiceNow."
projectRequestTemplate:
name: project-request
Save and exit to update the configuration.
This is a better operational model for most enterprises, project creation goes through a gated process where the platform team can validate the request, assign appropriate quota tiers, and ensure naming conventions are followed.
In more mature environments, this gated process is often handled entirely through GitOps, project creation happens via pull requests to a Git repository, reviewed by the platform team, and synced to the cluster by ArgoCD or Flux. But that’s beyond the scope of this guide.
Common Pitfalls
- Template changes don’t apply retroactively. The project template only applies to projects created after the template is configured. Existing projects keep whatever resources they had. To bring existing projects into compliance, you need to manually create the ResourceQuota, LimitRange, and NetworkPolicy objects in those namespaces or use a tools like Kyverno or Gatekeeper to enforce this as policy.
- Some operators and Helm charts don’t set resource limits. Once your LimitRange is in place, the defaults will kick in for these. But if the defaults are too low for a particular operator, its pods may get throttled or OOM-killed. Test operator deployments in a namespace with your LimitRange before rolling out cluster-wide.
- Don’t quota system namespaces. Never apply ResourceQuotas to namespaces like
openshift-*,kube-system, ordefault. Constraining etcd, the API server, or cluster operators can destabilize your entire platform. Project templates only affect user-created projects, so this shouldn’t happen organically, but be cautious if you’re retroactively applying quotas. - CPU is compressible; memory is not. When a container hits its CPU limit, it gets throttled, performance degrades but the container keeps running. When a container exceeds its memory limit, it gets killed. This asymmetry means you should be more conservative with memory limit overcommit ratios than CPU ratios.
Conclusion
At the start of this guide, your cluster had no guardrails. Any authenticated user could create a project, deploy unlimited workloads, and talk to any pod across any namespace. One runaway JVM could trigger OOM kills across unrelated teams. One forgotten requests: field could quietly overcommit a node. One compromised pod had free lateral movement to everything.
That’s no longer the case.
Every new project now gets a ResourceQuota that caps its total compute and storage footprint, a LimitRange that ensures no container can be deployed without sensible defaults, and a set of NetworkPolicies that enforce namespace-level isolation from the moment the project exists, not after someone remembers to configure it.
More importantly, none of this depends on developers doing the right thing. The template enforces it at creation time. The platform team sets the policy once; every tenant inherits it automatically.
From here, the natural next steps are:
- Tiered quotas: not every team needs the same allocation. A GitOps pipeline with ArgoCD lets you codify different quota profiles and apply them through a pull request workflow instead of manual
occommands. - Existing namespaces: the template only covers new projects. Use Kyverno or Gatekeeper to audit and remediate namespaces that predate this configuration.
- Quota reviews: the monitoring queries from the previous section should feed a monthly review. Quotas that are never revisited become either bottlenecks or dead weight.
Multi-tenancy isn’t a feature you enable, it’s a set of controls you layer and maintain. This guide gives you the foundation. What keeps it working is treating quota sizing and network policy review as ongoing operational work, not a one-time setup task.
