NetworkPolicy is one of those Kubernetes features where the YAML looks obvious and the behaviour isn’t. Most production surprises come from three places: forgetting that a policy only affects the Pods it selects, mixing up which direction a rule controls, and a single missing dash that turns “Pods labelled X in namespace Y” into “any Pod in namespace Y, or any Pod labelled X”. This guide walks through each with a concrete three-tier app.
Examples assume Kubernetes 1.30 or later and a CNI plugin that enforces NetworkPolicy, such as Calico or Cilium. The API server happily stores policies even when nothing enforces them, which is the first thing to check if a policy seems to do nothing. Output shown is illustrative.
The model: selected Pods become allow-listed
Every NetworkPolicy has a podSelector that picks Pods in its own namespace. Those Pods are “isolated” for the directions listed in policyTypes. From then on, traffic in that direction is dropped unless some policy’s rule allows it. Pods that no policy selects stay fully open.
Three consequences follow, and each one is a common interview question:
- There is no deny rule. You only write allows. “Deny” is what happens to traffic that isn’t allowed once a Pod is isolated.
- Policies don’t have an order or priority. If two policies select the same Pod, the allowed traffic is the union of both. You can’t write a second policy to take something away.
- Ingress and egress are independent. A Pod can be isolated for ingress and wide open for egress, or the reverse. For a connection from Pod A to Pod B to work, A’s egress (if isolated) must allow it and B’s ingress (if isolated) must allow it.
Reply traffic for an allowed connection is permitted automatically. If web may connect to api on 8080, you don’t need an egress rule on api for the response packets; policies are about who can open a connection.
Default deny, then open what you need
Take a namespace shop with three tiers: web (labelled tier: web), api (tier: api) and postgres (tier: db). The usual pattern starts with a policy that isolates everything:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: shop
spec:
podSelector: {}
policyTypes: ["Ingress", "Egress"]No ingress or egress rules means nothing is allowed. Apply that and every Pod in shop goes dark, including for DNS lookups. That’s the trap with default-deny egress: your app’s first symptom is usually not “connection refused” but a DNS timeout like getaddrinfo EAI_AGAIN api or could not translate host name "postgres" to address. So the next policy most teams apply is a DNS allowance:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: shop
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: 53kubernetes.io/metadata.name is a label the API server sets on every namespace automatically, so you can select a namespace by name without labelling it yourself. k8s-app: kube-dns is the label CoreDNS Pods carry in most distributions, but check yours with kubectl get pods -n kube-system --show-labels. TCP 53 matters too: large responses fall back to TCP.
Now the application paths. Web may call API on 8080:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-from-web
namespace: shop
spec:
podSelector:
matchLabels:
tier: api
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels:
tier: web
ports:
- protocol: TCP
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: web-to-api
namespace: shop
spec:
podSelector:
matchLabels:
tier: web
policyTypes: ["Egress"]
egress:
- to:
- podSelector:
matchLabels:
tier: api
ports:
- protocol: TCP
port: 8080Both halves are needed because default-deny isolated both directions for both Pods. Leave out web-to-api and web’s outbound SYN never leaves; leave out api-from-web and it arrives and is dropped. The same pair is needed for API to Postgres on 5432.
One detail about port: it’s the port on the destination Pod, after any Service translation. If the api Service listens on 80 and forwards to targetPort: 8080, the policy says 8080. Policies see Pod-to-Pod traffic, not Service ports.
The AND/OR selector trap
This is the one that bites careful people. Compare these two from blocks for Postgres ingress, intended to allow only the API Pods in the shop namespace plus a monitoring agent:
# Version A: one entry, both selectors -> AND
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app: postgres-exporter
---
# Version B: two entries -> OR
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
- podSelector:
matchLabels:
app: postgres-exporterVersion A allows Pods labelled app: postgres-exporter that are in the monitoring namespace. Version B allows every Pod in monitoring, or any Pod labelled app: postgres-exporter in shop (a bare podSelector means the policy’s own namespace). The difference is one -. When you review a policy, read each list item under from or to as a separate allowed source, and the keys within one item as conditions that must all hold.
Traffic from outside the cluster
ipBlock allows CIDR ranges, typically for egress to a managed database or a partner API:
egress:
- to:
- ipBlock:
cidr: 10.20.0.0/16
except:
- 10.20.99.0/24
ports:
- protocol: TCP
port: 443Two caveats come up. First, ipBlock is meant for addresses outside the cluster; Pod IPs are ephemeral, so selecting Pods by IP is fragile. Second, for ingress from a load balancer, the source IP the Pod sees may be the node’s IP or the load balancer’s, depending on externalTrafficPolicy and the cloud. If you write an ipBlock for your office CIDR and it doesn’t work, SNAT is the likely reason.
NetworkPolicy also can’t match on hostnames. “Allow egress to api.stripe.com” isn’t expressible in the core API because the IPs behind the name change. Cilium and Calico offer FQDN-based policies as extensions; with plain NetworkPolicy you either allow the provider’s published ranges or route through an egress proxy.
Testing a policy instead of trusting it
Since a policy that isn’t enforced looks identical to one that is, test from a throwaway Pod. Give it the labels of the client you’re simulating:
$ kubectl run probe -n shop --rm -it --image=busybox:1.36 \
--labels=tier=web --restart=Never -- \
sh -c 'nc -zv -w 3 api 8080; nc -zv -w 3 postgres 5432'
api (10.96.14.2:8080) open
nc: postgres (10.96.80.7:5432): Connection timed outWeb reaching API and timing out on Postgres is what the design intends. Note it’s a timeout, not “connection refused”: dropped packets produce no reply. A refusal usually means the packet got through and nothing was listening, which points away from the policy.
Interview practice
“After someone adds a default-deny policy to the orders namespace, the orders-api Pods start failing readiness with dial tcp: lookup payments.billing.svc.cluster.local: i/o timeout. There’s already an ingress policy on billing allowing traffic from orders. What’s wrong and what do you add?”
Answer
The error is a DNS lookup timing out, so the failure happens before any connection to billing is attempted. The default-deny in orders almost certainly isolated egress as well as ingress, which blocks UDP and TCP 53 to CoreDNS in kube-system. I’d add an egress policy in orders allowing port 53 to the DNS Pods, selected with kubernetes.io/metadata.name: kube-system and the CoreDNS label in a single to entry so both conditions apply.
That fixes the lookup, but the connection to billing would still fail: the existing billing ingress policy only covers half the path. orders Pods are now egress-isolated too, so I’d add an egress rule from orders-api to Pods in billing (a namespaceSelector on kubernetes.io/metadata.name: billing plus a podSelector for the payments Pods, again in one entry) on the payments container port. Then I’d verify both with a labelled throwaway Pod, checking that the lookup resolves and the port opens.