Kubernetes Multi-Tenant Platform
Isolate two teams on one Kubernetes cluster with RBAC, quotas, and network policies.
Introduction
30 Second Summary
Imagine an office building where multiple companies share the same address. Each company needs their own locked floor, their own share of electricity, and guarantees that another tenant cannot wander into their space or hog all the resources.
In this project, you will build a multi-tenant Kubernetes platform that isolates two fictional engineering teams on a shared cluster. You will set up access control, resource limits, and network boundaries, then prove each isolation layer works by running verification commands.
What You'll Build
After completing this project, you can open a terminal, impersonate a team member, and demonstrate that they cannot read another team's secrets, exceed their resource budget, or send network traffic to another team's pods.
By the end of this project, you'll have:
- Two isolated team namespaces with RBAC rules that block each team from accessing the other's secrets and resources, testable with impersonation commands.
- Resource quotas that reject a deployment when a team tries to exceed their CPU or memory budget, with a visible error you trigger yourself.
- Network policies that block cross-namespace pod traffic, proven by a curl request that times out at the namespace boundary.
- Secret Mission: Add a shared-services namespace with a monitoring pod both teams can reach, while teams still cannot reach each other.
Are there any prerequisites?
No prior Kubernetes experience is required. This project includes full setup instructions for every tool you need, including Docker Desktop, minikube, and kubectl. You just need a macOS or Windows machine with at least 4 GB of free RAM.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Local Kubernetes Cluster
To build a multi-tenant platform, you first need a running Kubernetes cluster that can actually enforce isolation rules.
Not every cluster supports network isolation out of the box. The default network plugin (Kindnet) ignores NetworkPolicy resources entirely. You need a cluster running Calico as its CNI (Container Network Interface) so that network policies have teeth. In this step, you will install Docker Desktop, minikube, and kubectl, then start a local cluster with Calico enabled.
In this step, get ready to:
- Install Docker Desktop as the container runtime for minikube.
- Install minikube and kubectl to create and manage your cluster.
- Start a Kubernetes cluster with Calico CNI and verify it is running.
Install Docker Desktop
Minikube needs a container runtime to run your cluster. Docker Desktop provides that runtime on both macOS and Windows.
- Download Docker Desktop from docker.com/products/docker-desktop.
- Run the installer.
macOS
- Open the downloaded .dmg file.
- Drag the Docker icon into your Applications folder.
- Open Docker from Applications. If prompted, allow any system permission requests.
Windows
- Run the Docker Desktop Installer.exe file.
- Make sure the Use WSL 2 instead of Hyper-V checkbox is selected during installation.
- Click Ok to proceed with the install, then restart your computer when prompted.
- After restarting, open Docker Desktop from the Start menu. If prompted, accept the service agreement.
Wait for Docker Desktop to finish starting. You will see the Docker whale icon in your system tray (Windows) or menu bar (macOS) when it is ready.
- Open a terminal and verify Docker is running by entering this command:
docker --version
✔️ I see a version number
Docker is ready. You will see output similar to Docker version 28.x.x. Move on to the next substep.
ⓧ Command not found
Docker Desktop is not on your PATH yet.
- Make sure Docker Desktop has fully launched (look for the whale icon in your system tray or menu bar).
- Close and reopen your terminal window so it picks up the new PATH.
- Try the docker --version command again.
Still not working?
On Windows, ensure WSL 2 is enabled. Open PowerShell as Administrator and run wsl --install, then restart. On macOS, check that your macOS version is one of the three most recent major releases.
help me troubleshoot Docker Desktop not being recognized in my terminal
Why Docker Desktop?
Minikube can use different drivers to run your cluster. Docker Desktop is the recommended driver because it works consistently on both macOS and Windows without additional hypervisor configuration.
Docker Desktop is free for personal use, education, and small businesses.
Install minikube and kubectl
Minikube creates a local single-node Kubernetes cluster on your machine. The kubectl command-line tool lets you interact with any Kubernetes cluster.
macOS
- Install minikube (which includes kubectl as a Homebrew dependency) by running this command:
brew install minikube
What does this command do?
Homebrew installs minikube and kubectl (listed as the kubernetes-cli dependency) in one step.
Don't have Homebrew?
If your terminal says brew: command not found, install Homebrew first by visiting brew.sh and following the one-line install instruction. Then re-run the command above.
Windows
- Open PowerShell and install minikube by running this command:
winget install Kubernetes.minikube
- Install kubectl separately by running this command:
winget install -e --id Kubernetes.kubectl
- Close and reopen PowerShell so both commands are available on your PATH.
What do these commands do?
The first command installs the minikube binary. The second installs the standalone kubectl binary. Windows requires them as two separate installs (unlike macOS where Homebrew bundles both).
- Verify minikube is installed by running:
minikube version
- Verify kubectl is installed by running:
kubectl version --client
✔️ I see version numbers for both
Both tools are installed. You should see minikube version v1.38.1 (or newer) and a kubectl client version. Move on to starting your cluster.
ⓧ One or both say command not found
- Close your terminal completely and open a fresh window. Package managers often require a new shell session to update the PATH.
- On macOS, if minikube is not found, try running brew link minikube.
- On Windows, if kubectl is not found, confirm you ran the second winget install command for Kubernetes.kubectl.
Still having trouble?
help me troubleshoot minikube or kubectl not being found after installation
Start the cluster with Calico CNI
Now you have all the tools. Time to create your Kubernetes cluster with Calico enabled so that NetworkPolicy resources will actually be enforced.
- Start your minikube cluster with Calico and 4 GB of memory by running this command:
minikube start --cni=calico --memory=4096
What does this command do?
- minikube start creates a single-node Kubernetes cluster on your machine using Docker as the driver.
- --cni=calico tells minikube to install Calico as the Container Network Interface plugin. Without Calico, any NetworkPolicy you create later would have zero effect.
- --memory=4096 allocates 4 GB of RAM to the cluster, which gives Calico and your workloads room to run comfortably.
This command takes a few minutes. You will see progress messages as minikube downloads images and starts components.
Seeing an error about Docker not running?
Make sure Docker Desktop is fully started (the whale icon is in your menu bar or system tray and is not animating). Minikube uses Docker as its default driver.
help me fix minikube start failing because it cannot connect to Docker
Once the command completes, verify your cluster node is ready.
- Before you run the next command, what status do you expect to see for your node?
- Check the cluster node by running:
kubectl get nodes
You should see one node named minikube with a STATUS of Ready.
Now verify that Calico is running inside the cluster.
- Check the Calico pod by running:
kubectl get pods -n kube-system -l k8s-app=calico-node
You should see a calico-node pod with a STATUS of Running. If it shows Init or ContainerCreating, wait 30 seconds and run the command again.
Calico pod not showing Running?
Calico can take 1-2 minutes to fully initialize after the cluster starts. Run the command again after waiting. If it stays in CrashLoopBackOff, try deleting and recreating the cluster:
minikube delete
minikube start --cni=calico --memory=4096
help me troubleshoot Calico not starting on my minikube cluster
Your local Kubernetes cluster is running with Calico enforcing network policies. Next up, you will create isolated namespaces for two teams and deploy workloads into them.
Create Team Namespaces and Deploy Workloads
Your cluster is running and Calico is enforcing network policies under the hood. Now you need isolated spaces for each team to work in, plus running workloads to test against.
In this step, you will create two team namespaces, deploy an nginx pod into each one, and then discover a critical problem. Without any access controls, anyone on the cluster can read any namespace's secrets.
In this step, get ready to:
- Create labeled namespaces for team-alpha and team-beta with simulated credentials.
- Deploy nginx workloads and services into each namespace.
- Expose the default open-access problem that namespaces alone cannot solve.
Create the team namespaces with labels
Kubernetes Namespaces are logical boundaries that group resources together. Labels on namespaces let you target them in policies later.
- Create a file called namespaces.yaml in your project folder by running:
touch namespaces.yaml
- Open namespaces.yaml in your text editor and paste the following:
apiVersion: v1
kind: Namespace
metadata:
name: team-alpha
labels:
team: alpha
---
apiVersion: v1
kind: Namespace
metadata:
name: team-beta
labels:
team: beta
What does this code do?
This defines two Kubernetes namespaces in a single YAML file, separated by ---.
- Each namespace gets a team label (alpha or beta) that NetworkPolicies will use later to identify traffic boundaries.
- The --- separator tells Kubernetes to treat each section as a separate resource in one file.
- Save namespaces.yaml.
- Apply the namespaces to your cluster by running:
kubectl apply -f namespaces.yaml
You should see two lines confirming both namespaces were created.
- Verify the namespaces exist by running:
kubectl get namespaces
You should see team-alpha and team-beta in the list alongside the default system namespaces.
Don't see the team namespaces?
- Make sure you saved namespaces.yaml before running the apply command.
- Check you are in the same directory as the file. Run ls and confirm namespaces.yaml appears.
- help me debug why my namespaces weren't created
✔️ Awesome, I've got everything!
Great. Make sure you have saved namespaces.yaml before continuing.
ⓧ I'd like to double check the full code
apiVersion: v1
kind: Namespace
metadata:
name: team-alpha
labels:
team: alpha
---
apiVersion: v1
kind: Namespace
metadata:
name: team-beta
labels:
team: beta
Now each team needs simulated credentials so you can test access control later.
- Create a secret in team-alpha by running:
kubectl create secret generic team-credentials --from-literal=api-key=alpha-secret-123 -n team-alpha
- Create a secret in team-beta by running:
kubectl create secret generic team-credentials --from-literal=api-key=beta-secret-456 -n team-beta
You should see secret/team-credentials created after each command.
What are these secrets for?
These simulate real team credentials like API keys or database passwords. In Step 3, you will test whether one team can read the other team's secrets.
Deploy a workload in each namespace
Running pods give you something concrete to test isolation against. You will deploy an nginx web server in each namespace so you can later verify network and access boundaries.
- Create a file called deployment-alpha.yaml by running:
touch deployment-alpha.yaml
- Open deployment-alpha.yaml and paste the Deployment resource:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: team-alpha
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
What does this Deployment do?
- Creates a single nginx pod (replicas: 1) in the team-alpha namespace.
- The resources block declares how much CPU and memory the pod requests and is allowed to use. This becomes critical in Step 4 when you add quotas.
- The app: nginx label connects the Deployment to the Service you will add next.
- Below the Deployment section in the same file, add a --- separator and then the Service resource:
---
apiVersion: v1
kind: Service
metadata:
name: nginx-service
namespace: team-alpha
spec:
selector:
app: nginx
ports:
- port: 80
targetPort: 80
What does this Service do?
A Service gives your pod a stable DNS name inside the cluster. Other pods can reach this nginx at nginx-service.team-alpha.svc.cluster.local without knowing the pod's IP.
- Save deployment-alpha.yaml.
- Apply the deployment to your cluster by running:
kubectl apply -f deployment-alpha.yaml
- Verify the pod is running by running:
kubectl get pods -n team-alpha
You should see one nginx pod with a status of Running. It may take 30 seconds to pull the image on first run.
Pod stuck in ContainerCreating?
- Wait 30-60 seconds for the image to download. Run the get pods command again.
- If it stays stuck, check events with kubectl describe pod -n team-alpha and look for image pull errors.
- help me debug my pod that won't start
✔️ Awesome, I've got everything!
Great. Confirm you have saved deployment-alpha.yaml and the pod is running.
ⓧ I'd like to double check the full code
Your deployment-alpha.yaml should contain two resources. Scroll up and confirm you have:
- The Deployment resource (with namespace: team-alpha, image: nginx:1.27, and resource requests/limits).
- A --- separator.
- The Service resource (with name: nginx-service, namespace: team-alpha, and port 80).
Now create the same structure for team-beta.
- Create deployment-beta.yaml by running:
touch deployment-beta.yaml
- Open deployment-beta.yaml and paste the Deployment resource for team-beta:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: team-beta
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
- Below the Deployment, add the --- separator and the Service resource:
---
apiVersion: v1
kind: Service
metadata:
name: nginx-service
namespace: team-beta
spec:
selector:
app: nginx
ports:
- port: 80
targetPort: 80
Why is this identical except for namespace?
Both teams get the same workload so you can test isolation symmetrically. The only difference is namespace: team-beta which places the resources in the other team's boundary.
- Save deployment-beta.yaml.
- Apply the deployment by running:
kubectl apply -f deployment-beta.yaml
- Verify the pod is running by running:
kubectl get pods -n team-beta
You should see one nginx pod with Running status, just like team-alpha.
✔️ Awesome, I've got everything!
Great. Confirm you have saved deployment-beta.yaml and the pod is running.
ⓧ I'd like to double check the full code
Your deployment-beta.yaml should contain two resources. Scroll up and confirm you have:
- The Deployment resource (with namespace: team-beta, image: nginx:1.27, and resource requests/limits).
- A --- separator.
- The Service resource (with name: nginx-service, namespace: team-beta, and port 80).
Test the problem: default open access
You have two namespaces with secrets and running workloads. But are they actually isolated from each other?
Before you run the next command, predict what will happen. Do you think you can read team-beta's secrets from the default context?
- Try reading team-beta's secrets by running:
kubectl get secrets -n team-beta
You can see team-credentials listed in the output. Anyone with cluster access can read any namespace's secrets.
- Try the same for team-alpha by running:
kubectl get secrets -n team-alpha
Same result. Both teams' credentials are visible to everyone on the cluster.
This is the problem namespaces alone cannot solve
Namespaces group resources, but they do not restrict access. Without RBAC rules, every user and service account can read, modify, or delete resources in any namespace.
This gap is exactly what you will close in the next step by defining who can do what in which namespace.
Your namespaces are created, workloads are deployed, and you have seen the isolation gap firsthand. Next up, you will lock down access with RBAC so each team can only manage their own namespace.
Lock Down Access with RBAC
In the previous step, you discovered a serious gap. Any user with cluster access can read secrets from any namespace, including namespaces they don't belong to.
RBAC (Role-Based Access Control) fixes this by defining exactly who can do what in which namespace. In this step, you will create permission rules and bind them to specific users so each team can only manage their own resources.
In this step, get ready to:
- Create a ClusterRole defining developer permissions.
- Bind the ClusterRole to each team in their own namespace only.
- Verify that cross-namespace access is blocked using impersonation.
Create a ClusterRole for team developers
A ClusterRole defines a reusable set of permissions. You define the permissions once at the cluster level, then bind them to specific users in specific namespaces.
- Create a new file called rbac.yaml in your project folder.
- Add the ClusterRole definition by pasting this code:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: team-developer
rules:
- apiGroups: [""]
resources: ["pods", "services", "configmaps", "secrets"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
What does this code do?
This ClusterRole named team-developer grants full CRUD access to the resources a developer typically needs.
- The first rule targets core API resources: pods, services, configmaps, and secrets.
- The second rule targets the apps API group for deployments and replicasets.
- The verbs (get, list, watch, create, update, delete) define what actions are allowed on those resources.
Why a ClusterRole instead of a Role?
A Role only exists in one namespace. A ClusterRole is defined once and can be bound to different users in different namespaces using RoleBindings. This avoids duplicating the same permission definition in every namespace.
- Save rbac.yaml.
- Apply the ClusterRole to your cluster by running:
kubectl apply -f rbac.yaml
You should see clusterrole.rbac.authorization.k8s.io/team-developer created in the output.
Seeing an error about the file?
- Make sure you saved the file before running the command.
- Confirm you are running the command from the same folder where rbac.yaml lives.
- Check for indentation errors. YAML requires consistent spacing (2 spaces, no tabs).
Help me debug my kubectl apply error for rbac.yaml
Bind the ClusterRole to each team's namespace
The ClusterRole exists, but it does not grant anyone permissions yet. A RoleBinding connects a ClusterRole to a specific user inside a specific namespace. Each team gets their own binding scoped to their namespace only.
- Open rbac.yaml and add the following RoleBindings below the ClusterRole:
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: team-alpha-dev-binding
namespace: team-alpha
subjects:
- kind: User
name: team-alpha-dev
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: team-developer
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: team-beta-dev-binding
namespace: team-beta
subjects:
- kind: User
name: team-beta-dev
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: team-developer
apiGroup: rbac.authorization.k8s.io
What do these RoleBindings do?
- The first RoleBinding grants user team-alpha-dev the team-developer permissions, but only inside the team-alpha namespace.
- The second RoleBinding does the same for user team-beta-dev in the team-beta namespace.
- Neither user gets any permissions in the other team's namespace. That is the isolation.
- Save rbac.yaml.
- Apply the updated file by running:
kubectl apply -f rbac.yaml
You should see the ClusterRole unchanged and two new RoleBindings created.
Only seeing the ClusterRole output?
- Make sure you included the --- separator between the ClusterRole and the first RoleBinding. YAML uses this to separate multiple documents in one file.
- Check that the namespace field is present in each RoleBinding's metadata. Without it, the binding has no target namespace.
Help me troubleshoot my RoleBinding not being created
✔️ Awesome, I've got everything!
Great. Double check you have saved rbac.yaml and applied it successfully.
ⓧ I'd like to double check the full code
Your rbac.yaml should contain these sections in order. Scroll up and confirm each is present:
- ClusterRole team-developer with two rules (core resources and apps resources).
- A --- separator.
- RoleBinding team-alpha-dev-binding in namespace team-alpha binding user team-alpha-dev.
- A --- separator.
- RoleBinding team-beta-dev-binding in namespace team-beta binding user team-beta-dev.
Verify isolation with impersonation
Kubernetes lets you test RBAC without creating real user certificates. The --as flag impersonates a user so you can check what they are allowed to do.
- First, confirm that team-alpha-dev can access secrets in their own namespace by running:
kubectl auth can-i get secrets --namespace=team-alpha --as=team-alpha-dev
You should see yes in the output. The RoleBinding grants this user access to secrets in team-alpha.
Now for the critical test. Before you run the next command, what do you think will happen when team-alpha-dev tries to access secrets in team-beta?
- Test cross-namespace access by running:
kubectl auth can-i get secrets --namespace=team-beta --as=team-alpha-dev
You should see no. The RoleBinding for team-alpha-dev only exists in the team-alpha namespace, so this user has zero permissions in team-beta.
- Try actually reading the secrets (not just checking permission) by running:
kubectl get secrets -n team-beta --as=team-alpha-dev
You should see an error message stating that the user is forbidden from listing secrets in team-beta. Compare this to Step 2 where any user could freely read any namespace's secrets. RBAC has closed that gap.
What just happened?
You proved the first layer of multi-tenant isolation. Each team's developer can manage resources in their own namespace, but gets rejected when reaching into the other team's space.
This is the least-privilege principle in action. Users get exactly the permissions they need and nothing more.
Still seeing yes for cross-namespace access?
- Confirm you applied the updated rbac.yaml with both RoleBindings. Run kubectl get rolebindings -n team-alpha to check.
- Make sure the --namespace flag in your command points to team-beta (the other team's namespace).
- Check that the --as flag uses the exact username team-alpha-dev (not the namespace name).
Help me debug why RBAC is not blocking cross-namespace access
Access control is locked down. Each team can only manage their own namespace. But there is still a gap: a single team could deploy hundreds of pods and consume all the cluster's CPU and memory, starving the other team. Next up, you will enforce resource limits with quotas.
Enforce Resource Limits with Quotas
In the last step, you locked down who can act in each namespace with RBAC. But RBAC does not limit how much a team can consume.
A single team could deploy hundreds of pods and exhaust the cluster's entire CPU and memory budget, starving the other team. ResourceQuotas solve this by capping what each namespace can consume, preventing noisy neighbors.
In this step, get ready to:
- Apply ResourceQuotas to cap CPU, memory, and pod count per namespace.
- Apply LimitRanges to set default resource requests for pods.
- Test quota enforcement by deploying a resource-hungry pod and watching it get rejected.
Apply ResourceQuotas to each namespace
A ResourceQuota object sets hard caps on the total resources all pods in a namespace can request. Once the combined requests hit the ceiling, Kubernetes rejects any new pod that would push the total over the limit.
- Create a file called resource-quotas.yaml in your project folder and paste the following:
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-alpha-quota
namespace: team-alpha
spec:
hard:
requests.cpu: "2"
requests.memory: 2Gi
limits.cpu: "4"
limits.memory: 4Gi
pods: "10"
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-beta-quota
namespace: team-beta
spec:
hard:
requests.cpu: "2"
requests.memory: 2Gi
limits.cpu: "4"
limits.memory: 4Gi
pods: "10"
What does this file do?
Each ResourceQuota sets a budget for one namespace. The hard section defines the maximum totals across all pods in that namespace.
- requests.cpu: "2" means all pods combined cannot request more than 2 CPU cores.
- limits.cpu: "4" means all pods combined cannot set CPU limits above 4 cores.
- pods: "10" means the namespace cannot have more than 10 pods running.
- Apply the quotas to your cluster by running:
kubectl apply -f resource-quotas.yaml
- Verify the quota is active by running:
kubectl describe resourcequota team-alpha-quota -n team-alpha
You should see a table showing Used vs Hard columns. The Used column reflects the resources your existing nginx pod already consumes (100m CPU, 128Mi memory).
Seeing an error when applying?
- Check that your YAML uses the exact namespace names team-alpha and team-beta (case-sensitive).
- Ensure the --- separator between the two documents has no trailing spaces.
Help me troubleshoot my ResourceQuota YAML file in Kubernetes
Apply LimitRanges for default pod limits
When a ResourceQuota exists in a namespace, every pod must declare resource requests or it gets rejected. A LimitRange provides sensible defaults so future pods without explicit resource specs still pass admission.
- Create a file called limit-ranges.yaml and paste the team-alpha LimitRange:
apiVersion: v1
kind: LimitRange
metadata:
name: team-alpha-limits
namespace: team-alpha
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
max:
cpu: "2"
memory: 2Gi
min:
cpu: 50m
memory: 64Mi
What does a LimitRange do?
A LimitRange sets per-container constraints within a namespace.
- default injects CPU/memory limits if a container omits them.
- defaultRequest injects CPU/memory requests if a container omits them.
- max and min reject any container whose requests or limits fall outside that range.
- Now add the team-beta LimitRange below the team-alpha block (separated by ---):
---
apiVersion: v1
kind: LimitRange
metadata:
name: team-beta-limits
namespace: team-beta
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
max:
cpu: "2"
memory: 2Gi
min:
cpu: 50m
memory: 64Mi
- Save limit-ranges.yaml and apply it by running:
kubectl apply -f limit-ranges.yaml
- Verify the LimitRange is active by running:
kubectl describe limitrange team-alpha-limits -n team-alpha
You should see a table showing the Min, Max, Default, and Default Request values you just defined.
LimitRange not showing your values?
- Make sure the --- separator sits on its own line between the two LimitRange documents.
- Confirm you saved the file before running kubectl apply.
Help me fix my LimitRange YAML in Kubernetes
Test quota enforcement with a resource hog
Your quotas are in place. Now you will prove they work by deploying a pod that requests more CPU than the namespace allows.
- Create a file called resource-hog.yaml and paste the following:
apiVersion: apps/v1
kind: Deployment
metadata:
name: resource-hog
namespace: team-alpha
spec:
replicas: 1
selector:
matchLabels:
app: resource-hog
template:
metadata:
labels:
app: resource-hog
spec:
containers:
- name: hog
image: nginx:1.27
resources:
requests:
cpu: "3"
memory: 128Mi
limits:
cpu: "3"
memory: 512Mi
Why 3 CPUs?
The team-alpha quota caps requests.cpu at 2 total. This pod asks for 3 CPUs all by itself, which exceeds the budget. Kubernetes should reject it at admission time.
- Apply the resource-hog deployment by running:
kubectl apply -f resource-hog.yaml
The deployment is created, but the underlying pod will not be scheduled. Before you check the events, predict what you think happened. Did the pod start, or did Kubernetes block it?
- Check the events in team-alpha by running:
kubectl get events -n team-alpha
You will see a Warning event with reason FailedCreate and a message containing forbidden: exceeded quota. The API server rejected the pod before it was ever scheduled because 3 CPUs exceeds the 2 CPU budget.
What should I see?
The events output shows a table with columns LAST SEEN, TYPE, REASON, OBJECT, and MESSAGE. Look for a row where TYPE is Warning, REASON is FailedCreate, and the MESSAGE mentions exceeded quota: team-alpha-quota with details about requested vs limited CPU.
- Clean up the failed deployment by running:
kubectl delete -f resource-hog.yaml
You should see deployment.apps "resource-hog" deleted confirming the cleanup.
Not seeing a FailedCreate event?
- Events expire after about an hour. If you waited a long time between applying and checking, reapply resource-hog.yaml and check events immediately.
- Confirm the quota is actually applied by running kubectl get resourcequota -n team-alpha. If no quota is listed, reapply resource-quotas.yaml.
Help me understand why my ResourceQuota is not blocking the pod
✔️ Awesome, I've got everything!
Great. Make sure you have saved both resource-quotas.yaml and limit-ranges.yaml, and that you have deleted the resource-hog deployment.
ⓧ I'd like to double check the full code
Scroll up and confirm you have these files in order:
- resource-quotas.yaml with both team-alpha-quota and team-beta-quota (25 lines total).
- limit-ranges.yaml with team-alpha-limits on top, --- separator, then team-beta-limits below (41 lines total).
- resource-hog.yaml exists on disk but the deployment has been deleted from the cluster with kubectl delete -f resource-hog.yaml.
Your cluster now enforces resource budgets per team. No single team can starve the other by consuming all available CPU or memory. Next up, you will close the final gap: network traffic between namespaces.
Isolate Network Traffic with Policies
Your cluster now controls who can act (RBAC) and how much they can consume (ResourceQuotas). But pods in team-alpha can still send network traffic directly to pods in team-beta.
NetworkPolicies close this final gap by controlling which pods can communicate at the network layer. In this step, you will apply deny-all policies to block all traffic by default, then selectively allow intra-namespace communication while keeping cross-namespace traffic blocked.
In this step, get ready to:
- Apply default-deny NetworkPolicies that block all ingress and egress traffic in both namespaces.
- Allow same-namespace pod communication and DNS resolution.
- Test that cross-namespace network traffic is blocked while intra-namespace traffic succeeds.
Prove the network gap exists
Even with RBAC locking down API access, the cluster network is still wide open. Pods in one namespace can freely reach pods in any other namespace.
Before you fix this, what do you think will happen if a pod in team-alpha tries to reach team-beta's nginx service right now?
- Test the current network access by running a temporary curl pod in team-alpha that targets team-beta's service:
kubectl run curl-test --image=curlimages/curl --rm -it --restart=Never -n team-alpha -- curl --max-time 5 http://nginx-service.team-beta.svc.cluster.local
You see the full nginx welcome page HTML returned. This proves that any pod in team-alpha can reach team-beta's services with no restrictions.
What does this command do?
- kubectl run curl-test creates a temporary pod named curl-test.
- --rm automatically deletes the pod after the command finishes.
- -it attaches your terminal to the pod's output so you can see the result.
- --restart=Never ensures the pod runs once and does not restart.
- The part after -- is the command the pod runs: a curl to team-beta's nginx service using the in-cluster DNS name.
Apply a default-deny NetworkPolicy to each namespace
The fix starts with a deny-all policy. This blocks all ingress and egress traffic for every pod in the namespace, creating a clean baseline where nothing can communicate unless explicitly allowed.
- Create a file called network-policy-deny-all.yaml in your project folder with this content:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: team-alpha
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: team-beta
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
What does this policy do?
- podSelector: {} selects every pod in the namespace.
- policyTypes: [Ingress, Egress] means both incoming and outgoing traffic are governed by this policy.
- Because there are no ingress or egress rules listed, all traffic is denied. NetworkPolicies are additive. Only traffic explicitly allowed by at least one policy is permitted.
- Apply the deny-all policies by running:
kubectl apply -f network-policy-deny-all.yaml
- Verify the policies were created in both namespaces by running:
kubectl get networkpolicies -n team-alpha
You should see default-deny-all listed. The Calico CNI you installed earlier is now enforcing this policy at the network level.
Not seeing the policy?
- Check that your YAML has the correct namespace values (team-alpha and team-beta).
- Make sure the apiVersion is networking.k8s.io/v1 with no typos.
- Help me troubleshoot my NetworkPolicy not appearing.
✔️ Awesome, I've got everything!
Great. Double check you have saved network-policy-deny-all.yaml.
ⓧ I'd like to double check the full code
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: team-alpha
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: team-beta
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
Allow intra-namespace and DNS traffic
The deny-all policy blocks everything, including traffic between pods in the same namespace and DNS lookups. You need a second policy that re-opens just enough communication for each team's pods to work together.
- Create a file called network-policy-allow-same-namespace.yaml and add the team-alpha policy:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-namespace
namespace: team-alpha
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector: {}
egress:
- to:
- podSelector: {}
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
What does this policy allow?
- ingress from podSelector: {} allows incoming traffic from any pod in the same namespace (empty selector matches all pods within the policy's namespace).
- egress to podSelector: {} allows outgoing traffic to pods in the same namespace.
- The second egress rule allows UDP port 53 traffic to kube-system. This is DNS resolution. Without it, pods cannot look up service names like nginx-service.team-alpha.svc.cluster.local.
The label kubernetes.io/metadata.name is automatically set by Kubernetes on every namespace, matching its name. This is a safe way to target kube-system specifically.
- Add the team-beta policy below the team-alpha policy in the same file, separated by ---:
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-namespace
namespace: team-beta
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector: {}
egress:
- to:
- podSelector: {}
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
Why is this identical to team-alpha's policy?
Both teams need the same communication pattern: talk to your own namespace, resolve DNS, but reach nothing else. The only difference is the namespace field targeting each team's namespace.
- Apply the allow policies by running:
kubectl apply -f network-policy-allow-same-namespace.yaml
- Verify both policies are now active in team-alpha by running:
kubectl get networkpolicies -n team-alpha
You should see both default-deny-all and allow-same-namespace listed.
Only seeing one policy?
- Make sure the --- separator is on its own line between the two YAML documents.
- Check that you saved the file before running kubectl apply.
- Help me debug my allow-same-namespace NetworkPolicy.
✔️ Awesome, I've got everything!
Great. Double check you have saved network-policy-allow-same-namespace.yaml.
ⓧ I'd like to double check the full code
Your network-policy-allow-same-namespace.yaml should contain two YAML documents in this order:
- The team-alpha allow-same-namespace NetworkPolicy (the first chunk you added above).
- A --- separator.
- The team-beta allow-same-namespace NetworkPolicy (the second chunk you added above).
Scroll up and confirm both chunks are present in order.
Test cross-namespace isolation
Your policies are in place. Time to prove they work. You will run two tests: one that should fail (cross-namespace) and one that should succeed (same-namespace).
Before you run the next command, predict what will happen. Will a pod in team-alpha be able to reach team-beta's nginx service?
- Test cross-namespace traffic by running a curl from team-alpha to team-beta's service:
kubectl run curl-test --image=curlimages/curl --rm -it --restart=Never -n team-alpha -- curl --max-time 5 http://nginx-service.team-beta.svc.cluster.local
The connection times out after 5 seconds and returns an error. No HTML response this time. The deny-all policy blocks all egress to pods outside team-alpha, and no policy allows traffic to team-beta.
Why does it time out instead of returning an error immediately?
NetworkPolicies drop packets silently rather than rejecting them. The curl command keeps waiting for a response that never arrives until the --max-time 5 timeout expires.
- Now test intra-namespace traffic by curling team-alpha's own service from within team-alpha:
kubectl run curl-test --image=curlimages/curl --rm -it --restart=Never -n team-alpha -- curl --max-time 5 http://nginx-service.team-alpha.svc.cluster.local
You see the nginx welcome page HTML. Pods within team-alpha can still communicate because the allow-same-namespace policy permits it.
What have you proved?
- Cross-namespace traffic is blocked: team-alpha cannot reach team-beta's services.
- Same-namespace traffic is allowed: team-alpha pods can still reach their own services.
- DNS still works: the curl command resolved the service hostname successfully (it timed out waiting for a response, not failing on DNS lookup).
Cross-namespace curl is NOT timing out?
- Make sure the Calico CNI is running. Check with kubectl get pods -n kube-system -l k8s-app=calico-node and verify the pod shows Running.
- Confirm the deny-all policy exists in team-beta: kubectl get networkpolicies -n team-beta. Without a CNI that supports NetworkPolicies, the policies have no effect.
- Help me figure out why my NetworkPolicy is not blocking traffic.
Your multi-tenant platform is fully secured. You have layered access control (RBAC), resource limits (quotas), and network isolation (NetworkPolicies) to keep teams completely separated. Next up, the Secret Mission challenges you to add shared infrastructure that all teams can reach without breaking the isolation between them.
Secret mission
Add a Shared Services Namespace
Real platforms need shared infrastructure that all teams can reach. Can you create a monitoring namespace that both teams access, while keeping cross-team traffic blocked?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- A minikube Kubernetes cluster with Calico CNI (uses ~4GB RAM while running).
- YAML configuration files (namespaces.yaml, deployment-alpha.yaml, deployment-beta.yaml, rbac.yaml, resource-quotas.yaml, limit-ranges.yaml, network-policy-deny-all.yaml, network-policy-allow-same-namespace.yaml, and Secret Mission files).
- Docker Desktop, minikube, and kubectl installed on your machine.
Keep everything running
No action needed. Choose this if you are still actively experimenting with your multi-tenant cluster or want to add more isolation layers.
- Your minikube cluster stays running in the background and uses approximately 4GB of RAM.
- Docker Desktop continues running as the container runtime for minikube.
- All your YAML files, namespaces, deployments, RBAC rules, quotas, and network policies remain intact and ready to use.
Pause - I'll come back to this later
Stop the minikube cluster to free up RAM without losing your configuration. You can restart it later and pick up where you left off.
- Stop the minikube cluster by running:
minikube stop
- This frees the ~4GB of RAM the cluster was using. All your namespaces, deployments, and policies are preserved on disk.
- When you are ready to resume, start the cluster again by running:
minikube start
- Your YAML files remain on your machine for reference or reapplication.
Delete - I don't want to use this again
Remove all project resources completely and free up all disk space and memory.
- Delete the minikube cluster and all its contents by running:
minikube delete
This removes the cluster VM, all namespaces, deployments, RBAC rules, quotas, network policies, and secrets that were running inside it.
- Delete your YAML configuration files. On macOS or Linux, run:
rm namespaces.yaml deployment-alpha.yaml deployment-beta.yaml rbac.yaml resource-quotas.yaml limit-ranges.yaml network-policy-deny-all.yaml network-policy-allow-same-namespace.yaml resource-hog.yaml shared-services-namespace.yaml shared-services-deployment.yaml shared-services-network-policy.yaml network-policy-allow-shared-egress.yaml
- On Windows, run:
del namespaces.yaml, deployment-alpha.yaml, deployment-beta.yaml, rbac.yaml, resource-quotas.yaml, limit-ranges.yaml, network-policy-deny-all.yaml, network-policy-allow-same-namespace.yaml, resource-hog.yaml, shared-services-namespace.yaml, shared-services-deployment.yaml, shared-services-network-policy.yaml, network-policy-allow-shared-egress.yaml
- To uninstall minikube and kubectl on macOS, run:
brew uninstall minikube
brew uninstall kubectl
- To uninstall minikube and kubectl on Windows, run:
winget uninstall Kubernetes.minikube
winget uninstall --id Kubernetes.kubectl
- To uninstall Docker Desktop on macOS, open Finder, navigate to Applications, and drag Docker to the Trash.
- To uninstall Docker Desktop on Windows, open Settings, select Apps, then Installed apps. Find Docker Desktop and click Uninstall.
Nice Work!
Nice Work!
You just built a production-grade multi-tenant Kubernetes platform from scratch. Two teams are now fully isolated from each other by access control, resource limits, and network boundaries, all running on your local machine.
You learned how to:
- Implemented RBAC with a ClusterRole and per-namespace RoleBindings that enforce least-privilege access, proving with impersonation commands that one team cannot touch another team's secrets.
- Applied ResourceQuotas and LimitRanges that cap resource consumption and reject over-budget deployments with a visible quota-exceeded error.
- Deployed default-deny NetworkPolicies with Calico CNI that block cross-namespace pod traffic while preserving intra-namespace communication and DNS resolution.
- Secret Mission: Built a shared-services namespace accessible to all teams while maintaining cross-team network isolation using namespaceSelector rules.
Ready to quiz yourself?