You Shall Not Pass – Segmenting Pod Traffic with NetworkPolicies
Duration: ~15 minutes
Overview
Move from open pod-to-pod traffic to NetworkPolicies that deny by default and allow only the flows you need.
Show open pod-to-pod traffic, install a default-deny NetworkPolicy, add a narrow allow rule, and confirm which flows still work.
Why it matters
By default, pods in a namespace can usually reach each other. If one pod is compromised, that becomes a free path to databases and internal APIs. NetworkPolicies flip the model: deny first, then allow only the clients and ports you choose. That limits how far an incident spreads and gives responders a concrete answer to what a pod was allowed to talk to.
What does it solve
-
Prevents every compromised pod becoming a pivot
-
Reduces accidental exposure of internal services
-
Eases incident scoping (“what could it reach?”)
-
Supports regulated segmentation requirements
Your Mission
A foothold in any pod can scan sideways to databases and internal APIs when east–west traffic is open.
Your mission: prove the open path exists, slam the door with default deny, then reopen only the client you trust.
Click each step only if you need a hint.
Create project 101-05-n-netpol-demo
Stay in this project for every command. You will add an API and two clients before any NetworkPolicy exists.
oc new-project 101-05-n-netpol-demo
Now using project "101-05-n-netpol-demo".
|
Deploy api on port 8080 and expose the Service
Python http.server is the stand-in API. Expose it so clients can use the Service IP.
oc create deployment api --image=registry.access.redhat.com/ubi9/python-311 -- python3 -m http.server 8080
oc expose deployment api --port=8080 --target-port=8080
oc get svc api should show port 8080.
|
Run client-allowed and client-denied
Two sleep pods with different role labels. Labels do not matter until you add a NetworkPolicy.
oc run client-allowed --image=registry.access.redhat.com/ubi9/ubi -l role=allowed -- sleep infinity
oc run client-denied --image=registry.access.redhat.com/ubi9/ubi -l role=denied -- sleep infinity
oc get pods should show both clients Running.
|
Save the api Service IP as SVC
Later curl commands use $SVC. Recapture it if you open a new terminal.
SVC=$(oc get svc api -o jsonpath='{.spec.clusterIP}')
echo "$SVC"
| Echo should be a cluster IP, not empty. |
Curl the API from both clients with no NetworkPolicy
Both clients should reach the API. That is the blast radius you must shrink.
oc exec client-allowed -- curl -s -o /dev/null -w '%{http_code}\n' http://$SVC:8080
oc exec client-denied -- curl -s -o /dev/null -w '%{http_code}\n' http://$SVC:8080
Both commands print 200. That is the correct result—there is no NetworkPolicy yet, so every pod can call the API.
|
200
200
What to explain: labels do not matter yet. role=allowed and role=denied are only names. Either pod can reach the API, so a foothold in any client is a pivot.
flowchart LR CA["client-allowed<br/>role=allowed"] CD["client-denied<br/>role=denied"] API["api :8080"] CA -->|"curl → 200"| API CD -->|"curl → 200"| API
Apply NetworkPolicy default-deny-ingress
Close every inbound door. Compromised pods should time out instead of probing freely.
oc apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
spec:
podSelector: {}
policyTypes:
- Ingress
EOF
networkpolicy.networking.k8s.io/default-deny-ingress created.
|
Confirm default-deny-ingress exists
oc get networkpolicy default-deny-ingress
POD-SELECTOR should be <none> (selects all pods).
|
Curl the API from both clients after default deny
Both clients should time out. Good — lateral movement is blocked until you explicitly reopen a path.
oc exec client-allowed -- sh -c "curl -s --max-time 3 -o /dev/null http://$SVC:8080" >/dev/null 2>&1 || echo TIMEOUT
oc exec client-denied -- sh -c "curl -s --max-time 3 -o /dev/null http://$SVC:8080" >/dev/null 2>&1 || echo TIMEOUT
Both commands print TIMEOUT. That is the correct result—default deny closed inbound traffic for every pod, including the client you will trust next.
|
TIMEOUT
TIMEOUT
What to explain: a default-deny Ingress policy selects all pods in the namespace. Timeouts are the win. You have not broken DNS or the API process—you blocked the path.
flowchart LR CA["client-allowed<br/>role=allowed"] CD["client-denied<br/>role=denied"] API["api :8080"] CA -.->|"curl → TIMEOUT"| API CD -.->|"curl → TIMEOUT"| API
Apply NetworkPolicy allow-api-from-allowed
Allow TCP/8080 solely from pods labeled role=allowed. Everything else stays locked out.
oc apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-from-allowed
spec:
podSelector:
matchLabels:
app: api
ingress:
- from:
- podSelector:
matchLabels:
role: allowed
ports:
- protocol: TCP
port: 8080
policyTypes:
- Ingress
EOF
networkpolicy.networking.k8s.io/allow-api-from-allowed created.
|
List NetworkPolicies in the project
oc get networkpolicy
You should see both default-deny-ingress and allow-api-from-allowed.
|
Curl from client-allowed then client-denied
Allowed client returns 200; denied client times out. Business traffic works; the untrusted client does not.
oc exec client-allowed -- curl -s -o /dev/null -w '%{http_code}\n' http://$SVC:8080
oc exec client-denied -- sh -c "curl -s --max-time 3 -o /dev/null http://$SVC:8080" >/dev/null 2>&1 || echo BLOCKED
Expected output is 200 then BLOCKED. The trusted client is back; the untrusted client is still dark.
|
200
BLOCKED
What to explain: labels now enforce the boundary. The allow rule matches role=allowed to app=api on TCP/8080 only. role=denied has no matching ingress rule, so it still times out.
flowchart LR CA["client-allowed<br/>role=allowed"] CD["client-denied<br/>role=denied"] API["api :8080"] CA -->|"curl → 200"| API CD -.->|"curl → BLOCKED"| API
Delete project 101-05-n-netpol-demo
Optional if you will run the module cleanup script at the bottom instead.
oc delete project 101-05-n-netpol-demo --wait=false
The project should show Terminating.
|
Debrief
You started from a flat, open namespace network, applied default-deny ingress, then reopened only the trusted client path—shrinking east-west blast radius.
What breaks without this:
-
No NetworkPolicy → every pod can reach every other pod by default
-
Over-broad labels → accidental allows for the wrong workloads
-
Missing egress rules when needed → exfil or C2 paths stay open
| default-deny in sensitive namespaces, smallest allow set that keeps the app working, consistent app/role/tier/env labels, and validation of allow vs deny paths. |
| policies select pods; once selected, only explicit allows pass. CNI enforces at the pod interface—not a substitute for node firewalls. Optional pattern notes below can help you design the next rules. |
Extra: Network Policy Patterns (Reading)
Details
Reference patterns adapted from docs/network-policies.adoc. Use them to reason about design; keep YAML only in the hands-on section above.
Each pattern includes an explanation, use case, risk, implementation checklist, and a mermaid sketch.
DENY all non-whitelisted traffic to a namespace
flowchart LR
subgraph ns_other ["namespace other"]
Blog[app=blog]
end
subgraph ns_default ["namespace default"]
API[app=api]
Guest[app=guestbook]
end
Blog -.-> Guest
Blog -. ❌ .-> API
API -. ❌ .-> Guest
Only an approved cross-namespace flow (blog → guestbook) is permitted; other cross or internal flows are blocked.
-
Use case: multi-tenant cluster; restrict which external namespace may call a frontend.
-
Why it matters: reduces lateral movement between namespaces.
-
Checklist: NetworkPolicy selecting protected pods; ingress
fromwithnamespaceSelector+podSelector; specify ports;policyTypes: [Ingress].
LIMIT traffic to an application
flowchart LR Coffee[app=coffeeshop\\nrole=api] BookAPI[app=bookstore\\nrole=api] BookFE[app=bookstore\\nrole=frontend] BookAPI -.-> BookFE Coffee -. ❌ .-> BookAPI
Frontend (role=frontend) may call the bookstore API; other APIs are denied.
-
Use case: intra-namespace microservice boundaries.
-
Why it matters: prevents accidental or malicious calls to internal APIs.
-
Checklist: podSelector for API pods; ingress from frontend label; restrict ports;
policyTypes: [Ingress].
DENY all traffic from other namespaces
flowchart LR
subgraph ns_foo ["namespace: foo"]
FooPod[Any Pod]
end
subgraph ns_default ["namespace: default"]
Web[app=web]
DB[app=db]
end
subgraph ns_bar ["namespace: bar"]
BarPod[Any Pod]
end
Web -.-> DB
DB -.-> Web
FooPod -. ❌ .-> Web
FooPod -. ❌ .-> DB
BarPod -. ❌ .-> Web
BarPod -. ❌ .-> DB
Only in-namespace communication is permitted.
-
Use case: tenant isolation; environment boundary.
-
Why it matters: prevents privilege creep and meets audit separation requirements.
-
Checklist: policy selecting web and db; ingress limited to the same namespace (no
namespaceSelector) unless you add a trusted namespace.
ALLOW traffic only to a metrics port
flowchart LR
Prom[app=prometheus\\nrole=monitoring]
subgraph API ["app=api"]
Metrics[":5000 (metrics)"]
HTTP[":8000 (http)"]
end
Prom -.-> Metrics
Prom -. ❌ .-> HTTP
Prometheus may scrape the metrics port; the general HTTP port is blocked.
-
Use case: observability access minimization.
-
Why it matters: monitoring credentials cannot reach non-observability endpoints.
-
Checklist: ingress from monitoring pods; allow port 5000 only.
DENY external egress traffic
flowchart LR
subgraph ns_default ["namespace: default"]
App1[app=web]
App2[app=db]
end
External[External services / Internet]
App1 -.-> App2
App2 -.-> App1
App1 -. ❌ .-> External
App2 -. ❌ .-> External
Internal communication is allowed; outbound to external networks is denied.
-
Use case: regulated workloads (PCI, OT) that need strict egress control.
-
Why it matters: reduces exfil and command-and-control callbacks.
-
Checklist: egress policy; allow only explicit internal destinations (DNS, logging);
policyTypes: [Egress].
DENY all inbound to an application (except a specific source)
flowchart LR
subgraph ns_default ["namespace: default"]
Web[app=web]
end
subgraph ns_foo ["namespace: foo"]
FooPod[Any Pod]
end
AnyOther[Any Pod]
FooPod -.-> Web
Web -.-> AnyOther
Web -. ❌ .-> FooPod
Web -. ❌ .-> AnyOther
Web can make outbound calls, but only FooPod can reach it inbound.
-
Use case: backend reachable only via a controlled proxy or connector.
-
Why it matters: prevents accidental exposure and narrows the attack surface.
-
Checklist: policy selecting web; ingress permitting only the proxy label; add
policyTypes: [Ingress,Egress]if you control both directions.
Operational notes
-
Pods not selected by any policy remain open. Once selected, only explicit allows pass.
-
Policies do not cross namespaces without
namespaceSelector. -
Default deny: add a policy that selects the pods, then add granular allows.
Validation snippets
# Test an allowed path
oc exec pod/frontend -- curl -s -o /dev/null -w '%{http_code}\n' http://api:8080
# Test a blocked path with timeout fallback
oc exec pod/untrusted -- curl -s --max-time 3 http://api:8080 || echo BLOCKED
