How to Implement Multi-Tenancy on OpenShift Using Project Templates, ResourceQuotas, and LimitRanges

How to Implement Multi-Tenancy on OpenShift Using Project Templates, ResourceQuotas, and LimitRanges

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.

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:

  1. The API server intercepts this request and checks the project.config.openshift.io/cluster resource for a projectRequestTemplate reference.
  2. If no custom template is configured, the API server uses a hardcoded default that creates the Project object and an admin RoleBinding for the requesting user.
  3. 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}.
  4. 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.hard field, 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> and limits.<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 like gold.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 like openshift.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.storage is 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.
Quota Enforcement Requires Requests/Limits

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 limits for 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 requests for 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.
Note:

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) or to (destination) using:
    • podSelector (pods by label),
    • namespaceSelector (namespaces by label), or
    • ipBlock (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.

Important!
NetworkPolicies are one of the riskiest components to roll out in a multi-tenancy setup. A single missing allow rule can silently break Routes, kill Prometheus metric scraping, or block API server webhook health checks, and the symptoms often look like unrelated application failures rather than a network policy issue. Always test your NetworkPolicies thoroughly in a test or UAT environment before applying them to production. Verify that Routes still work, monitoring still scrapes, and deployments don’t fail webhook validation. Only after confirming everything works should you include them in your project request template.

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:

  1. deny-all-ingress: Blocks all incoming traffic to every pod in the namespace. This is the baseline.
  2. 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.
  3. allow-from-openshift-ingress: Allows the OpenShift router (ingress controller) to reach pods that have Routes. Without this, your Routes stop working.
  4. allow-from-openshift-monitoring: Allows Prometheus to scrape metrics from pods in the namespace.
  5. 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, or default. 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 oc commands.
  • 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.

SUPPORT US VIA A VIRTUAL CUP OF COFFEE

We're passionate about sharing our knowledge and experiences with you through our blog. If you appreciate our efforts, consider buying us a virtual coffee. Your support keeps us motivated and enables us to continually improve, ensuring that we can provide you with the best content possible. Thank you for being a coffee-fueled champion of our work!

Photo of author
Kifarunix
DevOps Engineer and Linux Specialist with deep expertise in RHEL, Debian, SUSE, Ubuntu, FreeBSD... Passionate about open-source technologies, I specialize in Kubernetes, Docker, OpenShift, Ansible automation, and Red Hat Satellite. With extensive experience in Linux system administration, infrastructure optimization, information security, and automation, I design and deploy secure, scalable solutions for complex environments. Leveraging tools like Terraform and CI/CD pipelines, I ensure seamless integration and delivery while enhancing operational efficiency across Linux-based infrastructures.

Leave a Comment

document.addEventListener("DOMContentLoaded", function() { document.querySelectorAll(".scroll-box").forEach(function(box) { box.style.position = "relative"; // Needed for absolute positioning of button var button = document.createElement("button"); button.className = "copy-icon-btn"; button.setAttribute("aria-label", "Copy code"); button.innerHTML = ''; box.appendChild(button); button.addEventListener("click", function() { var text = box.innerText; navigator.clipboard.writeText(text).then(function() { button.querySelector("svg").setAttribute("fill", "#4CAF50"); setTimeout(function() { button.querySelector("svg").setAttribute("fill", "white"); }, 1500); }).catch(function(err) { console.error("Copy failed: ", err); }); }); }); });