Article

Kubernetes Network Policy: Locking Down Your Cluster Traffic

13 min read
Originally published on Medium. Read, clap or comment on Medium ↗

By default, every pod in your cluster can talk to every other pod. In production, that’s not a feature — it’s a vulnerability. Here’s how to fix it.

Kubernetes Zero to Hero · Bonus Episode ·

We briefly touched on NetworkPolicy back in Episode 04 when we introduced Services and pod networking. We showed a quick example and moved on. This bonus episode gives NetworkPolicy the full treatment it deserves — because in any cluster handling real workloads, getting this wrong has serious consequences.

The default Kubernetes networking model is wide open. A compromised frontend pod can freely connect to your database. A rogue process in one namespace can probe services in another. Any pod can attempt to reach the Kubernetes API Server. Without NetworkPolicy, your cluster is a flat network where everything can reach everything.

NetworkPolicy is how you change that. It is Kubernetes-native firewall rules — declarative, auditable, and enforced at the network layer.

The Default: Zero Isolation

Let’s prove the problem before we solve it.

# Deploy a simple backend database pod
kubectl run postgres \
--image=postgres:15 \
--env=POSTGRES_PASSWORD=secret \
--labels=app=postgres,tier=database
# Deploy a frontend pod
kubectl run frontend \
--image=nginx:1.25 \
--labels=app=frontend,tier=web
# Deploy a completely unrelated pod — say, a compromised microservice
kubectl run attacker \
--image=busybox \
--labels=app=attacker \
-- sleep 3600
# Get the postgres pod IP
kubectl get pod postgres -o wide
# NAME IP
# postgres 10.244.1.5
# From the "attacker" pod — can it reach postgres?
kubectl exec -it attacker -- wget -qO- http://10.244.1.5:5432
# It can initiate a connection. Without NetworkPolicy, nothing stops it.

That unrelated pod has unrestricted network access to your database. In a real cluster, that means a compromised microservice — or a poorly written app, or a misconfigured sidecar — can probe, scan, and attempt connections to every service in your cluster.

NetworkPolicy closes that door.

How NetworkPolicy Works

A NetworkPolicy is a Kubernetes object that defines rules controlling which pods can send traffic to which other pods, and on which ports.

Three things to understand before writing your first policy:

1. NetworkPolicy is additive. There is no “default deny” unless you create one. Policies only add restrictions — they never override each other maliciously. If two policies apply to the same pod, traffic is allowed if either policy permits it.

2. Policies are pod-scoped via selectors. A NetworkPolicy targets pods using podSelector label matching — the same mechanism as Services. The policy applies to every pod matching that selector, cluster-wide within the namespace.

3. Enforcement requires a compatible CNI plugin. NetworkPolicy objects are Kubernetes API resources, but the actual enforcement happens in the CNI plugin. Calico, Cilium, and Weave Net enforce NetworkPolicy. Flannel does not. If your cluster uses Flannel, NetworkPolicy objects will be accepted but silently ignored — a dangerous false sense of security.

# Check your CNI plugin
kubectl get pods -n kube-system
# Look for: calico-*, cilium-*, weave-*
# If using a managed cluster, check the docs:
# EKS: uses VPC CNI — NetworkPolicy requires enabling a separate NetworkPolicy controller
# GKE: uses native CNI — enable NetworkPolicy at cluster creation or via gcloud
# AKS: uses Azure CNI — enable with --network-policy=calico or --network-policy=azure

The Four Building Blocks of NetworkPolicy

Every NetworkPolicy spec is built from the same four elements:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: example-policy
namespace: default
spec:
podSelector: # 1. WHICH pods does this policy apply TO?
matchLabels:
app: backend
  policyTypes:          # 2. WHICH traffic directions are controlled?
- Ingress # Ingress = traffic coming IN to selected pods
- Egress # Egress = traffic going OUT from selected pods
  ingress:              # 3. WHAT ingress traffic is allowed?
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
  egress:               # 4. WHAT egress traffic is allowed?
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432

The logic is: “For pods matching app: backend — only allow ingress from pods with app: frontend on port 8080, and only allow egress to pods with app: postgres on port 5432. All other traffic is denied."

The Most Important Policy: Default Deny All

The foundation of any secure cluster network is a default deny policy. Create this in every namespace that runs production workloads. It blocks all traffic by default, forcing you to explicitly allow only what is needed.

