Skip to content

Network Policies

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.

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/v1
kind: NetworkPolicy
metadata:
name: allow-frontend-to-api
namespace: shop
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080

This 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.

Terminal window
kubectl get networkpolicy -n shop
kubectl describe networkpolicy allow-frontend-to-api -n shop

NetworkPolicy 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.

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/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: shop
spec:
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/v1
kind: NetworkPolicy
metadata:
name: allow-monitoring-scrape
namespace: shop
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app: prometheus
ports:
- protocol: TCP
port: 9090

That 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”
k8s/network-policies/default-deny-all.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: netpol-demo
spec:
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.yaml
networkpolicy.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.local
wget: bad address 'web.netpol-demo.svc.cluster.local'
command terminated with exit code 1

Before 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”
k8s/network-policies/ingress-only-example.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ingress-only-example
namespace: netpol-demo
spec:
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.local
10.96.0.1 kubernetes.default.svc.cluster.local kubernetes.default.svc.cluster.local

With 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”
k8s/network-policies/allow-dns-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: netpol-demo
spec:
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.local
Server: 10.96.0.10
Address: 10.96.0.10:53
Name: kubernetes.default.svc.cluster.local
Address: 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.local
wget: download timed out
command terminated with exit code 1

Note 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)”
k8s/network-policies/and-selector.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: and-selector
namespace: netpol-demo
spec:
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.local
wget: download timed out
command terminated with exit code 1
k8s/network-policies/or-selector.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: or-selector
namespace: netpol-demo
spec:
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.local
wget: download timed out
command terminated with exit code 1

Same 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.

k8s/network-policies/ipblock-allow.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ipblock-allow
namespace: netpol-demo
spec:
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 wide
NAME IP NODE
client-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/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
spec:
podSelector: {}
policyTypes:
- Ingress
EOF
networkpolicy.networking.k8s.io/default-deny-ingress created
$ kubectl exec -n kindnet-fix-check busybox -- wget -q -T 3 -O- http://10.244.2.7
wget: download timed out
command terminated with exit code 1

A 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.

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.

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 address failure, not a connection timeout), a very common self-inflicted outage.
  • NetworkPolicy manifests applied and “verified working” only by kubectl get networkpolicy succeeding — 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 from list assumed to be an OR-of-ANDs when the author actually wrote one entry combining namespaceSelector + podSelector (AND) vs meaning two separate criteria (which needs two separate from entries) — proven above these produce genuinely different allowed traffic.

🐛 Spot the bug

k8s/network-policies/lockdown-shop.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: lockdown-shop
namespace: shop
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
egress:
- to:
- podSelector:
matchLabels:
app: database
Show 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.

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.

ChoiceBenefitCost
podSelector: {} + empty ingress: []/egress: [] (default-deny)Explicit, auditable baseline — nothing gets in/out unless a rule says soEvery 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 aboveMore restrictive than authors sometimes intend — easy to write when “OR” was meant
Two separate from entries (OR)Broader, matches “any of these sources,” proven aboveEasy to accidentally over-permit if one entry is broader than intended
ipBlockAllow/deny by raw CIDR, useful for external or legacy IP-based systemsPod IPs are not stable identities — proven above, an ipBlock tied to Pod IPs directly is fragile versus label selectors
  • “policyTypes: [Ingress] alone means egress is now restricted too, since the policy is ‘about’ this Pod.” Proven above — egress stays completely open unless Egress is explicitly listed in policyTypes with its own rules.
  • “A default-deny policy only blocks ‘bad’ traffic, DNS still works because it’s infrastructure.” Proven above with a real bad address failure — DNS (port 53) is just another egress destination and is blocked like anything else without an explicit allow rule.
  • “If kubectl apply accepts 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 successful apply.
  • “namespaceSelector and podSelector together in a from entry 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
Default flat all-to-all Pod network versus after a NetworkPolicy restricts allowed paths
With no NetworkPolicy objects created at all, how do Pods in a cluster communicate by default
What is required for a NetworkPolicy object to actually restrict traffic
How do you express deny-all-ingress for a set of Pods with NetworkPolicy
What does combining namespaceSelector and podSelector in a single NetworkPolicy rule achieve
After a default-deny (Ingress+Egress) policy is applied, the real failure is bad address, not a connection timeout. What does that specific error prove?
$ kubectl exec client-a -- wget -q -T 3 -O- http://web...
<!DOCTYPE html>
...
$ kubectl apply -f default-deny-all.yaml
networkpolicy.../default-deny-all created
$ kubectl exec client-a -- wget -q -T 3 -O- http://web...
wget: bad address 'web.netpol-demo.svc.cluster.local'
This succeeded on a Pod selected by a NetworkPolicy with policyTypes: [Ingress] only (no Egress). What does this prove?
$ kubectl exec web -- getent hosts kubernetes.default.svc.cluster.local
10.96.0.1  kubernetes.default.svc.cluster.local