Picture this: an attacker pops a single pod in your cluster, maybe through a vulnerable image or a leaked token. From that one foothold, they can reach every database, every internal API, every secret-fetching sidecar you run. Nothing stops them, because by default nothing tries to.
Network Policies are the thing that stops them. They turn “one compromised pod” into “one compromised pod, and that’s it.” Everyone knows they should use them. Almost nobody actually does, because the YAML looks scary and the behaviour is weird until the mental model clicks.
I’ve burned hours debugging policies that “should work” and didn’t. So this post builds the model up one layer at a time, with a diagram for each step. Start at the top, stop whenever you have enough.
The Default: Everything Talks to Everything
Here’s the part that surprises people. Out of the box, Kubernetes lets every pod talk to every other pod, across every namespace. No restrictions at all.
flowchart TD
subgraph cluster["Cluster"]
Pod1["Pod"] <--> Pod2["Pod"]
Pod2 <--> Pod3["Pod"]
Pod3 <--> Pod4["Pod"]
Pod1 <--> Pod3
Pod1 <--> Pod4
Pod2 <--> Pod4
end
note["Everyone can talk"]
Great for getting something running on day one. Terrible the moment you care about blast radius. Compromise one pod and the attacker reaches the whole cluster. That’s exactly the flat trust model zero trust tells us to avoid: assume breach, then make sure the breach can’t spread.
Network Policies: The Firewall for Pods
A NetworkPolicy describes how pods are allowed to communicate. It’s a firewall rule that targets groups of pods by their labels instead of by IP, which matters because pod IPs are ephemeral and you’d go insane managing them by hand.
Four concepts carry the whole feature:
- Ingress: traffic coming in to a pod
- Egress: traffic going out from a pod
- Selectors: how you pick which pods a rule applies to (by labels)
- Policy Types: whether you’re controlling ingress, egress, or both
That’s the vocabulary. Everything below is just combinations of these four.
The Most Important Rule: Default Deny
The first policy you apply to any namespace is default deny. It blocks all traffic, and then you open up exactly what you need. This is the simplest possible policy and also the most important one.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # Empty = all pods
policyTypes:
- Ingress
- Egress
flowchart TD
subgraph production["production namespace"]
Pod1["Pod"] x--x Pod2["Pod"]
Pod2 x--x Pod3["Pod"]
Pod3 x--x Pod4["Pod"]
end
note["All traffic blocked by default"]
Now nothing communicates. That feels broken, and that’s the point. You’re starting from zero and building up, which is the only way to know what your apps actually need. Anything you forget to allow will fail loudly during testing instead of quietly leaving a hole open in production.
Allowing Ingress: Who Can Talk TO This Pod?
Say you have a frontend that needs to receive traffic. You select that pod by its label and spell out who’s allowed to reach it.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-ingress
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
- podSelector:
matchLabels:
app: ingress-controller
ports:
- protocol: TCP
port: 8080
flowchart LR
subgraph ingress-nginx["ingress-nginx ns"]
IC["ingress controller"]
end
subgraph production["production ns"]
FE["frontend<br/>app:frontend"]
Other["other pods"]
end
IC -->|"✓"| FE
Other x--x|"✗"| FE
The frontend now accepts traffic from the ingress controller on port 8080, and nothing else gets in. Other pods in the namespace can knock all they want; the door stays shut.
Allowing Egress: What Can This Pod Talk TO?
Ingress controls who reaches a pod. Egress controls where a pod is allowed to reach. The frontend needs to call the backend API, so we open that path and nothing more.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-egress
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 3000
- to: # Allow DNS
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
flowchart TD
subgraph production["production namespace"]
FE["frontend"] -->|"✓"| BE["backend"]
BE x--x|"✗"| DB["database"]
FE -->|"✓ DNS only"| DNS["kube-dns"]
end
The frontend can reach the backend on port 3000 and resolve DNS. It cannot touch the database directly, which is the whole idea. If someone compromises the frontend, they’re stuck talking to the backend and can’t jump straight to your data.
The Complete Three-Tier Example
Now the layers come together. A realistic web app: ingress to frontend, frontend to backend, backend to database. Each tier gets a policy, each policy allows only its immediate neighbour.
# Frontend policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-policy
spec:
podSelector:
matchLabels:
tier: frontend
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress
ports:
- port: 80
egress:
- to:
- podSelector:
matchLabels:
tier: backend
ports:
- port: 8080
- to: # DNS
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
---
# Backend policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-policy
spec:
podSelector:
matchLabels:
tier: backend
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
tier: frontend
ports:
- port: 8080
egress:
- to:
- podSelector:
matchLabels:
tier: database
ports:
- port: 5432
- to: # DNS
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
---
# Database policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: database-policy
spec:
podSelector:
matchLabels:
tier: database
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
tier: backend
ports:
- port: 5432
egress: [] # Database doesn't need to initiate connections
flowchart LR
Internet --> Ingress
Ingress --> Frontend
Frontend --> Backend
Backend --> Database
Frontend -.->|"DNS only"| DNS["kube-dns"]
Backend -.->|"DNS only"| DNS
Each tier talks only to the tier next to it. The frontend can’t reach the database. The database can’t reach the internet at all, which means even a full database compromise can’t exfiltrate data over an outbound connection. That’s least privilege expressed as a graph, and it’s the same single-step model from earlier, just repeated three times.
Common Gotchas
This is where my debugging hours went. None of these are obvious until they bite you.
Gotcha 1: Don’t Forget DNS
Your pods resolve service names through kube-dns. Apply a default-deny egress policy without allowing DNS and suddenly nothing resolves, which looks like a hundred different bugs before you realise it’s just blocked port 53.
egress:
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
Without this, backend.production.svc.cluster.local never resolves and your app sits there timing out.
Gotcha 2: Policies Are Additive
Multiple policies on the same pod combine with OR logic. Policy A allows traffic from X, policy B allows traffic from Y, so the pod accepts both X and Y.
There’s no “deny” rule that overrides an “allow.” The only way to deny something is to never allow it in the first place. If you’re used to firewall systems with explicit deny rules and ordering, unlearn that here.
Gotcha 3: Empty Selector Means “All”
podSelector: {} # All pods in this namespace
namespaceSelector: {} # All namespaces
This one catches almost everyone. An empty selector matches everything, not nothing. It reads like “no pods” and behaves like “every pod,” which is exactly why default-deny uses podSelector: {} to grab the whole namespace.
Gotcha 4: Policies Only Affect Selected Pods
A NetworkPolicy only touches pods that match its podSelector. Any pod that no policy selects has zero restrictions and talks freely.
So you can write beautiful policies for half your namespace and leave the other half wide open without noticing. Default-deny fixes this by guaranteeing every pod is selected by at least one policy.
Testing Network Policies
Don’t trust a policy you haven’t tested. Spin up a throwaway pod and try to reach things that should and shouldn’t work.
# Create a test pod
kubectl run test-pod --image=busybox --rm -it -- sh
# Try to reach another service
wget -qO- --timeout=2 http://backend.production:8080/health
# Try to reach the database directly (should fail)
nc -zv database.production 5432
When you need more than busybox, netshoot ships with every networking tool you’d want:
kubectl run netshoot --image=nicolaka/netshoot -it --rm -- bash
The “should fail” tests matter more than the “should work” ones. It’s easy to confirm allowed traffic flows. The whole point is confirming the blocked paths are actually blocked.
CNI Requirements
One catch before you rely on any of this: Network Policies are enforced by your CNI, not by Kubernetes itself. If your CNI doesn’t implement the NetworkPolicy API, the cluster will happily accept your policies and ignore them entirely, which is a nasty way to discover you have no isolation.
- Calico: Full support, plus its own extensions
- Cilium: Full support, plus L7 policies
- Weave: Full support
- Flannel: No support (run Calico on top)
Check what you’re running before you assume a policy does anything. On my own clusters I run Cilium, partly for the L7 features below.
Beyond Basic Policies
Once the basics click, the CNI-specific extensions open up:
- Cilium Network Policies: L7 (HTTP) filtering, DNS-based policies
- Calico Network Policies: host protection, global policies
- Service Mesh: Istio or Linkerd for mutual TLS and L7 policies
The vanilla Kubernetes NetworkPolicy API stays deliberately simple, which is a feature. It covers L3/L4, and when you genuinely need L7 you reach for a CNI that gives it to you. Don’t reach for it before you need it.
My Recommendation
Start small and build up, the same way this post did:
- Apply default-deny to every namespace
- Add policies that allow only the communication you actually need
- Test thoroughly, especially the paths that should fail, before production
- Use Cilium’s policy editor to visualise what you’re writing
Network Policies are one of the highest-value security improvements available in Kubernetes. No extra infrastructure to run, declarative so they live in Git like everything else, and they cut your blast radius down to almost nothing.
The mental model takes a few sessions of trial and error. Once it lands, you’ll feel a little uneasy running any cluster without it.
Network Policies put least privilege on the network. Every connection that doesn’t need to exist shouldn’t exist.
