Network policies
Default-deny and per-consumer allowlists.
Spin up a brand-new cluster and every pod can reach every other pod. Different namespace, different team, doesn't matter. A Pod (the smallest unit Kubernetes runs, one or more containers sharing an address) holding your payments database will happily accept a connection from a throwaway pod in an intern's test namespace, because nothing is telling it not to. The pod network is flat and open. One big room where anyone can walk up to anyone. A NetworkPolicy is how you put up interior walls, hang doors on them, and post a guest list at each door. This lesson takes a namespace from that open room down to a locked one, and shows you how to prove the lock actually holds.
What a policy actually does
Think of a bouncer at a club door. No bouncer, everyone strolls in. The second you post one, the rule flips: nobody gets in unless they're on the list. NetworkPolicies work the same way, with one twist people always miss. A policy is a bundle of allow rules attached to some pods, chosen by their labels. The moment any policy selects a pod for a direction (incoming traffic, outgoing traffic, or both), that pod stops being open and starts dropping everything except what an allow rule permits. And there is no such thing as a deny rule. You cannot write 'block this source.' Every policy only ever says yes to something. Policies also stack: if a pod is covered by three of them, traffic is allowed when any one permits it. No ordering, no priority, no first-match. The result is just the union of every yes.
That hands you the one move everything else builds on. Select every pod in a namespace, permit nothing, and the namespace flips from open to closed. An empty podSelector, the two curly braces, means 'every pod in this namespace.' List Ingress and Egress as the policy types with no rules underneath, and you've said: these pods may send and receive nothing until a later policy grants it.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: default-denynamespace: paymentsspec:podSelector: {}policyTypes:- Ingress- Egress
$ kubectl apply -f default-deny.yamlnetworkpolicy.networking.k8s.io/default-deny created$ kubectl get netpol -n paymentsNAME POD-SELECTOR AGEdefault-deny <none> 6s
Open exactly what has to talk, DNS first
With default-deny in place, every pod in payments is now deaf and mute. Before you celebrate, notice what you just broke. Almost every app starts a request by looking up a name. 'api' turns into an IP address (a number the network can actually route to), 'postgres' turns into another, and that lookup is itself a network call to CoreDNS, the cluster's phone book, running over in the kube-system namespace on port 53. Your egress default-deny just cut that line. So the pods reach nothing, and their logs fill with name-resolution errors that make the whole namespace look dead. The first allow you write is almost always DNS, the Domain Name System that does that name-to-address lookup. It rides on two protocols, UDP and TCP, so a complete rule opens port 53 for both. After that come the real flows: the frontend may reach the api on 8080. Because the default-deny here selects every pod for egress as well as ingress, that one flow needs a yes at each end, so the frontend gets an egress rule out and the api gets an ingress rule in. That is a consequence of this setup, not a law of NetworkPolicy: where only one end is selected for the direction in question, only that end needs a rule.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: allow-dnsnamespace: paymentsspec:podSelector: {}policyTypes: [Egress]egress:- to:- namespaceSelector:matchLabels:kubernetes.io/metadata.name: kube-systempodSelector:matchLabels:k8s-app: kube-dnsports:- { protocol: UDP, port: 53 }- { protocol: TCP, port: 53 }---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: api-allow-frontendnamespace: paymentsspec:podSelector:matchLabels: { app: api }policyTypes: [Ingress]ingress:- from:- podSelector:matchLabels: { app: frontend }ports:- { protocol: TCP, port: 8080 }---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: frontend-egress-apinamespace: paymentsspec:podSelector:matchLabels: { app: frontend }policyTypes: [Egress]egress:- to:- podSelector:matchLabels: { app: api }ports:- { protocol: TCP, port: 8080 }
$ kubectl apply -f allow.yamlnetworkpolicy.networking.k8s.io/allow-dns creatednetworkpolicy.networking.k8s.io/api-allow-frontend creatednetworkpolicy.networking.k8s.io/frontend-egress-api created$ kubectl describe netpol api-allow-frontend -n paymentsName: api-allow-frontendNamespace: paymentsSpec:PodSelector: app=apiAllowing ingress traffic:To Port: 8080/TCPFrom:PodSelector: app=frontendNot affecting egress trafficPolicy Types: Ingress
One detail in that DNS policy quietly decides whether your rules do what you think. Look at the 'to' block: a namespaceSelector and a podSelector sit under the same list entry. Because they share one entry, they're ANDed. The rule matches pods labeled k8s-app=kube-dns that also live in kube-system. Pull them apart into two separate list entries and the meaning flips to OR: every pod in kube-system, plus any kube-dns pod inside payments itself, where there are none. That single indentation level is a favorite way to accidentally allow far more than you meant, and to end up with a second entry that matches nothing. A podSelector on its own only ever looks inside the policy's own namespace, so if you want to reach across namespaces you must add a namespaceSelector. The handy anchor is kubernetes.io/metadata.name, a label Kubernetes stamps on every namespace for you, so you can target prod or kube-system by name without labeling anything yourself. Rules follow labels, not IPs, and that's the whole point: pods get rescheduled and change addresses constantly, while a label-based policy keeps holding as the IPs churn underneath it.
Prove it actually blocks
Here's the part that bites people hardest. A NetworkPolicy is a spec, not an enforcer. The API server, the cluster's front desk where every change gets handed in, accepts your policy file and stores it whether or not anything acts on it. The thing that actually drops packets is your CNI, the network plugin wiring up pod networking (Container Network Interface). Calico, Cilium, and Antrea enforce policy. Plain Flannel does not, and some managed clusters ship with enforcement switched off until you flip it on. So on the wrong plugin, kubectl get proudly lists your default-deny while every packet still sails through. Never assume. Test it. Send traffic from a pod that should be blocked and confirm it hangs, then from one that's allowed and confirm it connects.
# allowed path: frontend is permitted to reach the api by name$ kubectl exec -n payments deploy/frontend -- nc -zv -w3 api 8080Connection to api (10.100.84.19) 8080 port [tcp/*] succeeded!# blocked path: a pod with no allow rule tries the same call$ kubectl run probe -n payments --image=nicolaka/netshoot \--labels app=debug --restart=Never --rm -i -- nc -zv -w3 api 8080nc: connect to api (10.100.84.19) port 8080 (tcp) timed out: Operation now in progresspod "probe" deleted
When a flow that should work doesn't, walk it in order. First, does the policy select the pods you think it does? Run kubectl describe on the policy, read the PodSelector line, then check the target pod actually carries that label with kubectl get pod --show-labels. Mislabeled pods are the number one cause: the policy is fine, it's just guarding the wrong door. Second, is DNS allowed? Exec into the pod and try resolving a name. Third, ask the CNI directly. On Cilium you can run hubble observe and watch verdicts flip from FORWARDED to DROPPED live, with the exact rule that decided. Seeing the real drop ends the guessing that eats an afternoon otherwise.
CNI support is required. Confirm enforcement with a deliberate deny test.
Selectors OR across peers and AND inside a peer. Misreading that grammar opens the namespace.
Cloud metadata at 169.254.169.254 is a classic miss. A default-deny egress already shuts it, but there is no deny rule, so the moment you allow broad egress you have to carve it back out with an ipBlock whose cidr is 0.0.0.0/0 and whose except lists 169.254.169.254/32.
Try this
Apply default-deny and a DNS allow in a lab namespace, prove a probe fails, then open a single consumer-to-provider path and prove it works.
# 1. a lab namespace with one server and one client$ kubectl create namespace netpol-labnamespace/netpol-lab created$ kubectl create deployment web --image=nginx -n netpol-labdeployment.apps/web created$ kubectl expose deployment web --port=80 -n netpol-labservice/web exposed$ kubectl run client -n netpol-lab --image=nicolaka/netshoot \--labels app=client -- sleep 3600pod/client created$ kubectl wait --for=condition=Ready pod --all -n netpol-lab --timeout=60spod/client condition metpod/web-5d47b76b8d-l9zqx condition met# 2. baseline on the flat network: the call just works$ kubectl exec -n netpol-lab client -- nc -zv -w3 web 80Connection to web (10.100.7.42) 80 port [tcp/http] succeeded!# 3. close the namespace in both directions$ kubectl apply -n netpol-lab -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: default-denyspec:podSelector: {}policyTypes: [Ingress, Egress]EOFnetworkpolicy.networking.k8s.io/default-deny created# 4. now the call dies at the name lookup, because egress to CoreDNS went with it$ kubectl exec -n netpol-lab client -- nc -zv -w3 web 80nc: getaddrinfo for host "web" port 80: Temporary failure in name resolutioncommand terminated with exit code 1# 5. hand DNS back, and nothing else$ kubectl apply -n netpol-lab -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: allow-dnsspec:podSelector: {}policyTypes: [Egress]egress:- to:- namespaceSelector:matchLabels:kubernetes.io/metadata.name: kube-systempodSelector:matchLabels:k8s-app: kube-dnsports:- { protocol: UDP, port: 53 }- { protocol: TCP, port: 53 }EOFnetworkpolicy.networking.k8s.io/allow-dns created# 6. the name resolves again, but the connection itself is still shut$ kubectl exec -n netpol-lab client -- nc -zv -w3 web 80nc: connect to web (10.100.7.42) port 80 (tcp) timed out: Operation now in progresscommand terminated with exit code 1# 7. open exactly one edge. default-deny selects both directions here,# so this flow needs the client's egress AND the server's ingress$ kubectl apply -n netpol-lab -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: client-egress-webspec:podSelector:matchLabels: { app: client }policyTypes: [Egress]egress:- to:- podSelector:matchLabels: { app: web }ports:- { protocol: TCP, port: 80 }---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: web-allow-clientspec:podSelector:matchLabels: { app: web }policyTypes: [Ingress]ingress:- from:- podSelector:matchLabels: { app: client }ports:- { protocol: TCP, port: 80 }EOFnetworkpolicy.networking.k8s.io/client-egress-web creatednetworkpolicy.networking.k8s.io/web-allow-client created# 8. the named edge is open, and a pod nobody listed still is not$ kubectl exec -n netpol-lab client -- nc -zv -w3 web 80Connection to web (10.100.7.42) 80 port [tcp/http] succeeded!$ kubectl run probe -n netpol-lab --image=nicolaka/netshoot \--labels app=debug --restart=Never --rm -i -- nc -zv -w3 web 80nc: connect to web (10.100.7.42) port 80 (tcp) timed out: Operation now in progresspod "probe" deleted# 9. tear the lab down$ kubectl delete namespace netpol-labnamespace "netpol-lab" deleted
Takeaway
NetworkPolicy flips selected pods to deny-by-default for that direction. Always keep DNS working, then allow only named edges.