Lesson 56 of 66 · 17 min read · Updated

Network Policies — Controlling Pod-to-Pod Traffic

The previous two lessons limited what a Pod may do to the API server and to its node. This lesson covers the third direction: what a Pod may do over the network.

By default, Kubernetes networking is completely flat. Every Pod can open a connection to every other Pod, in every namespace, on every port. The Services lessons relied on that: a ClusterIP Service just works, from anywhere. For security it is the worst possible starting point. If an attacker compromises your public-facing frontend, nothing stops the next request from going straight to your payments service, your database, or the metrics endpoint of every other team.

A NetworkPolicy is a firewall rule written in terms of labels instead of IP addresses. This lesson builds a small shop, closes every path, and opens exactly the ones the architecture needs.

We will cover the following topics:

  1. How NetworkPolicies select Pods and why they are additive allow-lists
  2. Who enforces them: the CNI plugin, and how to check that yours does
  3. Default-deny policies for ingress and egress
  4. Allowing traffic by podSelector, namespaceSelector, and both together
  5. The AND versus OR trap in the from and to lists
  6. The DNS exception every egress policy needs
  7. Why egress rules use the Pod's port, not the Service's port
  8. Allowing internet access with ipBlock while blocking private ranges and the node

Technical Requirements

  1. A Kubernetes cluster whose network plugin enforces NetworkPolicies. The outputs here come from Docker Desktop's Kubernetes with the kind provisioner (one control plane and three workers, Kubernetes v1.36). Its network plugin, kindnet, enforces NetworkPolicies, as Step 3 proves.
  2. kubectl. No application code is needed: the servers use the small traefik/whoami image and the clients use curlimages/curl.
  3. The Services lessons, for Service DNS names and ports.

Core Concepts

The Rules of the Game

A NetworkPolicy has three parts: which Pods it applies to, which directions it covers, and what it allows.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-api-allow-frontend
namespace: sec-shop
spec:
podSelector: # 1. the Pods this policy protects, in this namespace
matchLabels:
app: orders-api
policyTypes: # 2. the directions it covers
- Ingress
ingress: # 3. what is allowed in
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080

Four rules explain all NetworkPolicy behaviour:

  1. A Pod no policy selects is wide open. Policies only affect the Pods matched by their podSelector. An empty selector, podSelector: {}, matches every Pod in the namespace.
  2. Once any policy selects a Pod for a direction, that direction becomes deny-by-default for the Pod. Only traffic some policy allows gets through.
  3. Policies are additive. There are no deny rules. If any policy allows a connection, it is allowed. Adding a policy can only open more paths, never close one.
  4. Return traffic is automatic. Policies are stateful. If a request is allowed in, its response is allowed out, and the other way around.

Rule 3 surprises people who are used to firewalls with ordered allow and deny rules. With NetworkPolicies you build an allow-list and let the default deny everything else.

Ingress and Egress

DirectionControlsTypical use
IngressConnections into the selected PodsOnly the frontend may call the orders API
EgressConnections out of the selected PodsThe orders API may call only the payments API and the internet on port 443

A connection from Pod A to Pod B must pass both A's egress rules and B's ingress rules. With policies on both sides you have to open the path twice.

Selecting Peers

Inside from (for ingress) and to (for egress) you list peers. There are three kinds:

PeerMatches
podSelectorPods with these labels in the policy's own namespace
namespaceSelectorAll Pods in namespaces with these labels
namespaceSelector + podSelector in the same list itemPods with these labels in namespaces with these labels
ipBlockIP ranges, with optional exceptions. Meant for traffic leaving or entering the cluster

Every namespace carries the automatic label kubernetes.io/metadata.name: <name>, so you can select a namespace by name without labelling it yourself.

The AND versus OR Trap

The difference between these two from blocks is a single dash, and it changes the meaning completely:

# AND: Pods labelled app=metrics-scraper that are in the sec-monitoring namespace
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sec-monitoring
podSelector:
matchLabels:
app: metrics-scraper

# OR: every Pod in sec-monitoring, plus Pods labelled app=metrics-scraper in this namespace
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sec-monitoring
- podSelector:
matchLabels:
app: metrics-scraper