# default-deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {} # empty selector = applies to ALL pods in the namespace
policyTypes:
- Ingress
- Egress
kubectl apply -f default-deny-all.yaml
# Now test — nothing can reach anything
kubectl exec -it frontend -- curl http://backend-service:8080
# curl: (7) Failed to connect — blocked by NetworkPolicy
⚠️ Apply default-deny before deploying workloads, not after. If you apply it to a running namespace, you’ll immediately break all in-cluster communication. Plan your allow rules first, then apply deny, then apply allows — or apply them simultaneously in a single kubectl apply -f on a directory.

Building a Real Policy Set: The Three-Tier Application

Let’s build a complete NetworkPolicy setup for a realistic three-tier application: a web frontend, an API backend, and a PostgreSQL database.

Internet → Frontend (port 80)
↓
Backend API (port 8080)
↓
PostgreSQL (port 5432)

The security requirement: the database should only accept connections from the backend. The backend should only accept connections from the frontend. The frontend should only accept connections from the internet (via a LoadBalancer Service). Nothing else.

Namespace and Label Setup

# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
environment: production
# Label your pods correctly — NetworkPolicy depends entirely on labels
kubectl label pod frontend-xxx app=frontend tier=web
kubectl label pod backend-xxx app=backend tier=api
kubectl label pod postgres-xxx app=postgres tier=database

Policy 1: Default Deny Everything

# 01-default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress

Policy 2: Allow Frontend to Receive External Traffic

# 02-allow-frontend-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-ingress
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 80
# No "from" block = allow from anywhere (internet traffic via LoadBalancer)

Policy 3: Allow Frontend to Call Backend

# 03-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 # applies to backend pods
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend # only accept traffic from frontend pods
ports:
- protocol: TCP
port: 8080

Policy 4: Allow Backend to Call Database

# 04-allow-backend-to-database.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-to-database
namespace: production
spec:
podSelector:
matchLabels:
app: postgres # applies to postgres pods
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: backend # only accept traffic from backend pods
ports:
- protocol: TCP
port: 5432

Policy 5: Allow DNS Resolution (Critical — Easy to Forget)

DNS queries go to CoreDNS on port 53. If you apply egress deny-all without allowing DNS, your pods can’t resolve service names — everything breaks silently.

# 05-allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: production
spec:
podSelector: {} # applies to ALL pods
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system # CoreDNS namespace
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
💡 Forgetting to allow DNS is the #1 NetworkPolicy mistake. Symptoms: pods are running, Services exist, but connections fail with connection refused or timeouts on service names. Everything works when you use pod IPs directly. Always add the DNS egress rule when applying egress restrictions.

Policy 6: Allow Egress for Backend to Reach Database

# 06-allow-backend-egress-to-db.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-egress-to-db
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432

Apply Everything at Once

kubectl apply -f 01-default-deny.yaml \
-f 02-allow-frontend-ingress.yaml \
-f 03-allow-frontend-to-backend.yaml \
-f 04-allow-backend-to-database.yaml \
-f 05-allow-dns.yaml \
-f 06-allow-backend-egress-to-db.yaml
# Verify all policies are created
kubectl get networkpolicies -n production
NAME POD-SELECTOR AGE
allow-backend-egress-to-db app=backend 5s
allow-backend-to-database app=postgres 5s
allow-dns-egress <none> 5s
allow-frontend-ingress app=frontend 5s
allow-frontend-to-backend app=backend 5s
default-deny-all <none> 5s

Namespace Isolation: Cross-Namespace Traffic Control

By default, podSelector only matches pods within the same namespace. For cross-namespace rules, you need namespaceSelector.

Allow Traffic from a Specific Namespace

# Allow monitoring namespace to scrape metrics from production pods
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 # only from monitoring namespace
ports:
- protocol: TCP
port: 9090 # Prometheus metrics port

Combining podSelector and namespaceSelector

There’s a critical syntax difference that trips up almost everyone:

# WRONG — this is OR logic (from namespace OR from pod)
ingress:
- from:
- namespaceSelector:
matchLabels:
environment: staging
- podSelector:
matchLabels:
app: frontend
# CORRECT — this is AND logic (from frontend pod IN staging namespace)
ingress:
- from:
- namespaceSelector:
matchLabels:
environment: staging
podSelector: # note: same list item, not a new "-"
matchLabels:
app: frontend

