Kubernetes Networking & Storage

NetworkPolicies

18 min Lesson 5 of 31

NetworkPolicies

By default, every Pod in a Kubernetes cluster can reach every other Pod — across any namespace — on any port. This is the default-allow model: the network is flat and fully open. For a demo cluster it is fine; for a production cluster running multiple services, teams, or tenants, it is a serious security gap. A compromised frontend pod can freely probe your database. A noisy-neighbor bug in one namespace can hammer the endpoints in another.

NetworkPolicy is the Kubernetes primitive that lets you restrict this traffic. Think of it as a Pod-scoped firewall rule: it selects a set of Pods, and then defines which peers — by pod label or namespace — are allowed to reach them (ingress) or that they are allowed to reach (egress). The network plugin (CNI) enforces the rules in the data plane; kube-apiserver only stores the objects.

CNI support is mandatory. NetworkPolicy objects are only enforced if your CNI plugin implements them. Flannel (the default in many kubeadm installs) does not enforce NetworkPolicy. Cilium, Calico, and Weave all do. Always confirm your CNI before depending on NetworkPolicy for security.

From Default-Allow to Default-Deny

The shift from default-allow to default-deny is the most impactful security posture change you can make in a cluster. It mirrors the zero-trust principle: nothing is trusted by default; access must be explicitly granted.

A NetworkPolicy selects Pods via podSelector. The critical insight is this: a policy with an empty podSelector ({}) selects all Pods in the namespace. And a policy with no ingress or egress rules means no traffic is allowed on those directions. Combining both gives you a namespace-wide default-deny:

# deny-all.yaml — apply to every namespace you want isolated apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny-all namespace: payments # repeat per namespace spec: podSelector: {} # selects ALL pods in the namespace policyTypes: - Ingress - Egress

Apply this policy and every Pod in payments immediately stops receiving and sending traffic — including DNS lookups. Do not apply it to kube-system unless you fully understand the implications.

Ship deny-all first, then allow-lists. The operational pattern used at scale: apply default-deny-all to a namespace in staging, watch what breaks (check kubectl describe pod and CNI logs), then write allow policies for each legitimate flow. This surfaces undocumented dependencies you did not know existed.

Allowing Specific Traffic

After default-deny is in place, you re-open only what is needed. Three kinds of selectors are available in from / to blocks:

  • podSelector — matches pods in the same namespace by label.
  • namespaceSelector — matches all pods in namespaces whose labels match.
  • ipBlock — matches a CIDR range (useful for external services or on-prem networks).

Selectors inside the same list item are ANDed; separate list items are ORed. This is a common source of confusion.

A realistic allow policy for an api-server deployment that should only accept traffic from the frontend pod, and only on port 8080:

apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-frontend-to-api namespace: payments spec: podSelector: matchLabels: app: api-server # policy targets: pods with this label policyTypes: - Ingress ingress: - from: - podSelector: matchLabels: app: frontend # only pods with this label may connect ports: - protocol: TCP port: 8080

Notice that egress from api-server is not mentioned — because the default-deny-all policy already covers egress, we need a separate egress policy (e.g., to allow api-server to talk to postgres on port 5432 and to kube-dns on UDP 53).

Namespace Isolation with Cross-Namespace Selectors

Production clusters often run a monitoring namespace (Prometheus) that needs to scrape metrics from application pods in many other namespaces. You want to allow that specific cross-namespace path without opening up everything.

First, label the monitoring namespace:

kubectl label namespace monitoring kubernetes.io/metadata.name=monitoring

Then add an ingress rule that combines a namespaceSelector and a podSelector — both in the same from list item so they are ANDed (only Prometheus pods from the monitoring namespace, not any pod from monitoring and not Prometheus pods from any namespace):

apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-prometheus-scrape namespace: payments spec: podSelector: matchLabels: app: api-server policyTypes: - Ingress ingress: - from: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: monitoring podSelector: matchLabels: app: prometheus # AND: namespace AND pod label ports: - protocol: TCP port: 9090

Diagram: Namespace Isolation with NetworkPolicy

Namespace isolation via NetworkPolicy Namespace: payments (default-deny-all applied) frontend app=frontend api-server app=api-server postgres app=postgres NetworkPolicy: allow-frontend-to-api Namespace: monitoring prometheus app=prometheus rogue-pod app=attacker ALLOW :8080 ALLOW :5432 ALLOW :9090 (namespaceSelector+podSelector) DENY (no matching policy) Allowed flow Denied flow
Namespace isolation: default-deny-all in "payments" with explicit allow rules. A cross-namespace Prometheus scrape is permitted; a rogue pod in "monitoring" is blocked.

DNS: The Silent Casualty of Default-Deny

The most common mistake after applying default-deny is forgetting to allow egress to kube-dns (port 53, UDP and TCP). Every DNS lookup from a Pod goes through kube-dns in kube-system. When that is blocked, all service discovery breaks — Pods cannot resolve postgres.payments.svc.cluster.local, and the error message is a generic connection timeout, not a DNS error. Always add this egress policy alongside default-deny:

apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-dns-egress namespace: payments spec: podSelector: {} # all pods in namespace 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

Debugging NetworkPolicies

When traffic is unexpectedly blocked, the fastest path to root cause is:

  1. Run kubectl get networkpolicies -n <namespace> -o yaml to dump all policies and verify selectors match your pod labels exactly (a single typo means no rule applies).
  2. Use kubectl exec into a debug pod and run curl or nc -zv <target> <port> to confirm what is reachable.
  3. Check your CNI logs. Cilium exposes cilium-dbg monitor --type drop which shows the exact policy verdict on every dropped packet — indispensable for production investigations.
  4. Verify pod labels with kubectl get pod <name> -o jsonpath='{.metadata.labels}' — these are what the NetworkPolicy podSelector matches against.
NetworkPolicy is additive: multiple policies targeting the same Pod are unioned. There is no explicit deny action in a NetworkPolicy rule — only allow. The deny comes from the absence of any matching allow rule when a default-deny policy is in effect. This means you can safely add allow policies incrementally without risking accidental over-restriction.

Production Best Practices

  • Namespace-per-team isolation: Each team owns a namespace, and each namespace gets default-deny-all as a baseline. Cross-namespace traffic is explicitly documented in policy YAML and code-reviewed like application code.
  • Label governance matters: NetworkPolicy is only as reliable as your label discipline. Enforce label standards via admission webhooks (OPA/Gatekeeper) so pods cannot be deployed without the labels policies depend on.
  • Use Cilium Network Policy or Calico GlobalNetworkPolicy for cluster-wide defaults that apply across all namespaces — standard NetworkPolicy is namespace-scoped.
  • Test in CI: Tools like netassert or k8s-netpol-verify can run connectivity assertions against a cluster and fail a pipeline if a NetworkPolicy regression lets unexpected traffic through.