Network Policies
The idea in one sentence
Section titled “The idea in one sentence”By default every Pod in a cluster can talk to every other Pod, and a NetworkPolicy is what lets you restrict that to only the traffic you explicitly allow.
The default: a flat, open network
Section titled “The default: a flat, open network”Out of the box, Kubernetes networking has no isolation at all. Any Pod can send traffic to any other Pod, regardless of namespace, and — depending on how Services are exposed — Pods can often be reached from outside the cluster too. There is no default-deny anywhere. This is simple to reason about but means a compromised or misconfigured Pod can reach far more of the cluster than it should ever need to.
NetworkPolicy: selecting Pods, then allowing traffic
Section titled “NetworkPolicy: selecting Pods, then allowing traffic”A NetworkPolicy (API group networking.k8s.io/v1) selects a set of Pods with a podSelector, then defines the ingress and/or egress rules allowed for exactly those Pods:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-frontend-to-api namespace: shopspec: podSelector: matchLabels: app: api policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: app: frontend ports: - protocol: TCP port: 8080This policy applies only to Pods labeled app: api in the shop namespace, and it allows inbound traffic only from Pods labeled app: frontend, only on TCP port 8080. Everything else to those Pods is implicitly denied once at least one policy selects them.
kubectl get networkpolicy -n shopkubectl describe networkpolicy allow-frontend-to-api -n shopNetworkPolicy only works if the CNI enforces it
Section titled “NetworkPolicy only works if the CNI enforces it”This is the single most important caveat: a NetworkPolicy object is inert unless the cluster’s CNI plugin actually implements NetworkPolicy enforcement. Kubernetes accepts and stores the object either way — kubectl apply will succeed — but if the CNI does not enforce policies, nothing actually changes about the traffic. Always confirm the installed CNI supports NetworkPolicy before relying on it for isolation.
The default-deny pattern
Section titled “The default-deny pattern”A common point of confusion: Pods that are not selected by any NetworkPolicy remain completely open (allow-all) for that traffic direction. Isolation is opt-in per Pod, not cluster-wide. To lock a namespace down, you deliberately create a policy that selects all Pods but allows nothing:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny-ingress namespace: shopspec: podSelector: {} policyTypes: - Ingress ingress: []podSelector: {} matches every Pod in the namespace, and an empty ingress: [] list allows nothing in. Once this exists, every Pod in shop is ingress-isolated by default, and you then add further NetworkPolicy objects to selectively allow the traffic you actually want — such as the allow-frontend-to-api example above.
Combining podSelector and namespaceSelector
Section titled “Combining podSelector and namespaceSelector”Putting both a podSelector and a namespaceSelector in the same from entry narrows the rule to specific Pods in specific namespaces, rather than “any Pod in this namespace” or “this label anywhere in the cluster”:
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-monitoring-scrape namespace: shopspec: podSelector: matchLabels: app: api policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: monitoring podSelector: matchLabels: app: prometheus ports: - protocol: TCP port: 9090That allows traffic only from Pods labeled app: prometheus that are running specifically inside the monitoring namespace — a precise, cross-namespace access rule.
Everything below was proven on this module’s own kind cluster (storage-net) with Calico installed (v3.31.5, via the Tigera operator) as a real enforcing CNI — chosen for its richer policy surface (AdminNetworkPolicy/BaselineAdminNetworkPolicy, below), not because kind’s own default CNI can’t enforce: kindnet has enforced NetworkPolicy since kind v0.24.0, proven directly at the end of this lesson.
Default-deny (ingress AND egress), proven with real timeouts
Section titled “Default-deny (ingress AND egress), proven with real timeouts”apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny-all namespace: netpol-demospec: podSelector: {} policyTypes: - Ingress - Egress$ kubectl exec -n netpol-demo client-a -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.local<!DOCTYPE html><html><head>...
$ kubectl apply -f default-deny-all.yamlnetworkpolicy.networking.k8s.io/default-deny-all created
$ kubectl exec -n netpol-demo client-a -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.localwget: bad address 'web.netpol-demo.svc.cluster.local'command terminated with exit code 1Before the policy, a real HTML response. After it, the request never even gets far enough to fail on the HTTP connection — bad address is a DNS resolution failure: with both Ingress and Egress denied, client-a’s own DNS query to CoreDNS (port 53 UDP) is blocked too, so the name never resolves at all. This is the single most common real-world default-deny outage, proven directly rather than just asserted.
policyTypes omitting Egress: egress stays wide open
Section titled “policyTypes omitting Egress: egress stays wide open”apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: ingress-only-example namespace: netpol-demospec: podSelector: matchLabels: app: web policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: app: client-a$ kubectl exec -n netpol-demo web-5dcf5c46bf-hjnh8 -- getent hosts kubernetes.default.svc.cluster.local10.96.0.1 kubernetes.default.svc.cluster.local kubernetes.default.svc.cluster.localWith only Ingress in policyTypes — even though this policy selects the same web Pod that was fully locked down a moment ago — web’s own outbound DNS lookup succeeds immediately, real proof that omitting Egress from policyTypes leaves that Pod’s egress completely unrestricted, regardless of what its ingress rules say.
DNS egress: the rule every default-deny needs
Section titled “DNS egress: the rule every default-deny needs”apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-dns-egress namespace: netpol-demospec: podSelector: {} policyTypes: - Egress egress: - to: - namespaceSelector: {} ports: - protocol: UDP port: 53 - protocol: TCP port: 53# with default-deny-all + allow-dns-egress both active:$ kubectl exec -n netpol-demo client-a -- nslookup kubernetes.default.svc.cluster.localServer: 10.96.0.10Address: 10.96.0.10:53
Name: kubernetes.default.svc.cluster.localAddress: 10.96.0.1
# but HTTP egress to web is still blocked -- no rule allows port 80$ kubectl exec -n netpol-demo client-a -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.localwget: download timed outcommand terminated with exit code 1Note the DNS lookup succeeds this time, but the HTTP request still times out (not bad address) — the name resolves fine now, but the actual TCP connection to port 80 is still denied, since allow-dns-egress only opened port 53. Both real failure modes, side by side: no DNS rule breaks name resolution itself; a DNS rule with no port-80 rule breaks the connection after a successful lookup.
namespaceSelector + podSelector: AND (one from entry) vs OR (two entries)
Section titled “namespaceSelector + podSelector: AND (one from entry) vs OR (two entries)”apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: and-selector namespace: netpol-demospec: podSelector: matchLabels: app: web policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: netpol-other podSelector: matchLabels: app: client-b# and-selector active. client-b lives in netpol-other, labeled app: client-b -- matches BOTH conditions in the one entry:$ kubectl exec -n netpol-other client-b -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.local<!DOCTYPE html><html><head>
# client-a lives in netpol-demo (not netpol-other) -- fails the namespaceSelector half of the AND:$ kubectl exec -n netpol-demo client-a -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.localwget: download timed outcommand terminated with exit code 1apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: or-selector namespace: netpol-demospec: podSelector: matchLabels: app: web policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: netpol-other - podSelector: matchLabels: app: client-b# or-selector active (only this policy, nothing else). A second client, in netpol-demo itself, labeled app: client-b:$ kubectl exec -n netpol-demo client-b-local -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.local<!DOCTYPE html><html><head>
# client-a (netpol-demo, labeled app: client-a) matches neither OR branch -- still blocked:$ kubectl exec -n netpol-demo client-a -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.localwget: download timed outcommand terminated with exit code 1Same two selector fields, two genuinely different real outcomes: one from entry combining namespaceSelector and podSelector is an AND (both conditions on the same source); two separate from entries, each with one selector, is an OR (either condition alone is enough). client-b-local — same namespace as web, no namespaceSelector match possible at all — is only ever reachable under the OR policy, proven directly above.
ipBlock
Section titled “ipBlock”apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: ipblock-allow namespace: netpol-demospec: podSelector: matchLabels: app: web policyTypes: - Ingress ingress: - from: - ipBlock: cidr: 10.244.0.0/16 except: - 10.244.1.0/24$ kubectl get pod client-a -n netpol-demo -o wideNAME IP NODEclient-a 10.244.147.21 storage-net-worker
$ kubectl exec -n netpol-demo client-a -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.local<!DOCTYPE html><html><head>client-a’s real Pod IP (10.244.147.21) falls inside 10.244.0.0/16 and outside the excluded 10.244.1.0/24, so it’s allowed — proven with a real successful request. ipBlock selects by raw CIDR, completely independent of labels; a Pod rescheduled to a different node (a different IP range) can silently fall in or out of an ipBlock rule with no label change at all, which is exactly its main fragility versus label-based selectors.
Enforcement depends on the CNI — proven on kindnet too
Section titled “Enforcement depends on the CNI — proven on kindnet too”A NetworkPolicy object is only as real as the CNI plugin enforcing it, and that is not a constant across every CNI, or even across versions of the same CNI — some silently accept the object and enforce nothing at all. The only way to know for a given cluster is a real connectivity test — never kubectl get networkpolicy succeeding, and never a CNI’s name alone.
kind’s own default CNI, kindnet (kindnetd), is the clearest illustration: earlier versions genuinely had no NetworkPolicy controller — just a minimal bridge + host-local IPAM CNI. That changed in kind v0.24.0 (2024-08-15), whose release notes state: “Out-of-the-box support for network policy via sigs.k8s.io/kube-network-policies” — kindnetd now bundles that controller. On the current shared cluster (kind 0.33, kindnetd:v20260820-69b56db7) this is real, proven directly rather than assumed from the version number, in a temporary namespace created for exactly this check and deleted immediately after:
$ kubectl exec -n kindnet-fix-check busybox -- wget -q -T 3 -O- http://10.244.2.7<!DOCTYPE html><html><head><title>Welcome to nginx!</title><style>...
$ kubectl apply -f - <<'EOF'apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny-ingressspec: podSelector: {} policyTypes: - IngressEOFnetworkpolicy.networking.k8s.io/default-deny-ingress created
$ kubectl exec -n kindnet-fix-check busybox -- wget -q -T 3 -O- http://10.244.2.7wget: download timed outcommand terminated with exit code 1A real HTTP response, then a real timeout after a default-deny podSelector: {} / policyTypes: [Ingress] policy — on kindnet, on this exact cluster, no Calico involved. A prior attempt at this same test used a Service DNS name with no matching Service and got bad address, a name-resolution failure that says nothing about policy enforcement either way; targeting the Pod IP directly removes that ambiguity.
The lesson here is still “always prove it,” with a version boundary attached: on a kind cluster older than v0.24.0 (or with kindnetd disabled and nothing substituted), this identical policy object would have changed nothing. Calico remains this lesson’s primary enforcing CNI for its richer policy surface — AdminNetworkPolicy/BaselineAdminNetworkPolicy (below) and a mature, dedicated policy engine — not because kindnet fails to enforce today.
AdminNetworkPolicy: docs-only overview
Section titled “AdminNetworkPolicy: docs-only overview”Installing Calico’s operator bundle (operator-crds.yaml, used to set up the enforcing CNI for this lesson) brought in the policy.networking.k8s.io AdminNetworkPolicy/BaselineAdminNetworkPolicy CRDs as a side effect — real and inspectable on this cluster (kubectl api-resources | grep networking.k8s.io lists them), but writing one is out of scope for this lesson’s depth. In one paragraph: AdminNetworkPolicy and BaselineAdminNetworkPolicy are cluster-admin-scoped policies (from the sig-network-policy-api subproject) that sit above and below namespaced NetworkPolicy in precedence respectively — an AdminNetworkPolicy rule can override what a namespace owner’s own NetworkPolicy would otherwise allow, and a BaselineAdminNetworkPolicy sets a default that ordinary NetworkPolicy objects can still override. Not every CNI implements them yet; see the Kubernetes AdminNetworkPolicy docs for current implementation status.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated NetworkPolicy manifests, check for:
- A “default-deny” policy with
policyTypes: [Ingress]only, comment claiming it blocks all traffic — proven above, egress stays wide open. - A default-deny policy with no accompanying DNS egress rule — proven above, this breaks every in-cluster DNS lookup from every selected Pod (a
bad addressfailure, not a connection timeout), a very common self-inflicted outage. - NetworkPolicy manifests applied and “verified working” only by
kubectl get networkpolicysucceeding — proven above (CNI enforcement section), the object can exist and be accepted while enforcing nothing at all on some CNIs or older CNI versions; enforcement depends entirely on the CNI, and only a real connectivity test proves it either way. - A
fromlist assumed to be an OR-of-ANDs when the author actually wrote one entry combiningnamespaceSelector+podSelector(AND) vs meaning two separate criteria (which needs two separatefromentries) — proven above these produce genuinely different allowed traffic.
🐛 Spot the bug
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:name: lockdown-shopnamespace: shopspec:podSelector: {}policyTypes: - Ingress - Egressingress: - from: - podSelector: matchLabels: app: frontendegress: - to: - podSelector: matchLabels: app: databaseShow the answer
This default-deny-style policy selects every Pod in shop for both Ingress and Egress, but its egress rule only allows traffic to Pods labeled app: database — proven above with a real bad address failure in an identical situation, this breaks DNS for every Pod in the namespace, since nothing allows egress to CoreDNS on port 53. Every Pod in shop, including frontend and database themselves, will fail to resolve any DNS name at all — Services they depend on by name become completely unreachable, with a resolution failure that looks nothing like a permissions problem at first glance. The fix: add an egress rule allowing UDP/TCP port 53 to kube-system (or, as proven above, to namespaceSelector: {} for simplicity) alongside the database rule.
Mental Model
Section titled “Mental Model”A NetworkPolicy is a request handed to the CNI, not a control Kubernetes itself enforces — kubectl apply succeeding only proves the object is stored, never that traffic changed, proven both ways above: the identical policy object genuinely blocks traffic on today’s kindnet, and did nothing at all on kindnet before kind v0.24.0. Within one policy’s ingress/egress rules, each from/to list entry is its own OR-branch, and multiple selector fields inside one entry are ANDed together (proven above with two side-by-side real policies) — that’s the one syntax fact this whole lesson’s depth hangs on.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
podSelector: {} + empty ingress: []/egress: [] (default-deny) | Explicit, auditable baseline — nothing gets in/out unless a rule says so | Every legitimate flow, including DNS, needs its own rule or the workload breaks — proven above with a real DNS failure |
One from entry, two selectors (AND) | Precise: this label AND this namespace, proven above | More restrictive than authors sometimes intend — easy to write when “OR” was meant |
Two separate from entries (OR) | Broader, matches “any of these sources,” proven above | Easy to accidentally over-permit if one entry is broader than intended |
ipBlock | Allow/deny by raw CIDR, useful for external or legacy IP-based systems | Pod IPs are not stable identities — proven above, an ipBlock tied to Pod IPs directly is fragile versus label selectors |
Common Misconceptions
Section titled “Common Misconceptions”- “
policyTypes: [Ingress]alone means egress is now restricted too, since the policy is ‘about’ this Pod.” Proven above — egress stays completely open unlessEgressis explicitly listed inpolicyTypeswith its own rules. - “A default-deny policy only blocks ‘bad’ traffic, DNS still works because it’s infrastructure.” Proven above with a real
bad addressfailure — DNS (port 53) is just another egress destination and is blocked like anything else without an explicit allow rule. - “If
kubectl applyaccepts a NetworkPolicy, it’s enforced.” Proven above both ways — enforcement is entirely up to the CNI and its version: kindnet enforced nothing before kind v0.24.0, enforces for real since (proven directly above), and any CNI still needs a real connectivity test to confirm it, never just a successfulapply. - “
namespaceSelectorandpodSelectortogether in afromentry is the same as listing them as two entries.” Proven above with two real, contrasting policies — one entry ANDs the conditions (this pod-label AND this namespace), two entries ORs them (either condition).
flowchart LR
subgraph before["Before: default flat network"]
a1["Pod A"] --- a2["Pod B"]
a2 --- a3["Pod C"]
a1 --- a3
end
subgraph after["After: NetworkPolicy applied"]
b1["frontend"] -->|"allowed"| b2["api"]
b3["random Pod"] -.->|"blocked"| b2
end