In the first form there is one list item with two selectors, and both must match. In the second there are two list items, and either may match. The second form is almost always a mistake, and it fails open. Step 6 demonstrates it.

Who Enforces NetworkPolicies

The API server only stores NetworkPolicy objects. The network plugin (CNI) enforces them, and not every plugin does. On a plugin without support, your policies are accepted, show up in kubectl get networkpolicy, and do nothing at all.

Network pluginEnforces NetworkPolicy
Calico, Cilium, AntreaYes, with extra features through their own policy CRDs
kindnet in current kind and Docker Desktop releasesYes
Flannel on its ownNo, it is usually paired with Calico (the "Canal" setup) for policies
Managed cloudsDepends on the options you choose: Azure CNI with Cilium or Calico on AKS, network policy enforcement on EKS and GKE

Never assume. Apply a default-deny policy in a test namespace and check that traffic really stops, as this lesson does.

Ports Are Evaluated After the Service

NetworkPolicies see Pod-to-Pod traffic after the Service has translated the Service IP and port into a Pod IP and targetPort. A Service on port 80 that forwards to a container on port 8080 must be allowed on 8080 in both ingress and egress rules. Writing the Service port is a common mistake, and the result is a silent timeout.

Hands-On: Kubernetes Commands

List the policies in a namespace

The POD-SELECTOR column shows which Pods each policy protects; <none> means all of them.

kubectl get networkpolicy -n sec-shop

Read a policy in plain language

describe turns the YAML into allow statements, which makes AND/OR mistakes easier to spot.

kubectl describe networkpolicy orders-api-allow-frontend -n sec-shop

Test a connection from a specific Pod

-m 3 sets a three-second limit, so a blocked connection fails fast. Exit code 28 is curl's timeout.

kubectl exec -n sec-shop deploy/frontend -- curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://orders-api.sec-shop.svc.cluster.local

Test DNS separately from HTTP

When egress is restricted, DNS is often what breaks first.

kubectl exec -n sec-shop deploy/frontend -- nslookup orders-api.sec-shop.svc.cluster.local

Find the labels a policy can match

Policies match labels, so check them on both the Pods and the namespaces.

kubectl get pods -n sec-shop --show-labels
kubectl get namespaces --show-labels

Find the DNS Pods and their labels

The egress DNS rule must match these labels in kube-system.

kubectl get pods -n kube-system -l k8s-app=kube-dns --show-labels

Step-by-Step Example

The Scenario

A small shop runs in the sec-shop namespace:

  1. frontend is the public-facing tier. It calls the orders API.
  2. orders-api calls the payments API, and it needs the internet on port 443 to reach an external service.
  3. payments-api must only be reachable from orders-api.

A monitoring team runs metrics-scraper in the sec-monitoring namespace, and it must reach both APIs.

The target architecture, as a list of allowed paths:

FromToPort
frontendorders-api8080
orders-apipayments-api8080
sec-monitoring / metrics-scraperany Pod in sec-shop8080
orders-apithe internet, but not private networks443
every Pod in sec-shopcluster DNS53

Everything else must be blocked.

Step 1 — Create the Namespaces and Workloads

apiVersion: v1
kind: Namespace
metadata:
name: sec-shop
---
apiVersion: v1
kind: Namespace
metadata:
name: sec-monitoring

The two APIs run traefik/whoami, a tiny HTTP server, on port 8080, each behind a Service on port 80. The clients run curl in a container that just sleeps. The orders-api Pod has a second toolbox container with curl, so you can test calls that originate from orders-api. Containers in a Pod share its network, so the toolbox's traffic carries the orders-api labels.

apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: sec-shop
spec:
replicas: 1
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: toolbox
image: curlimages/curl:8.17.0
command: ["sleep", "infinity"]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: sec-shop
spec:
replicas: 1
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: api
image: traefik/whoami:v1.11.0
args: ["--port", "8080"]
ports:
- containerPort: 8080
- name: toolbox
image: curlimages/curl:8.17.0
command: ["sleep", "infinity"]
---
apiVersion: v1
kind: Service
metadata:
name: orders-api
namespace: sec-shop
spec:
selector:
app: orders-api
ports:
- port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
namespace: sec-shop
spec:
replicas: 1
selector:
matchLabels:
app: payments-api
template:
metadata:
labels:
app: payments-api
spec:
containers:
- name: api
image: traefik/whoami:v1.11.0
args: ["--port", "8080"]
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: payments-api
namespace: sec-shop
spec:
selector:
app: payments-api
ports:
- port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: metrics-scraper
namespace: sec-monitoring
spec:
replicas: 1
selector:
matchLabels:
app: metrics-scraper
template:
metadata:
labels:
app: metrics-scraper
spec:
containers:
- name: toolbox
image: curlimages/curl:8.17.0
command: ["sleep", "infinity"]
kubectl apply -f 1-namespaces.yaml -f 2-workloads.yaml

