Harden a Kubernetes Cluster
Lock down a local Kubernetes cluster with RBAC, Pod Security, and Network Policies.
Introduction
30 Second Summary
Think about the last time you used a keycard to enter a building. Different people get access to different floors, certain doors lock automatically after hours, and security cameras watch for anyone wandering where they shouldn't be. Kubernetes clusters need the same layered approach to security.
In this project, you will harden a local Kubernetes cluster with three security layers: controlling who can access resources, controlling how pods run, and controlling which pods can communicate with each other.
What You'll Build
A fully hardened local Kubernetes cluster running a multi-namespace microservices scenario, secured with RBAC, Pod Security Standards, and Network Policies.
By the end of this project, you'll have:
- RBAC Roles and RoleBindings that enforce least-privilege access for a developer persona and a CI/CD service account.
- Pod Security Standards enforced at the namespace level, automatically rejecting insecure pods before they can run.
- Network Policies that segment traffic between services and block unauthorized communication.
- Secret Mission: Add default-deny egress policies with fine-grained rules that allow only DNS and specific service endpoints.
Are there any prerequisites?
Basic familiarity with Kubernetes concepts like pods, deployments, and namespaces is helpful. No prior security experience is required.
Before We Start
Before We Start...
Install minikube and kubectl
Before you can secure a Kubernetes cluster, you need a cluster to work with.
In this step, you will install minikube (a tool that runs a local Kubernetes cluster on your machine) and kubectl (the command-line tool for managing Kubernetes). Then you will start a cluster with the Calico networking plugin, which is required for Network Policy enforcement later in the project.
In this step, get ready to:
- Install minikube and kubectl on Windows.
- Start a minikube cluster with Docker and Calico CNI.
- Create three namespaces for the project.
Install minikube and kubectl
You need two tools to work with a local Kubernetes cluster. minikube creates the cluster itself, and kubectl sends commands to it.
- Press the Windows key to open your search bar.
- Type PowerShell and select Run as administrator.
Why run as administrator?
Package managers like winget need elevated permissions to install software system-wide. Running PowerShell as administrator ensures the install commands succeed without permission errors.
- Install minikube by running this command:
winget install Kubernetes.minikube
- Install kubectl by running this command:
winget install -e --id Kubernetes.kubectl
What do these commands do?
Both commands use winget, the Windows Package Manager, to download and install each tool.
The -e --id flags on the kubectl command ensure an exact match on the package identifier, so you get the right tool.
- Close and reopen PowerShell as administrator so your terminal picks up the new installations.
- Verify minikube is installed by running:
minikube version
- Verify kubectl is installed by running:
kubectl version --client
✔️ I see version numbers for both tools
Both tools are installed and ready. You should see a minikube version and a kubectl client version in the output.
ⓧ Command not found for minikube or kubectl
The tools installed but your terminal does not know where to find them yet.
- Close all PowerShell windows completely.
- Reopen PowerShell as administrator.
- Run minikube version and kubectl version --client again.
If the commands still fail, restart your computer to refresh system paths, then try again.
ⓧ winget is not recognized
Your Windows installation may not include winget by default. You can install the tools using Chocolatey instead.
- Install minikube with Chocolatey by running:
choco install minikube
- Install kubectl with Chocolatey by running:
choco install kubernetes-cli
- Close and reopen PowerShell, then verify both tools with minikube version and kubectl version --client.
Start your minikube cluster with Calico CNI
A fresh Kubernetes cluster does not enforce Network Policies by default. The built-in networking ignores them entirely.
To make Network Policies work, you need a CNI (Container Network Interface) plugin that supports them. You will use Calico, one of the most widely adopted options.
- Make sure Docker Desktop is running. You can confirm by checking for the Docker whale icon in your system tray.
- Start your minikube cluster with the Docker driver and Calico CNI by running this command:
minikube start --driver=docker --cni=calico
What does this command do?
The --driver=docker flag tells minikube to run the cluster inside a Docker container instead of a full virtual machine.
The --cni=calico flag installs the Calico networking plugin, which watches for NetworkPolicy resources and enforces traffic rules between pods.
Why Calico instead of the default CNI?
The default minikube networking does not enforce NetworkPolicy resources. If you deploy a NetworkPolicy without Calico, Kubernetes accepts the resource but nothing actually blocks traffic. Calico actively watches for NetworkPolicy objects and programs firewall rules to enforce them.
- Wait 1-2 minutes for the command to complete. You will see a message confirming that kubectl is configured to use the minikube cluster.
- Verify the cluster is running by checking its status:
minikube status
- You should see host: Running, kubelet: Running, and apiserver: Running in the output.
- Verify kubectl can connect to the cluster by running:
kubectl get nodes
- You should see one node named minikube with a Ready status.
Cluster not starting?
If you see an error about Docker not being available, make sure Docker Desktop is fully started. Look for the whale icon in your system tray and confirm it says "Docker Desktop is running".
If minikube reports insufficient resources, check that you have at least 2 CPUs and 2GB of free memory available.
Help me troubleshoot why minikube won't start with the Docker driver on Windows.
Create your project namespaces
Namespaces in Kubernetes let you divide a cluster into isolated sections. Each namespace can have its own security policies, which is exactly what you need for this project.
- Create three namespaces for the project by running these commands:
kubectl create namespace dev-team
kubectl create namespace production
kubectl create namespace monitoring
What are these namespaces for?
- dev-team is where developer workloads will run. You will apply RBAC rules here to restrict what a developer can do.
- production is for hardened workloads. You will enforce strict Pod Security Standards here.
- monitoring is for observability tools. You will use it later to test that Network Policies block unauthorized access.
- Verify all three namespaces exist by running:
kubectl get namespaces
- You should see dev-team, production, and monitoring listed alongside the default Kubernetes system namespaces.
Namespace creation failed?
If you see a "connection refused" error, your cluster may not be fully ready yet. Run minikube status and confirm all components show Running. Wait a moment and try the namespace commands again.
Help me figure out why kubectl cannot connect to my minikube cluster.
Your cluster is up and running with three namespaces ready for security controls. Next up, you will deploy sample workloads into these namespaces to see the default "everything is open" state before locking things down.
Deploy Sample Workloads
Your cluster is running with three empty namespaces waiting for workloads. Now it's time to deploy services into those namespaces so you have something to secure in the following steps.
Right now, your cluster is completely open. Any pod can talk to any other pod across any namespace, and there are no restrictions on what containers can do. You'll deploy a frontend and backend service to see this default behavior firsthand.
In this step, get ready to:
- Deploy a frontend service in the dev-team namespace.
- Deploy a backend service in the production namespace.
- Verify that pods can freely communicate across namespaces.
Deploy the frontend and backend services
You'll create two YAML manifests that define your workloads. Each manifest contains a Deployment (which manages the pod) and a ClusterIP Service (which gives the pod a stable internal address).
- Create a project folder on your Desktop to store your YAML files by running these commands:
cd ~/Desktop
mkdir k8s-security
cd k8s-security
What does this do?
This creates a k8s-security folder on your Desktop where you'll store all the Kubernetes manifests for this project.
- Confirm the folder exists by running:
ls
- You should see the k8s-security folder listed in the output.
- Create the frontend deployment file by running:
New-Item frontend-deployment.yaml
- Open frontend-deployment.yaml in a text editor and paste the following code:
# Deployment: manages the frontend pod in the dev-team namespace
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: dev-team
labels:
app: frontend
role: frontend
spec:
replicas: 1
selector:
matchLabels:
app: frontend
role: frontend
template:
metadata:
labels:
app: frontend
role: frontend
spec:
containers:
- name: frontend
image: nginx:1.27
ports:
- containerPort: 80
---
# Service: exposes the frontend pod internally on port 80
apiVersion: v1
kind: Service
metadata:
name: frontend-svc
namespace: dev-team
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 80
What does this code do?
This manifest defines two resources separated by ---:
- The Deployment creates one pod running the nginx:1.27 image. It includes labels app: frontend and role: frontend on both the deployment and the pod template.
- The Service named frontend-svc creates a stable internal DNS name that routes traffic to the pod on port 80.
The role: frontend label is important. You'll use it later when creating Network Policies to control which pods can communicate.
- Save frontend-deployment.yaml.
- Apply the frontend manifest to your cluster by running:
kubectl apply -f frontend-deployment.yaml
- You should see output confirming both resources were created:
deployment.apps/frontend created
service/frontend-svc created
Seeing an error about the file not found?
Make sure you are in the k8s-security folder on your Desktop. Run pwd to check your current directory.
Help me troubleshoot this
- Now create the backend deployment file by running:
New-Item backend-deployment.yaml
- Open backend-deployment.yaml in a text editor and paste the following code:
# Deployment: manages the backend pod in the production namespace
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: production
labels:
app: backend
role: backend
spec:
replicas: 1
selector:
matchLabels:
app: backend
role: backend
template:
metadata:
labels:
app: backend
role: backend
spec:
containers:
- name: backend
image: nginx:1.27
ports:
- containerPort: 80
securityContext:
runAsNonRoot: true
runAsUser: 101
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
securityContext:
seccompProfile:
type: RuntimeDefault
---
# Service: exposes the backend pod internally on port 80
apiVersion: v1
kind: Service
metadata:
name: backend-svc
namespace: production
spec:
selector:
app: backend
ports:
- port: 80
targetPort: 80
What does this code do?
This manifest is similar to the frontend, but the backend pod includes a securityContext section that hardens how the container runs:
- runAsNonRoot: true prevents the container from running as the root user.
- runAsUser: 101 runs the process as the nginx user (user ID 101 in the image).
- allowPrivilegeEscalation: false blocks processes from gaining more permissions than their parent.
- readOnlyRootFilesystem: true prevents writing to the container's filesystem.
- capabilities: drop: ALL removes all Linux capabilities from the container.
These settings are already included because the production namespace will be hardened with Pod Security Standards in a later step. Pre-configuring the backend ensures it won't be rejected when enforcement is turned on.
- Save backend-deployment.yaml.
- Apply the backend manifest to your cluster by running:
kubectl apply -f backend-deployment.yaml
- You should see output confirming both resources were created:
deployment.apps/backend created
service/backend-svc created
Seeing a creation error?
Make sure the production namespace exists. Run kubectl get namespaces to confirm. If it's missing, create it with kubectl create namespace production.
Help me troubleshoot this
✔️ Awesome, I've got everything!
Great. Double check you've saved both files.
ⓧ I'd like to double check the full code
Here's the full frontend-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: dev-team
labels:
app: frontend
role: frontend
spec:
replicas: 1
selector:
matchLabels:
app: frontend
role: frontend
template:
metadata:
labels:
app: frontend
role: frontend
spec:
containers:
- name: frontend
image: nginx:1.27
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: frontend-svc
namespace: dev-team
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 80
Here's the full backend-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: production
labels:
app: backend
role: backend
spec:
replicas: 1
selector:
matchLabels:
app: backend
role: backend
template:
metadata:
labels:
app: backend
role: backend
spec:
containers:
- name: backend
image: nginx:1.27
ports:
- containerPort: 80
securityContext:
runAsNonRoot: true
runAsUser: 101
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
securityContext:
seccompProfile:
type: RuntimeDefault
---
apiVersion: v1
kind: Service
metadata:
name: backend-svc
namespace: production
spec:
selector:
app: backend
ports:
- port: 80
targetPort: 80
Verify pods are running and can communicate
Both deployments are applied, but you need to confirm the pods are actually running. You also want to prove that any pod can reach any service across namespaces by default.
- Check that the frontend pod is running by running:
kubectl get pods -n dev-team
- You should see the frontend pod with a Running status. It may take 30-60 seconds to pull the image on the first run.
- Check that the backend pod is running by running:
kubectl get pods -n production
- You should see the backend pod with a Running status.
Pod stuck in ContainerCreating or CrashLoopBackOff?
If the pod shows ContainerCreating, wait another minute for the image to download. If it shows CrashLoopBackOff, check the pod logs with kubectl logs <pod-name> -n <namespace> to see what went wrong.
Help me debug my pod status
Now test that the frontend can reach the backend across namespaces. This proves the cluster's default open networking state.
- Run a temporary pod in the dev-team namespace that curls the backend service:
kubectl run curl-test --rm -it --image=curlimages/curl --restart=Never -n dev-team -- curl http://backend-svc.production.svc.cluster.local
What does this command do?
- kubectl run curl-test creates a temporary pod named curl-test.
- --rm -it automatically deletes the pod after it finishes and shows the output interactively.
- --image=curlimages/curl uses a lightweight image that has curl pre-installed.
- backend-svc.production.svc.cluster.local is the full DNS name of the backend service. Kubernetes automatically creates DNS entries for every Service.
- You should see the default nginx welcome page HTML in your terminal output. This confirms that a pod in dev-team can freely reach a service in production.
Seeing a connection timeout or DNS error?
The Calico CNI takes 1-2 minutes to fully initialize after cluster creation. Run kubectl get pods -n kube-system and confirm all calico pods show Running status before retrying.
Help me troubleshoot my network connectivity
Inspect the role-based labels
The deployment manifests you created already include role labels on each pod. These labels will be used by Network Policies in a later step to control which pods can communicate.
- View the labels on your frontend pod by running:
kubectl get pods -n dev-team --show-labels
- You should see labels including role=frontend in the output.
- View the labels on your backend pod by running:
kubectl get pods -n production --show-labels
- You should see labels including role=backend in the output.
Why do labels matter for security?
Network Policies use label selectors to target specific pods. By labeling your frontend pod with role: frontend and your backend pod with role: backend, you create handles that policies can grab onto.
In Step 5, you'll write a policy that says "only pods labeled role: frontend can reach pods labeled role: backend." Without labels, there would be no way to target specific pods.
Your workloads are deployed and communicating freely. Next up, you'll implement RBAC to control who can access these resources.
Implement RBAC for Least-Privilege Access
Your cluster is running with both services communicating freely. Right now, anyone with kubectl access can read, modify, or delete any resource in any namespace.
In production, different team members need different levels of access. A developer might need to view pods for debugging, but should never be able to delete production workloads. RBAC (Role-Based Access Control) lets you define exactly who can do what, in which namespace.
In this step, get ready to:
- Create a read-only Role and RoleBinding for a developer persona.
- Test RBAC rules by simulating user access with kubectl.
- Create a restricted ServiceAccount for CI/CD deployments.
Create a developer read-only Role and RoleBinding
A Role defines a set of permissions (which resources can be accessed and which actions are allowed). A RoleBinding attaches that Role to a specific user or group, scoped to a single namespace.
- Create a file called rbac-developer.yaml in your project folder. This file defines a read-only Role and binds it to a user called dev-alice.
- Paste the following YAML into rbac-developer.yaml:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: developer-readonly
namespace: dev-team
rules:
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: dev-alice-readonly
namespace: dev-team
subjects:
- kind: User
name: dev-alice
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: developer-readonly
apiGroup: rbac.authorization.k8s.io
What does this YAML do?
The first document creates a Role called developer-readonly in the dev-team namespace. It grants only get, list, and watch permissions on pods, services, and deployments.
The second document creates a RoleBinding that attaches this Role to a user named dev-alice. Alice can now view resources in dev-team but cannot modify or delete anything.
What is least-privilege?
The least-privilege principle means giving users only the minimum permissions they need to do their job. A developer who debugs pods needs read access but never needs delete access to production workloads.
- Save rbac-developer.yaml.
- Apply the file to your cluster by running:
kubectl apply -f rbac-developer.yaml
- You should see two lines of output confirming the Role and RoleBinding were created:
role.rbac.authorization.k8s.io/developer-readonly created
rolebinding.rbac.authorization.k8s.io/dev-alice-readonly created
Seeing a "not found" or namespace error?
Make sure the namespace: dev-team field in your YAML matches the namespace you created in Step 1. Typos in the namespace name cause the apply to fail.
help me debug a kubectl apply error for my RBAC yaml file
Test RBAC rules with kubectl auth can-i
Creating RBAC rules is only half the job. You need to verify they actually work by simulating requests as the restricted user.
- Verify that dev-alice can list pods in the dev-team namespace by running:
kubectl auth can-i list pods -n dev-team --as=dev-alice
- You should see yes in the output. This confirms Alice has read access.
- Now verify that Alice cannot delete pods in the same namespace by running:
kubectl auth can-i delete pods -n dev-team --as=dev-alice
- You should see no. Alice can view pods but not destroy them.
- Finally, verify that Alice has no access to the production namespace by running:
kubectl auth can-i list pods -n production --as=dev-alice
- You should see no. The RoleBinding only grants access in dev-team, so Alice is invisible to the production namespace.
What is kubectl auth can-i?
The kubectl auth can-i command checks whether a specific action is allowed for a user. The --as flag impersonates another user so you can test their permissions without needing separate credentials.
Getting "yes" for everything?
If all three commands return yes, you may be running as a cluster-admin user that has full permissions by default. Make sure you include the --as=dev-alice flag in each command. Without it, you are checking your own admin permissions.
help me troubleshoot why kubectl auth can-i is returning yes for all permissions
Create a CI/CD ServiceAccount with deploy-only permissions
Automated systems like CI/CD pipelines also need restricted access. A pipeline that deploys code should be able to create and update deployments, but nothing else.
- Create a file called rbac-cicd.yaml in your project folder. This defines a ServiceAccount, a Role, and a RoleBinding for a CI/CD deployer.
- Paste the following YAML into rbac-cicd.yaml:
apiVersion: v1
kind: ServiceAccount
metadata:
name: cicd-deployer
namespace: dev-team
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: cicd-deploy-role
namespace: dev-team
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: cicd-deployer-binding
namespace: dev-team
subjects:
- kind: ServiceAccount
name: cicd-deployer
namespace: dev-team
roleRef:
kind: Role
name: cicd-deploy-role
apiGroup: rbac.authorization.k8s.io
What does this YAML do?
The ServiceAccount cicd-deployer is a machine identity for your CI/CD pipeline. Unlike human users, ServiceAccounts are managed directly inside Kubernetes.
The Role cicd-deploy-role grants get, list, create, update, and patch on deployments only. It cannot touch pods, services, secrets, or anything else.
Why a ServiceAccount instead of a User?
Kubernetes does not manage human users internally. Human identities like dev-alice are handled by external identity providers. ServiceAccounts, on the other hand, are native Kubernetes resources that represent machine identities for automated processes.
- Save rbac-cicd.yaml.
- Apply the file to your cluster by running:
kubectl apply -f rbac-cicd.yaml
- You should see three lines confirming the ServiceAccount, Role, and RoleBinding were created.
- Verify the CI/CD deployer can create deployments by running:
kubectl auth can-i create deployments -n dev-team --as=system:serviceaccount:dev-team:cicd-deployer
- You should see yes. The deployer can create deployments as intended.
- Verify the CI/CD deployer cannot delete pods by running:
kubectl auth can-i delete pods -n dev-team --as=system:serviceaccount:dev-team:cicd-deployer
- You should see no. The deployer is locked to deployment operations only.
Seeing permission errors with the ServiceAccount impersonation?
The --as flag for ServiceAccounts requires the full path format: system:serviceaccount:<namespace>:<name>. Make sure you use system:serviceaccount:dev-team:cicd-deployer exactly.
help me fix my kubectl auth can-i command for a ServiceAccount
✔️ Awesome, I've got everything!
Great. Make sure both YAML files are saved and the RBAC resources are applied to your cluster.
ⓧ I'd like to double check the full code
Here is the full rbac-developer.yaml:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: developer-readonly
namespace: dev-team
rules:
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: dev-alice-readonly
namespace: dev-team
subjects:
- kind: User
name: dev-alice
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: developer-readonly
apiGroup: rbac.authorization.k8s.io
Here is the full rbac-cicd.yaml:
apiVersion: v1
kind: ServiceAccount
metadata:
name: cicd-deployer
namespace: dev-team
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: cicd-deploy-role
namespace: dev-team
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: cicd-deployer-binding
namespace: dev-team
subjects:
- kind: ServiceAccount
name: cicd-deployer
namespace: dev-team
roleRef:
kind: Role
name: cicd-deploy-role
apiGroup: rbac.authorization.k8s.io
Your cluster now has two layers of access control in place. Developers get read-only access, and CI/CD pipelines get deploy-only access. Next up, you will enforce Pod Security Standards to control how pods themselves are allowed to run.
Enforce Pod Security Standards
Your RBAC rules now control who can access resources in the cluster. The next layer controls how pods themselves are allowed to run.
Without guardrails, any deployment can request root access, mount host paths, or escalate privileges. Pod Security Standards solve this by defining security profiles that Kubernetes enforces automatically at the namespace level through Pod Security Admission.
In this step, get ready to:
- Label the production namespace with restricted Pod Security Admission enforcement.
- Test that insecure pods are automatically rejected.
- Deploy a security-compliant pod that satisfies the restricted standard.
Label the production namespace with restricted enforcement
Pod Security Admission uses namespace labels to decide which security level to apply. There are three modes: enforce (reject violating pods), warn (allow but show a warning), and audit (allow but log the violation).
- Apply all three modes at the restricted level to the production namespace by running this command:
kubectl label --overwrite ns production \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
What does this command do?
This applies three labels to the production namespace. The enforce=restricted label tells Kubernetes to reject any pod that violates the restricted security standard.
The warn and audit labels add extra visibility. Warnings appear in your terminal output and audit annotations are logged for compliance tracking.
- Verify the labels were applied by running:
kubectl get ns production --show-labels
You should see the three pod-security.kubernetes.io labels listed in the output alongside the namespace name.
What is the restricted standard?
The restricted level is the strictest Pod Security Standard. It requires pods to run as non-root, disables privilege escalation, drops all Linux capabilities, enforces a read-only root filesystem, and requires an explicit seccomp profile.
Any pod that fails even one of these checks will be rejected by the admission controller before it ever starts running.
Seeing a warning about existing pods?
When you add an enforce label, Kubernetes checks all existing pods in the namespace against the new policy. If your backend pod already has the correct security context (as deployed in Step 2), you should not see a violation warning. If you do see one, your backend deployment may not have applied the security settings correctly.
help me understand why my existing pod violates the restricted standard
Test the enforcement with a privileged pod
The best way to prove enforcement works is to deliberately deploy a pod that violates the policy and observe the rejection.
- Create a file called privileged-pod-test.yaml in your project folder with the following content:
apiVersion: v1
kind: Pod
metadata:
name: privileged-test
namespace: production
spec:
containers:
- name: privileged
image: nginx:1.27
securityContext:
privileged: true
What does this pod do?
This pod sets securityContext.privileged: true on its container. A privileged container has unrestricted access to the host system, bypassing nearly all security boundaries. The restricted standard explicitly forbids this.
- Attempt to deploy the privileged pod by running:
kubectl apply -f privileged-pod-test.yaml
The command should fail with an error stating the pod is forbidden because it violates the restricted Pod Security Standard. You will see a message mentioning the privileged field as the cause.
Pod was created instead of rejected?
If the pod was accepted, the namespace labels may not have applied correctly. Run kubectl get ns production --show-labels and confirm you see pod-security.kubernetes.io/enforce=restricted in the output.
If the label is missing, re-run the kubectl label command from the previous substep.
help me figure out why my privileged pod was not rejected by Pod Security Admission
Deploy a compliant pod
Now that you have confirmed insecure pods are blocked, deploy a pod that satisfies every requirement of the restricted standard.
- Create a file called compliant-pod.yaml in your project folder with the following content:
apiVersion: v1
kind: Pod
metadata:
name: compliant-app
namespace: production
labels:
app: compliant
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginx:1.27
securityContext:
runAsNonRoot: true
runAsUser: 101
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
ports:
- containerPort: 80
What makes this pod compliant?
This pod satisfies every requirement of the restricted Pod Security Standard:
- runAsNonRoot: true and runAsUser: 101 ensure the container runs as the nginx user, not root.
- allowPrivilegeEscalation: false prevents the process from gaining more privileges than its parent.
- readOnlyRootFilesystem: true prevents writes to the container filesystem.
- capabilities.drop: [ALL] removes all Linux capabilities from the container.
- seccompProfile.type: RuntimeDefault at the pod level applies a system call filter.
- Deploy the compliant pod by running:
kubectl apply -f compliant-pod.yaml
- Verify the pod is running by running:
kubectl get pods -n production
You should see compliant-app with a status of Running. This confirms the restricted standard accepted the pod.
Compliant pod not starting?
If the pod is rejected, the error message will tell you exactly which field is non-compliant. Double check that seccompProfile is set at the pod level (under spec.securityContext), not only at the container level. The restricted standard requires it at the pod scope.
If the pod shows CrashLoopBackOff instead, that means it was accepted by the admission controller but nginx cannot write to its required temp directories because of the read-only filesystem. This is expected behaviour for a stripped-down test pod and does not affect our security verification.
help me debug why my compliant pod is not being accepted by the restricted Pod Security Standard
✔️ Awesome, I've got everything!
Great. Double check you have saved both YAML files.
ⓧ I'd like to double check the full code
Here is privileged-pod-test.yaml:
apiVersion: v1
kind: Pod
metadata:
name: privileged-test
namespace: production
spec:
containers:
- name: privileged
image: nginx:1.27
securityContext:
privileged: true
Here is compliant-pod.yaml:
apiVersion: v1
kind: Pod
metadata:
name: compliant-app
namespace: production
labels:
app: compliant
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: nginx:1.27
securityContext:
runAsNonRoot: true
runAsUser: 101
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
ports:
- containerPort: 80
Your production namespace now automatically blocks any pod that does not meet hardened security requirements. Next up, you will add Network Policies to control which pods can communicate with each other.
Apply Network Policies for Traffic Segmentation
Your cluster now controls who can access resources (RBAC) and how pods can run (Pod Security Standards). But there is still a critical gap in your defenses.
By default, every pod in a Kubernetes cluster can freely send traffic to any other pod in any namespace. Network Policies close this gap by defining exactly which pods are allowed to communicate with each other. In this step, you will lock down the production namespace so that only explicitly allowed traffic can reach your backend.
In this step, get ready to:
- Apply a default-deny ingress policy that blocks all incoming traffic to pods in production.
- Create a targeted allow policy that permits only frontend pods to reach the backend.
- Verify network isolation by confirming that unauthorized pods cannot reach the backend.
Create the default-deny ingress policy
A default-deny policy is the foundation of network segmentation. It flips the cluster from "allow all" to "deny all" for a namespace. After this, only traffic that matches an explicit allow rule can get through.
- Create a file called netpol-default-deny-ingress.yaml in your project folder with the following content:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
What does this policy do?
The empty podSelector: {} matches every pod in the production namespace. By specifying Ingress in policyTypes without providing any ingress rules, the policy blocks all incoming traffic to every pod.
Think of it as locking every door in the building. You will then hand out specific keys (allow policies) to authorized visitors.
- Apply the policy to your cluster by running:
kubectl apply -f netpol-default-deny-ingress.yaml
- You should see networkpolicy.networking.k8s.io/default-deny-ingress created in the output.
Now verify that the frontend pod in dev-team can no longer reach the backend service in production.
- Deploy a temporary test pod in the dev-team namespace with the role: frontend label by running:
kubectl run test-frontend --image=busybox:1.36 -n dev-team --labels="role=frontend" --restart=Never -- sleep 3600
Why a separate test pod?
The nginx:1.27 image used by your frontend does not include networking tools like wget or curl. The busybox image includes wget out of the box, making it ideal for connectivity tests.
The --labels="role=frontend" flag ensures this test pod matches the same selectors your Network Policies will target later.
- Wait a few seconds for the pod to start, then test connectivity to the backend by running:
kubectl exec test-frontend -n dev-team -- wget -qO- --timeout=3 backend-svc.production.svc.cluster.local
- The command should hang and then fail with a timeout error. This confirms that the default-deny policy is blocking all ingress to the production namespace.
Still getting a response instead of a timeout?
Calico can take a moment to enforce newly created policies. Wait 10-15 seconds and try the wget command again.
Verify the policy was created correctly by running kubectl get networkpolicies -n production. You should see default-deny-ingress listed.
If you still get a response, confirm your minikube cluster was started with --cni=calico. The default minikube CNI does not enforce Network Policies.
Help me figure out why my default-deny NetworkPolicy is not blocking traffic
Allow frontend-to-backend traffic
With everything locked down, you now need to create a precise rule that allows only the frontend to reach the backend. This is the least-privilege principle applied to networking.
- Create a file called netpol-allow-frontend.yaml in your project folder with the following content:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels:
role: backend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: dev-team
podSelector:
matchLabels:
role: frontend
ports:
- protocol: TCP
port: 80
What does this policy do?
- podSelector targets only pods with the role: backend label in the production namespace.
- The from block uses BOTH a namespaceSelector and a podSelector together (AND logic). Traffic is only allowed from pods that are in the dev-team namespace AND have the role: frontend label.
- The ports field restricts allowed traffic to TCP port 80 only.
- Kubernetes automatically labels every namespace with kubernetes.io/metadata.name: <name>, so you can use it as a namespace selector without adding custom labels.
- Apply the allow policy by running:
kubectl apply -f netpol-allow-frontend.yaml
- You should see networkpolicy.networking.k8s.io/allow-frontend-to-backend created in the output.
- Test connectivity from the frontend test pod again by running:
kubectl exec test-frontend -n dev-team -- wget -qO- --timeout=3 backend-svc.production.svc.cluster.local
- This time you should see HTML output from nginx (the backend's default page). This confirms the allow policy is working correctly.
Still getting a timeout?
Check that your test pod has the correct label by running kubectl get pod test-frontend -n dev-team --show-labels. You should see role=frontend in the labels column.
Verify the allow policy targets the right pods by running kubectl describe networkpolicy allow-frontend-to-backend -n production. Check that the PodSelector shows role=backend and the ingress source shows both the namespace and pod selectors.
Help me troubleshoot why my allow NetworkPolicy is not permitting frontend traffic to the backend
✔️ Awesome, I've got everything!
Great. Make sure you have saved both netpol-default-deny-ingress.yaml and netpol-allow-frontend.yaml.
ⓧ I'd like to double check the full code
Here is the complete netpol-default-deny-ingress.yaml:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
Here is the complete netpol-allow-frontend.yaml:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels:
role: backend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: dev-team
podSelector:
matchLabels:
role: frontend
ports:
- protocol: TCP
port: 80
Verify network isolation from monitoring
The final test proves that your policies truly isolate the backend. A pod in the monitoring namespace has no matching allow rule, so it should be completely blocked from reaching the backend.
- Deploy a temporary test pod in the monitoring namespace by running:
kubectl run test-monitor --image=busybox:1.36 -n monitoring --restart=Never -- sleep 3600
- Wait a few seconds for the pod to start, then attempt to reach the backend by running:
kubectl exec test-monitor -n monitoring -- wget -qO- --timeout=3 backend-svc.production.svc.cluster.local
- The command should fail with a timeout. This proves that pods in monitoring cannot reach the backend, even though the frontend can.
Why does this work?
Your allow policy specifies that ingress is only permitted from pods with role: frontend in the dev-team namespace. The test pod in monitoring does not match either condition. The default-deny policy catches everything else and blocks it.
This is defense-in-depth at the network layer. Even if an attacker compromises a monitoring pod, they cannot pivot to production services.
- Clean up both test pods by running:
kubectl delete pod test-frontend -n dev-team
kubectl delete pod test-monitor -n monitoring
- You should see both pods confirmed as deleted.
Monitoring pod got a response?
If the monitoring pod received HTML instead of timing out, verify your default-deny policy is still in place by running kubectl get networkpolicies -n production. You should see both default-deny-ingress and allow-frontend-to-backend listed.
Also check that all Calico pods are running by running kubectl get pods -n kube-system -l k8s-app=calico-node. If a Calico pod is in a crash loop, Network Policies will not be enforced.
Help me figure out why my monitoring pod can still reach the production backend despite network policies
Your cluster now has all three security layers in place. RBAC controls who can access resources, Pod Security Standards control how pods run, and Network Policies control which pods can talk to each other. Next up, you will run a comprehensive audit to verify every layer is working as intended.
Verify Your Security Layers
You have configured three independent security layers on your cluster: RBAC to control who can do what, Pod Security Standards to control how pods run, and Network Policies to control which pods can talk to each other.
Each layer works independently, but together they form a defense-in-depth strategy. In this step, you will systematically test each control to prove it rejects unauthorized access, blocks insecure pods, and isolates network traffic.
In this step, get ready to:
- Audit all RBAC bindings and confirm no wildcard permissions exist.
- Test Pod Security by attempting multiple violations in the production namespace.
- Verify Network Policy isolation with a rogue pod from a new namespace.
Run a comprehensive RBAC audit
A secure cluster should have no overly permissive bindings. Wildcard permissions (* in verbs or resources) grant unrestricted access, which violates the least-privilege principle.
- List all RoleBindings in the dev-team namespace by running this command:
kubectl get rolebindings -n dev-team
You should see two bindings: dev-alice-readonly and cicd-deployer-binding.
- List all RoleBindings in the production namespace by running:
kubectl get rolebindings -n production
You should see no custom RoleBindings here. The production namespace has no user-defined RBAC bindings, which is correct since workloads there are managed at the cluster level.
- Verify that no Roles in dev-team use wildcard permissions by inspecting their rules:
kubectl get roles -n dev-team -o yaml
What am I looking for?
Scan the output for any verbs or resources fields that contain "*". A wildcard in verbs means the role grants every possible action. A wildcard in resources means it applies to every resource type.
Your roles should list specific verbs like get, list, watch and specific resources like pods, deployments. No wildcards means your RBAC follows least-privilege.
- Confirm the default service account in dev-team has no extra permissions by running:
kubectl auth can-i --list --as=system:serviceaccount:dev-team:default -n dev-team
What does this output mean?
This command simulates what the default service account can do. You should see only basic self-referential permissions (like getting its own token). If you see broad access to pods, deployments, or secrets, it means the default account has been granted more access than it needs.
A clean output with minimal permissions confirms that only the specific accounts you created (dev-alice and cicd-deployer) have the access you intentionally granted.
Seeing unexpected permissions on the default account?
If the default service account shows broad permissions, check whether any ClusterRoleBindings reference system:serviceaccount:dev-team:default or a group that includes it. Run kubectl get clusterrolebindings -o yaml and search for the binding.
help me understand why my default service account has unexpected permissions
Test Pod Security with violation attempts
Your production namespace enforces the restricted Pod Security Standard. Any pod that violates these requirements should be automatically rejected by the Pod Security Admission controller.
- Attempt to deploy a privileged container by re-applying the existing test file:
kubectl apply -f privileged-pod-test.yaml
You should see an error message indicating the pod was forbidden because privileged: true violates the restricted policy.
- Attempt to deploy a pod with hostNetwork access by running:
@"
apiVersion: v1
kind: Pod
metadata:
name: hostnetwork-test
namespace: production
spec:
hostNetwork: true
containers:
- name: test
image: nginx:1.27
"@ | kubectl apply -f -
You should see a rejection message. The restricted standard does not allow pods to share the host's network namespace.
- Attempt to deploy a pod with hostPID access by running:
@"
apiVersion: v1
kind: Pod
metadata:
name: hostpid-test
namespace: production
spec:
hostPID: true
containers:
- name: test
image: nginx:1.27
"@ | kubectl apply -f -
This should also be rejected. Sharing the host's PID namespace would let a container see and signal all processes on the node.
Why test three different violations?
Each violation tests a different aspect of the restricted standard. Privileged containers bypass all Linux security modules. Host networking exposes the node's network stack. Host PID exposes all node processes.
Testing multiple violations confirms the admission controller is broadly enforcing the policy, not just catching one specific field.
Pod was accepted instead of rejected?
Verify the production namespace still has the correct PSA labels by running kubectl get namespace production --show-labels. You should see pod-security.kubernetes.io/enforce=restricted in the labels.
If the label is missing, re-apply it with kubectl label --overwrite ns production pod-security.kubernetes.io/enforce=restricted.
help me debug why my PSA labels aren't rejecting insecure pods
Test Network Policy isolation end-to-end
Your Network Policies allow only the frontend pod in dev-team to reach the backend. To prove this works, you will deploy a rogue pod in a completely new namespace and confirm it is blocked.
- Create a new namespace for the rogue pod by running:
kubectl create namespace rogue-ns
- Deploy a rogue pod in the new namespace by running:
kubectl run rogue-pod --image=nginx:1.27 -n rogue-ns
- Wait for the pod to be ready by running:
kubectl wait --for=condition=ready pod/rogue-pod -n rogue-ns --timeout=60s
You should see a message confirming the pod met the ready condition.
- Attempt to reach the backend service from the rogue pod by running:
kubectl exec -n rogue-ns rogue-pod -- curl -s --max-time 3 http://backend-svc.production.svc.cluster.local
The command should time out after 3 seconds with no response. This proves the Network Policy is blocking traffic from unauthorized pods.
Why does this prove isolation?
The rogue pod is in a namespace that is not dev-team, and it does not have the label role: frontend. Your allow-frontend-to-backend policy only permits ingress from pods matching both conditions.
Everything else hits the default-deny-ingress policy and gets dropped silently. The curl timeout is the proof that the deny policy is working.
Rogue pod can reach the backend?
Make sure the Calico CNI pods are fully running. Check with kubectl get pods -n kube-system and confirm all calico pods show Running status. Calico needs to be healthy to enforce Network Policies.
Also verify your default-deny-ingress policy is still applied with kubectl get networkpolicies -n production.
help me figure out why my Network Policy is not blocking traffic from an unauthorized pod
Secret mission
Default-Deny Egress with Fine-Grained Rules
Your cluster's ingress policies block unauthorized incoming traffic. But what about outgoing traffic? A compromised pod could still exfiltrate data to any external IP. Lock down egress to complete your defense-in-depth strategy.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine using minikube, so there are no ongoing cloud costs. Decide whether to keep your cluster running, pause it to come back later, or delete everything entirely.
Resources you used:
- A minikube cluster running locally (includes Docker containers used as the driver).
- Project YAML files stored in your project folder on the Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your hardened cluster.
- Your minikube cluster will persist on your machine until you explicitly stop or delete it.
- Check on your cluster anytime by running:
minikube status
Note that minikube and kubectl remain installed on your machine. They take minimal disk space and don't run in the background when the cluster is stopped.
Pause - I'll come back to this later
Shut down the cluster to free up CPU and memory while keeping your configuration intact. Your namespaces, RBAC rules, and Network Policies will all be preserved.
- Pause the cluster by running:
minikube pause
- When you're ready to resume, run:
minikube unpause
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes the minikube cluster, all Docker containers it created, and frees up disk space.
- Delete the minikube cluster by running:
minikube delete
- Delete your project folder by running:
Remove-Item -Path "$HOME\Desktop\k8s-security" -Recurse -Force
- Optionally, uninstall minikube from your machine by running:
winget uninstall --id Kubernetes.minikube
- Optionally, uninstall kubectl by running:
winget uninstall --id Kubernetes.kubectl
Nice Work!
Nice Work!
You just hardened a Kubernetes cluster using the same defense-in-depth strategy used in production environments.
Here's what you accomplished:
- Implemented RBAC Roles and RoleBindings enforcing least-privilege access for a developer persona and a CI/CD service account.
- Enforced Pod Security Standards at the namespace level, automatically rejecting insecure pods before they can run.
- Created Network Policies that segment traffic between services and block unauthorized communication across namespaces.
- Secret Mission: Implemented default-deny egress policies with fine-grained DNS and CIDR-based rules.
Ready to quiz yourself?