The difference is a single - character. The first allows traffic from any pod in the staging namespace or any pod labelled app=frontend in any namespace. The second allows traffic only from pods labelled app=frontend that are also in the staging namespace.

This is the most common NetworkPolicy bug in production clusters.

IP Block Rules: Controlling External Traffic

Sometimes you need to allow or deny traffic based on IP ranges — external APIs, office networks, or specific cloud services.

# Allow backend to reach an external payment API
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-payment-api-egress
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 203.0.113.0/24 # payment provider IP range
except:
- 203.0.113.10/32 # except this specific IP
ports:
- protocol: TCP
port: 443
# Block all egress to private/internal networks (prevent SSRF attacks)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: block-internal-egress
namespace: production
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0 # allow all internet
except:
- 10.0.0.0/8 # except private class A
- 172.16.0.0/12 # except private class B
- 192.168.0.0/16 # except private class C
- 169.254.0.0/16 # except link-local (AWS metadata endpoint)
💡 Block the AWS EC2 metadata endpoint (169.254.169.254) from your application pods. SSRF (Server-Side Request Forgery) vulnerabilities in web applications have been used to steal IAM credentials from the metadata service. NetworkPolicy is your second line of defence after instance metadata service v2 (IMDSv2).

Practical Patterns

Pattern 1: Allow All Within a Namespace, Deny Cross-Namespace

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-same-namespace
namespace: staging
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector: {} # any pod in this namespace (no namespaceSelector = same namespace only)

Pattern 2: Isolate a Single Sensitive Pod

# Lock down a secrets management pod — only allow access from specific trusted pods
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: isolate-vault
namespace: production
spec:
podSelector:
matchLabels:
app: vault
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
vault-access: "true" # only pods with this label
ports:
- port: 8200
egress:
- to:
- podSelector: {} # can communicate within namespace for raft consensus
ports:
- port: 8201

Pattern 3: Temporary Debug Access

# Add this label to a debug pod to grant it temporary access
# kubectl label pod debug-pod vault-access=true
# kubectl label pod debug-pod vault-access- (remove when done)

Pattern 4: Allow Kubernetes Health Checks

kubelets run liveness and readiness probes from the node network, not from within the pod network. If you restrict ingress too aggressively, probes fail and Kubernetes marks your pod unhealthy.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-kubelet-probes
namespace: production
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- ipBlock:
cidr: 10.0.0.0/8 # node CIDR — adjust to match your node IP range
ports:
- protocol: TCP
port: 8080

Debugging NetworkPolicy

NetworkPolicy failures are notoriously quiet — a blocked connection looks exactly like a down service. Here’s the systematic approach.

Step 1: Check if NetworkPolicy is enforced at all

# Does your CNI support NetworkPolicy?
kubectl get pods -n kube-system | grep -E 'calico|cilium|weave'
# Are there any NetworkPolicies in the namespace?
kubectl get networkpolicies -n <namespace>
# Describe a specific policy
kubectl describe networkpolicy <policy-name> -n <namespace>

Step 2: Test connectivity directly

# Run a temporary debug pod with network tools
kubectl run netdebug \
--image=nicolaka/netshoot \
--labels="app=netdebug" \
--rm -it \
--restart=Never \
-- /bin/bash
# From inside the debug pod:
# Test TCP connectivity
nc -zv backend-service 8080
# Test DNS
nslookup backend-service
# Test HTTP
curl -v http://backend-service:8080
# Check if it's a DNS issue vs connection issue
curl -v http://10.96.45.100:8080 # use ClusterIP directly

Step 3: Check the Endpoints object

# If endpoints are empty, it's a label/selector issue — not NetworkPolicy
kubectl get endpoints <service-name> -n <namespace>

Step 4: Temporarily remove the policy to isolate the issue

# Is it definitely NetworkPolicy causing the problem?
kubectl delete networkpolicy default-deny-all -n <namespace>
# If connectivity restores → confirmed NetworkPolicy issue
# Re-apply and systematically add allow rules
kubectl apply -f default-deny-all.yaml

Step 5: Use Cilium’s policy visibility (if using Cilium)

# Cilium has excellent built-in policy troubleshooting
cilium policy get
cilium connectivity test

Step 6: Common fixes checklist