Step 2 — A Script That Tests Every Path

Testing policies by hand gets tedious. This script calls both Services from all three clients and prints a matrix. Each probe is retried once before it counts as blocked, so a single slow DNS answer cannot fake a result.

#!/bin/sh
# Prints which client can reach which Service.
# OK = HTTP 200. BLOCKED = no answer within 3 seconds, twice in a row.
probe() { # namespace deployment container url
for attempt in 1 2; do
code=$(kubectl exec -n "$1" deploy/"$2" -c "$3" -- curl -s -o /dev/null -m 3 -w '%{http_code}' "$4" 2>/dev/null)
[ "$code" = "200" ] && { echo OK; return; }
done
echo BLOCKED
}
printf '%-32s %-12s %-12s\n' "FROM" "orders-api" "payments-api"
for c in "sec-shop frontend toolbox" "sec-shop orders-api toolbox" "sec-monitoring metrics-scraper toolbox"; do
set -- $c
printf '%-32s %-12s %-12s\n' "$1/$2" \
"$(probe $1 $2 $3 http://orders-api.sec-shop.svc.cluster.local)" \
"$(probe $1 $2 $3 http://payments-api.sec-shop.svc.cluster.local)"
done

Save it as check.sh, make it executable, and run it before any policy exists:

chmod +x check.sh
./check.sh
FROM orders-api payments-api
sec-shop/frontend OK OK
sec-shop/orders-api OK OK
sec-monitoring/metrics-scraper OK OK

This is the flat network. The frontend can call payments directly, and so can anything in any namespace.

Step 3 — Deny All Ingress

Start by closing every door into the namespace. The empty podSelector selects every Pod, and listing Ingress without any ingress rules allows nothing:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: sec-shop
spec:
podSelector: {}
policyTypes:
- Ingress
kubectl apply -f 3-default-deny-ingress.yaml
./check.sh
FROM orders-api payments-api
sec-shop/frontend BLOCKED BLOCKED
sec-shop/orders-api BLOCKED BLOCKED
sec-monitoring/metrics-scraper BLOCKED BLOCKED

Everything is blocked, which proves that this cluster's network plugin enforces policies. If your matrix still shows OK everywhere, your CNI ignores NetworkPolicies and nothing in the rest of this lesson will have any effect.

Note that Pods inside the same namespace are blocked too. A NetworkPolicy does not treat "same namespace" as trusted.

Step 4 — Allow Frontend to Orders

Open the first path. The port is 8080, the container port, not the Service's port 80:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-api-allow-frontend
namespace: sec-shop
spec:
podSelector:
matchLabels:
app: orders-api
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
kubectl apply -f 4-allow-frontend-to-orders.yaml
./check.sh
FROM orders-api payments-api
sec-shop/frontend OK BLOCKED
sec-shop/orders-api BLOCKED BLOCKED
sec-monitoring/metrics-scraper BLOCKED BLOCKED

Exactly one cell changed. describe shows the policy in plain words:

kubectl describe networkpolicy orders-api-allow-frontend -n sec-shop
Spec:
PodSelector: app=orders-api
Allowing ingress traffic:
To Port: 8080/TCP
From:
PodSelector: app=frontend
Not affecting egress traffic
Policy Types: Ingress

Step 5 — Allow Orders to Payments

The same pattern protects the payments API:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: payments-api-allow-orders
namespace: sec-shop
spec:
podSelector:
matchLabels:
app: payments-api
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: orders-api
ports:
- protocol: TCP
port: 8080
kubectl apply -f 5-allow-orders-to-payments.yaml
./check.sh
FROM orders-api payments-api
sec-shop/frontend OK BLOCKED
sec-shop/orders-api BLOCKED OK
sec-monitoring/metrics-scraper BLOCKED BLOCKED

