Network policies

Default-deny and per-consumer allowlists.

Advanced12 min · lesson 51 of 65
In plain terms
By default every pod can talk to every other pod — one big open-plan office. A NetworkPolicy adds doors and a guest list so only approved conversations happen. Just remember to leave the phone line (DNS) open.

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.

default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: payments
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
Terminal
$ kubectl apply -f default-deny.yaml
networkpolicy.networking.k8s.io/default-deny created
$ kubectl get netpol -n payments
NAME POD-SELECTOR AGE
default-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.

allow.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: payments
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-allow-frontend
namespace: payments
spec:
podSelector:
matchLabels: { app: api }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: frontend }
ports:
- { protocol: TCP, port: 8080 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-egress-api
namespace: payments
spec:
podSelector:
matchLabels: { app: frontend }
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels: { app: api }
ports:
- { protocol: TCP, port: 8080 }
Terminal
$ kubectl apply -f allow.yaml
networkpolicy.networking.k8s.io/allow-dns created
networkpolicy.networking.k8s.io/api-allow-frontend created
networkpolicy.networking.k8s.io/frontend-egress-api created
$ kubectl describe netpol api-allow-frontend -n payments
Name: api-allow-frontend
Namespace: payments
Spec:
PodSelector: app=api
Allowing ingress traffic:
To Port: 8080/TCP
From:
PodSelector: app=frontend
Not affecting egress traffic
Policy 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.

Terminal
# allowed path: frontend is permitted to reach the api by name
$ kubectl exec -n payments deploy/frontend -- nc -zv -w3 api 8080
Connection 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 8080
nc: connect to api (10.100.84.19) port 8080 (tcp) timed out: Operation now in progress
pod "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.

A default-deny with no DNS allow takes the whole namespace down
This is the most common self-inflicted NetworkPolicy outage. You apply a default-deny egress, forget that pods reach CoreDNS on port 53 to resolve names, and every app in the namespace starts failing lookups at once. Nothing logs 'blocked by policy,' the packets just vanish, so it reads like DNS itself or the apps broke. Always pair a default-deny egress with an explicit allow to kube-dns on both UDP and TCP 53. And the quieter cousin of this trap: on a CNI that doesn't enforce policy, or a managed cluster with enforcement turned off, the exact same policy file is accepted and does absolutely nothing. Confirm a known-bad connection actually times out before you trust the wall.
How one packet gets decided
A packet arrives for a pod
Coming in as ingress, or leaving as egress. The verdict starts by asking one question about that pod, per direction.
No policy selects the pod
Allowed (allow-all)
An unselected pod keeps the flat-network default. Anything can reach it. This is the state you're trying to kill.
A policy selects it and an allow rule matches this peer and port
Allowed
Rules are additive. One match across all policies covering the pod is enough. There is no order to worry about.
A policy selects it but no allow rule matches
Dropped
There is no deny rule to write. Silence is the denial. The packet is discarded with no reset, so the client just hangs until it times out.
Selected for egress but DNS was never allowed
Dropped at name lookup
The pod can't even resolve a name, so everything it depends on looks down at once. This is the classic default-deny outage.
No policy selecting a pod means allow-all. The instant one does, that direction becomes deny-all except explicit allows, and there is no deny rule you can write.

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.

terminal
# 1. a lab namespace with one server and one client
$ kubectl create namespace netpol-lab
namespace/netpol-lab created
$ kubectl create deployment web --image=nginx -n netpol-lab
deployment.apps/web created
$ kubectl expose deployment web --port=80 -n netpol-lab
service/web exposed
$ kubectl run client -n netpol-lab --image=nicolaka/netshoot \
--labels app=client -- sleep 3600
pod/client created
$ kubectl wait --for=condition=Ready pod --all -n netpol-lab --timeout=60s
pod/client condition met
pod/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 80
Connection 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/v1
kind: NetworkPolicy
metadata:
name: default-deny
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
EOF
networkpolicy.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 80
nc: getaddrinfo for host "web" port 80: Temporary failure in name resolution
command terminated with exit code 1
# 5. hand DNS back, and nothing else
$ kubectl apply -n netpol-lab -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
EOF
networkpolicy.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 80
nc: connect to web (10.100.7.42) port 80 (tcp) timed out: Operation now in progress
command 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/v1
kind: NetworkPolicy
metadata:
name: client-egress-web
spec:
podSelector:
matchLabels: { app: client }
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels: { app: web }
ports:
- { protocol: TCP, port: 80 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: web-allow-client
spec:
podSelector:
matchLabels: { app: web }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: client }
ports:
- { protocol: TCP, port: 80 }
EOF
networkpolicy.networking.k8s.io/client-egress-web created
networkpolicy.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 80
Connection 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 80
nc: connect to web (10.100.7.42) port 80 (tcp) timed out: Operation now in progress
pod "probe" deleted
# 9. tear the lab down
$ kubectl delete namespace netpol-lab
namespace "netpol-lab" deleted

Takeaway

NetworkPolicy flips selected pods to deny-by-default for that direction. Always keep DNS working, then allow only named edges.

Quick check
01You want the api pods to accept traffic only from pods labeled role=frontend that live in the prod namespace. You write a single 'from' entry that holds both a namespaceSelector (matching prod) and a podSelector (matching role=frontend). What does this rule actually allow?
Correct — Two selectors in the same 'from' array element are combined with AND, so both must hold: the pod is in prod and it is labeled role=frontend. That is exactly the scoping you wanted.
Incorrect — Inside a single entry the selectors AND, not OR. Splitting them into two 'from' entries does give an OR, but not this one: a lone podSelector only ever matches inside the api's own namespace, so you would get any pod in prod plus any role=frontend pod alongside the api.
Incorrect — A podSelector alone means same-namespace, but adding a namespaceSelector in the same entry widens the match to the selected namespace (prod), not the local one. The namespaceSelector is not ignored.
Incorrect — They can, and combining them in one entry is precisely how you scope to specific pods in a specific namespace. This is the intended pattern.
02A pod is selected for ingress by three NetworkPolicies at once: one allows app=frontend, one allows app=metrics, and one has no ingress rules at all. How is an inbound connection decided?
Incorrect — NetworkPolicies have no ordering or priority, so there is no first-match.
Incorrect — there is no such thing as a deny rule; a policy with no rules simply adds no permissions.
Correct — the result is the union of every allow across all policies covering the pod, so one match is enough.
Incorrect — policies combine as a union, not an intersection, so a peer need not satisfy every policy.
03You apply a default-deny that selects every pod for both Ingress and Egress. Instantly every app in the namespace starts failing with name-resolution errors, though no rule you wrote mentions DNS. What happened, and what fixes it?
Incorrect — CoreDNS is fine; your egress rule is dropping the pods' packets to it, not breaking the DNS service.
Correct — name resolution is itself a network call to CoreDNS, so a blanket egress-deny cuts it, and the first allow you write is almost always DNS on both protocols.
Incorrect — DNS is ordinary pod-to-pod traffic on port 53 and is very much subject to egress policy.
Incorrect — the lookup is egress from the app pod to CoreDNS, and the reply rides that same allowed flow, so it is the egress rule that matters.

Related