# Did you forget the DNS egress rule?
kubectl get networkpolicy allow-dns-egress -n <namespace>
# Do your pod labels actually match the policy selector?
kubectl get pods -n <namespace> --show-labels
kubectl describe networkpolicy <name> -n <namespace> | grep Selector
# Are you using AND vs OR selectors correctly?
kubectl get networkpolicy <name> -n <namespace> -o yaml | grep -A 10 from
# Are kubelet probe IPs in the allowed range?
kubectl get nodes -o wide # check node IPs

Hands-On Lab

# 1. Create a test namespace
kubectl create namespace netpol-lab
# 2. Deploy three pods
kubectl run frontend --image=nginx:1.25 \
--labels=app=frontend -n netpol-lab
kubectl run backend --image=hashicorp/http-echo:0.2.3 \
--labels=app=backend -n netpol-lab \
-- -text="backend response"
kubectl run attacker --image=busybox \
--labels=app=attacker -n netpol-lab \
-- sleep 3600
# 3. Get backend pod IP
kubectl get pods -n netpol-lab -o wide
# 4. Verify open access — attacker can reach backend
kubectl exec -n netpol-lab attacker -- wget -qO- http://<BACKEND-IP>:5678
# Should return: backend response
# 5. Apply default deny
kubectl apply -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: netpol-lab
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
EOF
# 6. Verify attacker is now blocked
kubectl exec -n netpol-lab attacker -- wget -qO- --timeout=3 http://<BACKEND-IP>:5678
# Should timeout — blocked
# 7. Allow only frontend to reach backend
kubectl apply -f - <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: netpol-lab
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 5678
EOF
# 8. Attacker still blocked
kubectl exec -n netpol-lab attacker -- wget -qO- --timeout=3 http://<BACKEND-IP>:5678
# Still blocked
# 9. Frontend is allowed
kubectl exec -n netpol-lab frontend -- wget -qO- http://<BACKEND-IP>:5678
# Returns: backend response
# 10. Clean up
kubectl delete namespace netpol-lab

Steps 4, 6, and 8–9 are the core of this lab. You watch unrestricted access become fully locked down, then see selective allow rules restore only the connections that should exist.

NetworkPolicy in Production: The Checklist

Before deploying NetworkPolicy to a production namespace:

□ CNI plugin supports NetworkPolicy (Calico, Cilium, Weave — not Flannel)
□ All pods have meaningful, consistent labels
□ DNS egress rule is included (UDP/TCP port 53 to kube-system)
□ Kubelet probe access is allowed (node CIDR on health check ports)
□ Default deny is applied LAST, after all allow rules are tested
□ Connectivity tested from every pod that needs to communicate
□ Monitoring/logging systems (Prometheus, Fluentd) have scrape access
□ CI/CD pipeline can reach deployment endpoints
□ Cross-namespace services are explicitly allowed
□ AND vs OR selector logic verified on all multi-condition rules

What’s Next

With NetworkPolicy under your belt, the next article in the main series picks back up with Episode 07: Ingress Controllers and Load Balancing — exposing multiple services through a single external entry point with path-based routing, TLS termination, and host-based routing.

Episode Topic
EP 01 Introduction to Kubernetes
EP 02 Kubernetes Architecture & Components
EP 03 Pods, Deployments & ReplicaSets
EP 04 Services & Networking
EP 05 ConfigMaps, Secrets & Environment Config
EP 06 Storage: PersistentVolumes & StatefulSets
Bonus Setting Up a Cluster with kubeadm on EC2
Bonus ← You are here Network Policy Deep Dive
EP 07 Ingress Controllers & Load Balancing
EP 08 RBAC & Security Fundamentals
EP 09 Monitoring with Prometheus & Grafana
EP 10 Production Hardening & Helm Charts

Before the Next Article

Test your understanding with these three questions:

  1. You apply a default-deny-all policy to a namespace. Your pods start failing DNS lookups. What did you forget and what does the fix look like?
  2. You write a NetworkPolicy with both namespaceSelector and podSelector as separate list items under from. What traffic does it actually allow?
  3. A new engineer joins your team and says “I added a NetworkPolicy to allow the monitoring pod to scrape metrics, but it’s not working.” What are the first three things you check?

If you can answer all three — you’ve genuinely absorbed this episode.

A cluster without NetworkPolicy is a cluster where one compromised pod can reach everything. You now have the tools to make sure that never describes your production environment. See you in Episode 07.

← All articles Discuss on Medium ↗