The frontend can no longer skip a tier. Even if it is compromised, payments is one hop further away.

Step 6 — Allow the Monitoring Namespace, and Fall into the Trap

The monitoring team's scraper must reach every Pod in sec-shop. That requires a peer in another namespace, so you need a namespaceSelector. Write it correctly first, with both selectors in one list item:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-monitoring-namespace
namespace: sec-shop
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sec-monitoring
podSelector:
matchLabels:
app: metrics-scraper
ports:
- protocol: TCP
port: 8080
kubectl apply -f 6-allow-monitoring.yaml
./check.sh
FROM orders-api payments-api
sec-shop/frontend OK BLOCKED
sec-shop/orders-api BLOCKED OK
sec-monitoring/metrics-scraper OK OK

Now start some other tool in the monitoring namespace, one that should not have access:

kubectl run random-tool -n sec-monitoring --image=curlimages/curl:8.17.0 --labels=app=random-tool --command -- sleep infinity
kubectl exec -n sec-monitoring random-tool -- curl -s -o /dev/null -m 3 -w '%{http_code}\n' http://orders-api.sec-shop.svc.cluster.local
000
command terminated with exit code 28

Blocked, as intended. Now apply the version with one extra dash before podSelector, the OR form:

ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sec-monitoring
- podSelector:
matchLabels:
app: metrics-scraper
kubectl apply -f 6b-allow-monitoring-or.yaml
kubectl exec -n sec-monitoring random-tool -- curl -s -o /dev/null -m 3 -w '%{http_code}\n' http://orders-api.sec-shop.svc.cluster.local
200

The random tool is in. The policy now admits every Pod in sec-monitoring, plus any Pod labelled app=metrics-scraper in sec-shop. Nothing in kubectl apply warned you. Restore the correct version before going on:

kubectl apply -f 6-allow-monitoring.yaml

Step 7 — Deny All Egress, and Break DNS

Ingress control stops attackers from getting in. Egress control stops a compromised Pod from reaching out, whether to scan the rest of the cluster or to send data to the internet. Deny all egress in the namespace:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: sec-shop
spec:
podSelector: {}
policyTypes:
- Egress
kubectl apply -f 7-default-deny-egress.yaml
./check.sh
FROM orders-api payments-api
sec-shop/frontend BLOCKED BLOCKED
sec-shop/orders-api BLOCKED BLOCKED
sec-monitoring/metrics-scraper OK OK

The scraper still works: it lives in another namespace, and the egress policy applies only to Pods in sec-shop. The shop's own calls fail, and not only because of HTTP:

kubectl exec -n sec-shop deploy/frontend -- nslookup orders-api.sec-shop.svc.cluster.local
;; connection timed out; no servers could be reached

The frontend cannot even ask DNS where orders-api is. DNS is an egress connection to the CoreDNS Pods in kube-system, on port 53, and default-deny egress blocks it.

Step 8 — Allow DNS

Allow every Pod in the namespace to reach the cluster DNS Pods, over UDP and TCP, since DNS falls back to TCP for large answers:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: sec-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: 53
kubectl apply -f 8-allow-dns-egress.yaml
kubectl exec -n sec-shop deploy/frontend -- nslookup orders-api.sec-shop.svc.cluster.local
Name: orders-api.sec-shop.svc.cluster.local
Address: 10.96.120.164

Name resolution works again. Every egress default-deny you write should come with this policy. The CoreDNS Pods carry the label k8s-app: kube-dns in most distributions, but check yours with kubectl get pods -n kube-system --show-labels.

Step 9 — Allow the Application Paths, with the Right Port

The frontend needs egress to orders-api. Write it with the Service port first, as many people do:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-egress
namespace: sec-shop
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: orders-api
ports:
- protocol: TCP
port: 80
kubectl apply -f 9-egress-wrong-port.yaml
./check.sh
FROM orders-api payments-api
sec-shop/frontend BLOCKED BLOCKED
...

