Something that surprised me when I first started using Kubernetes was how open the default network is. By default, every Pod in a cluster can talk to every other Pod, across every namespace, with no restrictions at all. For a small side project that’s fine. For anything handling real user data, it’s a problem. NetworkPolicies are how you fix that, and in this guide I’ll walk through what they are, how the underlying model works, and how to write policies that actually lock things down without breaking your app.
Understanding the Model
A NetworkPolicy is a namespaced resource that selects a group of Pods (via labels) and defines rules for what traffic is allowed in (ingress) and out (egress). Two things trip people up constantly:
- NetworkPolicies are additive, never subtractive on their own. If no policy selects a Pod, all traffic is allowed. The moment any policy selects that Pod, it switches to default-deny for the traffic type (ingress or egress) that policy addresses, and only explicitly allowed traffic gets through.
- You need a CNI plugin that enforces NetworkPolicies. Not all of them do. Calico, Cilium, and Weave Net support it; the basic
kubenetplugin does not. If you write policies and traffic doesn’t seem restricted at all, this is the first thing to check.
Step 1: Verify Your CNI Supports NetworkPolicies
kubectl get pods -n kube-system
Look for a Calico, Cilium, or Weave DaemonSet. If you’re on a managed cluster, check your provider’s docs — for example, GKE requires enabling “Network Policy” explicitly at cluster creation or via an update, since it isn’t on by default:
gcloud container clusters update mycluster --update-addons=NetworkPolicy=ENABLED
Step 2: Start with a Default-Deny Policy
A common and recommended starting point is denying all ingress traffic in a namespace, then explicitly allowing what’s needed:
# default-deny-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
An empty podSelector: {} means “select all Pods in this namespace.” With no ingress rules defined, nothing is allowed in.
kubectl apply -f default-deny-ingress.yaml
Test that this actually blocks traffic:
kubectl run test-pod --rm -it --image=busybox -n production -- wget -qO- --timeout=2 http://myapp-service
You should see a timeout, confirming the deny is active.
Step 3: Allow Specific Traffic
Now open up exactly what’s needed. Let’s say we have a frontend that needs to talk to a backend API:
# allow-frontend-to-backend.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
This says: Pods labeled app: backend will only accept ingress traffic from Pods labeled app: frontend, and only on port 8080. Everything else remains blocked by the default-deny policy from Step 2.
kubectl apply -f allow-frontend-to-backend.yaml
Step 4: Allow Traffic from Another Namespace
Cross-namespace communication needs a namespaceSelector, often combined with a podSelector:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-monitoring-scrape
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app: prometheus
ports:
- protocol: TCP
port: 9090
Note that combining namespaceSelector and podSelector inside the same from entry is an AND condition — traffic must come from a Pod matching both selectors. Listing them as separate entries in the from array would instead be an OR condition.
Step 5: Egress Policies
Restricting outbound traffic is just as important — it limits what a compromised Pod can reach. Here’s a policy that only allows a Pod to reach DNS and a specific external API:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: restrict-egress
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- ipBlock:
cidr: 203.0.113.0/24
ports:
- protocol: TCP
port: 443
Always allow DNS (port 53) explicitly in egress policies — forgetting this is the single most common reason apps break immediately after applying a default-deny egress policy, since they can no longer resolve service names.
Step 6: Testing Policies Thoroughly
I always validate policies with a throwaway debug Pod, testing both allowed and denied paths:
kubectl run debug --rm -it --image=nicolaka/netshoot -n production -- bash
# Inside the pod
curl -v --max-time 2 http://backend-service:8080/health # should succeed if from frontend
nslookup backend-service.production.svc.cluster.local # should succeed (DNS allowed)
curl -v --max-time 2 http://unrelated-service:9000 # should time out if denied
Step 7: Combining Multiple Policies
Policies are additive per Pod — if multiple NetworkPolicies select the same Pod, the union of all their rules applies. This lets you build modular policies: one for basic DNS/monitoring access shared cluster-wide, and app-specific ones layered on top.
kubectl get networkpolicy -n production
NAME POD-SELECTOR AGE
default-deny-ingress <none> 10m
allow-frontend-to-backend app=backend 8m
allow-monitoring-scrape app=backend 5m
restrict-egress app=backend 3m
Debugging NetworkPolicy Issues
kubectl describe networkpolicy allow-frontend-to-backend -n production
If traffic that should be allowed is still blocked, check:
- Label mismatches — a typo in
matchLabelsis the most common cause by far. - Whether the CNI plugin actually enforces policies (test with a deliberately open policy to confirm enforcement works at all).
- Whether both ingress AND egress need rules — if the source Pod has an egress-restricting policy too, it needs its own egress rule allowing the outbound connection, independent of the destination’s ingress rule.
For Cilium specifically, cilium monitor and Hubble give much deeper visibility into allowed/denied flows in real time, which I’d recommend over blind trial and error once policies get complex.
Security Best Practices
- Start every namespace with default-deny for both ingress and egress, then open explicitly — deny-by-default is dramatically safer than trying to enumerate every bad actor.
- Label Pods consistently and intentionally; NetworkPolicy security is only as good as your labeling discipline.
- Combine NetworkPolicies with RBAC and Pod Security Standards for defense in depth — network isolation alone doesn’t stop a compromised service account from misusing the Kubernetes API.
- Document policies alongside application architecture diagrams so on-call engineers understand why traffic is blocked when debugging incidents.
- Regularly audit policies for overly broad rules like an empty
podSelector: {}in afromblock, which allows all Pods in a namespace.
Common Mistakes
- Assuming NetworkPolicies work without checking CNI support — the most common “why isn’t this working” issue.
- Forgetting DNS egress rules and breaking service discovery cluster-wide.
- Writing ingress-only policies while ignoring egress, leaving lateral movement wide open in the outbound direction.
- Applying default-deny to
kube-systemand breaking core cluster functionality — usually best left more permissive unless you know exactly what you’re isolating.
Layer 7 Policies and Beyond Basic NetworkPolicy
The built-in Kubernetes NetworkPolicy resource operates at Layer 3/4 — IP addresses, ports, protocols. It has no concept of HTTP methods, paths, or application-layer identity. If you need Layer 7 controls (like “only allow GET requests from this service, block POST”) or stronger identity guarantees than label matching provides, you’re looking at a service mesh like Istio, Linkerd, or Cilium’s own Layer 7-aware policies, layered on top of basic NetworkPolicy rather than replacing it. Cilium in particular extends its CiliumNetworkPolicy custom resource well beyond the standard API, supporting DNS-aware egress rules (allow only traffic to *.amazonaws.com, for example) and HTTP-aware rules natively, without needing a full sidecar-based service mesh:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-specific-http
namespace: production
spec:
endpointSelector:
matchLabels:
app: backend
ingress:
- fromEndpoints:
- matchLabels:
app: frontend
toPorts:
- ports:
- port: "8080"
protocol: TCP
rules:
http:
- method: "GET"
path: "/api/v1/.*"
This is worth knowing about even if you start with plain NetworkPolicy, since migrating later to something more expressive doesn’t require throwing away your existing label-based segmentation model.
A Realistic Multi-Tier Application Policy Set
To make this concrete, here’s how I’d typically structure NetworkPolicies for a standard three-tier app (frontend, backend API, database), all in one namespace:
# 1. Default deny everything
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes: ["Ingress", "Egress"]
---
# 2. Allow all pods to reach DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: production
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
---
# 3. Frontend can be reached from Ingress controller, can reach backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-policy
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
policyTypes: ["Ingress", "Egress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
egress:
- to:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 8080
---
# 4. Backend only reachable from frontend, only talks to database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-policy
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes: ["Ingress", "Egress"]
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: database
ports:
- protocol: TCP
port: 5432
---
# 5. Database only reachable from backend, no outbound needed
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: database-policy
namespace: production
spec:
podSelector:
matchLabels:
app: database
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 5432
This set enforces a strict linear trust chain — Ingress reaches frontend, frontend reaches backend, backend reaches database, and nothing skips a tier or reaches anything it doesn’t strictly need. It’s the kind of layout that turns “a compromised frontend Pod” into a contained incident rather than a path straight to the database.
Auditing Policy Coverage Across a Cluster
For clusters with many namespaces, periodically check which namespaces have no NetworkPolicies at all — these are your fully-open blind spots:
for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do
count=$(kubectl get networkpolicy -n "$ns" --no-headers 2>/dev/null | wc -l)
echo "$ns: $count policies"
done
Namespaces reporting zero policies are worth prioritizing, roughly in order of how sensitive the data they handle is.
Summary
NetworkPolicies give you the equivalent of a namespace-aware firewall for Pod-to-Pod traffic. The keys to using them well are understanding that they’re default-allow until any policy selects a Pod (at which point it becomes default-deny for that traffic direction), always allowing DNS explicitly, and testing thoroughly with a debug Pod before trusting a policy in production. Start with default-deny and layer in precise allow rules — it’s a more secure posture than trying to block specific bad actors after the fact.