Network policies & default-deny

Segment the cluster without breaking DNS.

Advanced14 min · lesson 2 of 24

The Kubernetes network model is flat and permissive on purpose: every pod gets a routable IP, and by default any pod can open a connection to any other pod on any port, in any namespace, with no NAT in between. That is wonderful for development and a gift to an attacker — a single compromised pod can port-scan every service in the cluster, reach databases it never needed, and exfiltrate data to the internet, all without tripping anything. NetworkPolicy is how you take that back.

How a NetworkPolicy allows and blocks traffic
namespace: prod policy: default-deny ingress: from role=web egress :53 web pod role=web · matched reporting pod role=reporting · no rule public internet 0.0.0.0/0 · external CoreDNS kube-dns · UDP/TCP 53 payments app=payments policy target · deny-all + allow A pod is unrestricted until a policy selects it — then it is deny-all except the explicit allows.
Allowed — web ingress reaches payments Blocked — stopped at the default-deny boundary DNS — egress to CoreDNS on :53
A pod is unrestricted until a policy selects it — then it is deny-all except the rules you write.

A NetworkPolicy is a namespaced, label-selected firewall enforced by your CNI. The critical rule to internalize: a pod is unrestricted until some policy selects it, and the moment one does, that pod switches to deny-all for whichever directions (Ingress, Egress) the policy names — then only the rules you wrote are allowed back in. So segmentation always starts the same way, with a policy that selects every pod and permits nothing.

default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments
spec:
podSelector: {} # empty selector = every pod in the namespace
policyTypes: [Ingress, Egress] # naming a type with no rules = deny that direction
Default-deny egress breaks DNS first
The instant Egress is denied, pods can no longer reach CoreDNS and every hostname lookup fails with a timeout — the classic “it worked until I applied the policy” outage. DNS is UDP and TCP port 53 in kube-system. Always ship the DNS-allow policy in the same change as the default-deny, never after.
allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns-egress, namespace: payments }
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }

Per-consumer allowlists

With deny in place, you reopen exactly the paths that should exist and nothing else. Allow ingress to the payments API only from the web pods that call it, matched by label — never by IP, which changes on every reschedule. Ingress and egress are independent: allowing web-to-payments ingress does not let payments call back out, so a two-way path needs a rule on each side. The payoff is a policy set you can read like a diagram: who talks to whom, on what port, full stop.

allow-from-web.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: payments-allow-web, namespace: payments }
spec:
podSelector:
matchLabels: { app: payments-api }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: web } # same namespace
- namespaceSelector:
matchLabels: { team: storefront } # or from a whole trusted namespace
podSelector:
matchLabels: { app: web }
ports:
- { protocol: TCP, port: 8080 }

A subtle trap: a from entry that lists both a namespaceSelector and a podSelector as separate list items is an OR (either matches), while the two under a single item is an AND (a pod with that label in a namespace with that label). Getting that wrong is the difference between “web in the storefront namespace” and “anything in storefront, plus any pod labelled web anywhere.”

Prove it both ways

A NetworkPolicy that is never tested is a NetworkPolicy that does not work. Verify from an allowed pod (should connect) and from a disallowed one (should hang and time out, not refuse — deny is a silent drop). If traffic still flows from everywhere, your CNI may not enforce NetworkPolicy at all: Calico, Cilium, and Weave do, and some managed or default “bridge” CNIs silently ignore every policy you write.

terminal
# from an allowed pod — connects
$ kubectl -n payments exec deploy/web -- curl -sS -m 3 payments-api:8080/healthz
ok
# from a disallowed pod — hangs until the timeout, then fails (silent drop)
$ kubectl run probe --rm -it --image=nicolaka/netshoot -n payments -- \
curl -m 3 payments-api:8080
curl: (28) Connection timed out after 3001 ms