Still blocked. The frontend connects to the Service on port 80, but by the time the packet is checked it has been rewritten to the Pod's IP and port 8080. Fix the frontend's rule and add the orders-api egress rule at the same time:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-egress
namespace: sec-shop
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: orders-api
ports:
- protocol: TCP
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-api-egress
namespace: sec-shop
spec:
podSelector:
matchLabels:
app: orders-api
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: payments-api
ports:
- protocol: TCP
port: 8080
kubectl apply -f 10-egress-rules.yaml
./check.sh
FROM orders-api payments-api
sec-shop/frontend OK BLOCKED
sec-shop/orders-api BLOCKED OK
sec-monitoring/metrics-scraper OK OK

This matrix matches the architecture table exactly, now with both directions controlled.

Step 10 — Allow the Internet, but Not the Inside

orders-api must call an external service over HTTPS. Right now it cannot:

kubectl exec -n sec-shop deploy/orders-api -c toolbox -- curl -s -m 5 -o /dev/null -w '%{http_code}\n' https://api.nuget.org/v3/index.json
000
command terminated with exit code 28

External services have IP addresses you do not control, so use an ipBlock. Allow everything on port 443 except private ranges and the link-local range. That keeps the rule from opening a back door to other Pods, the nodes, or a cloud metadata endpoint at 169.254.169.254:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-api-egress-internet
namespace: sec-shop
spec:
podSelector:
matchLabels:
app: orders-api
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16
ports:
- protocol: TCP
port: 443
kubectl apply -f 11-orders-egress-internet.yaml
kubectl exec -n sec-shop deploy/orders-api -c toolbox -- curl -s -m 5 -o /dev/null -w '%{http_code}\n' https://api.nuget.org/v3/index.json
200

The internet works on 443. The node, at 172.18.0.5 on this cluster, is inside an excluded range, so the kubelet port stays closed, and the frontend, which has no internet rule, stays offline:

kubectl exec -n sec-shop deploy/orders-api -c toolbox -- curl -sk -m 3 -o /dev/null -w '%{http_code}\n' https://172.18.0.5:10250/healthz
000
command terminated with exit code 28

kubectl exec -n sec-shop deploy/frontend -- curl -s -m 3 -o /dev/null -w '%{http_code}\n' https://api.nuget.org/v3/index.json
000
command terminated with exit code 28

ipBlock is for addresses outside the cluster. Do not use it to match Pod IPs: Pods are replaced and get new addresses, which is exactly why labels exist. If you need to allow a fixed set of external hosts by name rather than by IP range, plain NetworkPolicy cannot do it. Cilium and Calico add DNS-based egress rules through their own policy types, and an egress gateway or proxy is the other common solution.

The namespace now has nine small policies:

kubectl get networkpolicy -n sec-shop
NAME POD-SELECTOR AGE
allow-dns-egress <none> 107s
allow-monitoring-namespace <none> 4m49s
default-deny-egress <none> 2m23s
default-deny-ingress <none> 5m30s
frontend-egress app=frontend 70s
orders-api-allow-frontend app=orders-api 5m14s
orders-api-egress app=orders-api 42s
orders-api-egress-internet app=orders-api 10s
payments-api-allow-orders app=payments-api 5m1s

Small policies with one purpose each are easier to review than one large policy, and because policies are additive, splitting them changes nothing about their combined effect.

Step 11 — Clean Up

kubectl delete namespace sec-shop sec-monitoring

Summary

  1. Kubernetes networking is flat by default: every Pod can reach every Pod. NetworkPolicies turn that into an allow-list.
  2. A Pod selected by any policy for a direction is deny-by-default in that direction. Policies are additive and have no deny rules; responses are allowed automatically.
  3. The network plugin enforces policies. Prove it with a default-deny test. kindnet, Calico and Cilium enforce; Flannel alone does not.
  4. Start every namespace with default-deny ingress and egress, then add one small allow policy per path.
  5. Every egress default-deny needs a DNS exception to k8s-app: kube-dns in kube-system on UDP and TCP 53.
  6. Rules match the Pod's port (targetPort), not the Service port.
  7. namespaceSelector and podSelector in one list item mean AND; as separate items they mean OR, which fails open.
  8. Use ipBlock with except for internet egress, excluding private ranges and 169.254.0.0/16.

In the next lesson you will enforce rules about the objects themselves, such as required labels or forbidden image tags, with ValidatingAdmissionPolicy.