Kubernetes ships with a flat, default-allow network model: every pod can reach every other pod unless something intervenes. Moving to zero trust means replacing that implicit trust with label-driven microsegmentation, and NetworkPolicy objects are the API for expressing it. Standard Kubernetes only defines the policy schema — enforcement is delegated to the CNI. The lab below runs on Calico, which intercepts pod traffic and applies both ingress and egress rules against a three-tier Frontend ⭢ Backend ⭢ Database stack.

Building the Target Application
A dedicated namespace hosts the workloads, each carrying the labels that will later act as firewall identity.
|
1 2 3 4 5 6 7 8 |
kubectl create namespace production-app # CREATE FRONTEND POD kubectl run frontend --image=nginx --labels=tier=frontend -n production-app # CREATE BACKEND POD kubectl run backend --image=nginx --labels=tier=backend -n production-app # CREATE DATABASE POD kubectl run database --image=postgres:18 --labels=tier=database -n production-app \ --env="POSTGRES_DB=myapp" --env="POSTGRES_USER=appuser" --env="POSTGRES_PASSWORD=securepass123" |
Confirm the pods and their labels:
|
1 2 3 4 5 6 |
kubectl get pods -n production-app --show-labels NAME READY STATUS RESTARTS AGE LABELS backend 1/1 Running 0 3h6m tier=backend database 1/1 Running 0 3h12m tier=database frontend 1/1 Running 0 3h6m tier=frontend |
Publish the pods behind ClusterIP Services so they resolve and connect by name:
|
1 2 3 |
kubectl expose pod frontend --port=80 --target-port=80 -n production-app kubectl expose pod backend --port=80 --target-port=80 -n production-app kubectl expose pod database --port=5432 --target-port=5432 -n production-app |
Closing the Namespace by Default
Nothing is restricted until a policy selects it. The starting point is therefore a namespace-wide default deny: a NetworkPolicy whose podSelector: {} matches every pod in the namespace and drops all inbound traffic that is not explicitly authorized.
default-deny-ingress.yaml:
|
1 2 3 4 5 6 7 8 9 |
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-ingress namespace: production-app spec: podSelector: {} policyTypes: - Ingress |
Opening the Ingress Paths
With the namespace sealed, each legitimate flow gets a narrow hole, keyed on the source pod's labels.
Frontend to Backend
The backend accepts connections only from pods labeled tier: frontend, and only on TCP port 80.
backend-allow-frontend.yaml:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: backend-allow-frontend namespace: production-app spec: podSelector: matchLabels: tier: backend policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: tier: frontend ports: - protocol: TCP port: 80 |
Backend to Database
The data tier is tighter still: only the backend may connect, and only on PostgreSQL's default port, 5432.
database-allow-backend.yaml:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: database-allow-backend namespace: production-app spec: podSelector: matchLabels: tier: database policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: tier: backend ports: - protocol: TCP port: 5432 |
Constraining Egress
Ingress rules stop inbound attacks on a service; egress rules stop a compromised pod from reaching outward, whether for lateral movement or for exfiltration.
Frontend Egress
frontend-allow-egress.yaml:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: frontend-allow-egress namespace: production-app spec: podSelector: matchLabels: tier: frontend policyTypes: - Egress egress: - to: - podSelector: matchLabels: tier: backend ports: - protocol: TCP port: 80 - to: - podSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53 |
Backend Egress
backend-allow-egress.yaml:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: backend-allow-egress namespace: production-app spec: podSelector: matchLabels: tier: backend policyTypes: - Egress egress: - to: - podSelector: matchLabels: tier: database ports: - protocol: TCP port: 5432 - to: - podSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53 |
Note: DNS is the easily missed piece of egress security. Both egress policies carry a second to: block. Once egress is restricted, cluster DNS resolution against CoreDNS stops working unless UDP and TCP port 53 to the kube-system namespace are explicitly permitted. Service discovery fails silently otherwise.
External APIs and IP Blocks
Some workloads need to reach external APIs while a known suspicious address inside the allowed range must stay unreachable.
frontend-allow-external-api.yaml:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: frontend-allow-external-api namespace: production-app spec: podSelector: matchLabels: tier: frontend policyTypes: - Egress egress: - to: - ipBlock: cidr: 203.0.113.0/24 except: - 203.0.113.1/32 ports: - protocol: TCP port: 443 - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53 |
Note: when a second policy selects the same pods, Kubernetes combines both policies with OR logic — the union of their permissions applies.
Verifying the Segmentation
Validation means probing both the paths that should work and the ones that should not.
Breaking the Default Deny
A label-less test pod is created inside the namespace to confirm the baseline is closed.
|
1 2 3 4 5 6 7 |
kubectl run test-pod --image=nicolaka/netshoot -n production-app -- sleep 3600 kubectl exec -n production-app test-pod -- curl --max-time 3 backend Output: curl: (28) Connection timed out after 3000 milliseconds command terminated with exit code 28 |
Blocked by the default deny policy: the backend only accepts pods bearing tier=frontend.
Reaching In From Another Namespace
|
1 2 3 4 5 6 7 |
kubectl run external-test --image=nicolaka/netshoot -n default -- sleep 3600 kubectl exec external-test -n default -- curl --max-time 3 backend.production-app.svc.cluster.local Output: curl: (28) Connection timed out after 3002 milliseconds command terminated with exit code 28 |
Blocked, since the connection originates in the default namespace.
The Authorized Frontend ⭢ Backend Hop
|
1 2 3 4 5 6 7 8 9 10 11 |
kubectl exec frontend -n production-app -- curl --max-time 3 backend Output: ... <!DOCTYPE html> <html> <head> <title>Welcome to nginx!</title> ... <h1>Welcome to nginx!</h1> ... |
Both sides agree: the frontend egress policy permits pods labeled tier=backend on port 80, and the backend ingress policy permits pods labeled tier=frontend on port 80.
Port-Level Enforcement at the Backend
|
1 2 3 4 5 |
kubectl exec frontend -n production-app -- curl --max-time 3 backend:8080 Output: curl: (28) Connection timed out after 3001 milliseconds command terminated with exit code 28 |
Failed even though the label match was valid — NetworkPolicy governs the port as well as the peer, and only port 80 is open.
Backend to Database
Bash's built-in /dev/tcp makes it possible to probe the TCP connection without curl.
|
1 2 3 4 |
kubectl exec backend -n production-app -- timeout 3 bash -c 'cat < /dev/null > /dev/tcp/database/5432' && echo "Connection succeessful" || echo "Connection failed" Output: Connection succeessful |
The backend egress policy allows the database pod on port 5432, and the database ingress policy accepts it.
Port-Level Enforcement at the Database
|
1 2 3 4 |
kubectl exec backend -n production-app --timeout 3 bash -c 'cat < /dev/null > /dev/tcp/database/80' && echo "Connection succeessful" || echo "Connection failed" Output: Connection failed |
Dropped: the database ingress policy admits port 5432 only, so port 80 goes nowhere.
Blocking the Lateral Shortcut
A compromised frontend must not be able to skip the application tier and talk to data directly.
|
1 2 3 4 |
kubectl exec frontend -n production-app --timeout 3 bash -c 'cat < /dev/null > /dev/tcp/database/5432' && echo "Connection succeessful" || echo "Connection failed" Output: Connection failed |
Two independent policies close this path. The frontend egress policy permits outbound traffic only to tier: backend on port 80, and the database ingress policy accepts only tier: backend sources.
What the Lab Demonstrates
- Labels are identity. Every pod-to-pod and namespace-to-namespace decision keys off labels, so RBAC over label mutation matters as much as RBAC over policies — changing a label can defeat a firewall.
- Traffic needs permission from both ends. In a locked-down cluster, a flow succeeds only when the source has an egress rule and the destination has an ingress rule.
- CoreDNS is part of the policy surface. Egress policies must include UDP/TCP port 53 access to
kube-system, or service discovery breaks without an obvious error.



