NetworkPolicy for Cluster-Level Access Control
The first competency in the first domain, and the task most likely to appear in front of you: take a namespace that can reach everything and leave it able to reach one service. This guide does it in four steps and measures the cluster after each one, including the step where everything breaks.
Cluster Setup Guide 4 of 40 Intermediate
- Kubernetesapiserver v1.36.4, kubelet v1.36.3
- Runtimecontainerd 2.2.6
- CNICilium 1.18.1 - tunnel/VXLAN, with Hubble relay and UI
- Host OSUbuntu 26.04 LTS, kernel 7.0.0-29
- Built withkubeadm v1.36.3 - podSubnet 10.244.0.0/16, serviceSubnet 10.96.0.0/12
- TimeAbout 20 min
- Reviewed25 August 2026
Written against the versions above. Enforcement is the CNI's job - these clusters run Cilium 1.18.1. A NetworkPolicy on a cluster whose CNI ignores them is accepted by the API and does nothing at all.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| CKA6001 | 192.168.0.46 | Ubuntu 26.04 LTS | Control Plane Node (tainted NoSchedule) | 2 Core | 4 GB | 50 GB |
| CKA6001-NODE01 | 192.168.0.47 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
| CKA6001-NODE02 | 192.168.0.48 | Ubuntu 26.04 LTS | Worker Node | 2 Core | 4 GB | 50 GB |
Before you start
- guide 3 - the cluster and its CNI.
-
The baseline, before anything is restricted
A client in one namespace, a service in another, and no policy anywhere.
bash Example session kubectl create ns cks-app >/dev/null 2>&1; kubectl create ns cks-client >/dev/null 2>&1; kubectl -n cks-app run web --image=docker.io/library/busybox:1.37 --labels=app=web --restart=Never --command -- sh -c 'echo APP-BACKEND-OK > /tmp/index.html; httpd -f -h /tmp -p 8080' >/dev/null; kubectl -n cks-app expose pod web --port=8080 --name=web >/dev/null; kubectl -n cks-client run client --image=docker.io/library/busybox:1.37 --restart=Never --command -- sh -c 'sleep 900' >/dev/null; kubectl -n cks-app wait --for=condition=Ready pod/web --timeout=150s >/dev/null; kubectl -n cks-client wait --for=condition=Ready pod/client --timeout=150s >/dev/null; echo "--- both up, and nothing is restricting anything yet"--- both up, and nothing is restricting anything yetecho -n "DNS: "; kubectl -n cks-client exec client -- sh -c 'nslookup web.cks-app.svc.cluster.local 2>&1 | grep -E "^Address|timed out" | tail -1'; echo -n "HTTP: "; kubectl -n cks-client exec client -- sh -c 'if wget -O /tmp/o -T 6 http://web.cks-app.svc.cluster.local:8080/ 2>/tmp/e; then cat /tmp/o; else grep -m1 "wget:" /tmp/e; fi'; echo "--- the baseline: the name resolves to the Service ClusterIP, and the request is answered. Every step below is measured against these two lines"DNS: Address: 10.111.167.47HTTP: APP-BACKEND-OK--- the baseline: the name resolves to the Service ClusterIP, and the request is answered. Every step below is measured against these two linesExpected result
DNS: Address: 10.111.167.47andHTTP: APP-BACKEND-OK.Success conditionYou have two lines to measure every later step against.
-
Default-deny, and the thing it breaks first
The four-line policy that closes a namespace, and the immediate consequence.
bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n name: default-deny\n namespace: cks-client\nspec:\n podSelector: {}\n policyTypes: ["Ingress","Egress"]\n' | kubectl apply -f - 2>&1 | tail -1; sleep 8; echo -n "DNS: "; kubectl -n cks-client exec client -- sh -c 'nslookup web.cks-app.svc.cluster.local 2>&1 | grep -E "^Address|timed out" | tail -1'; echo "--- DNS is the first casualty of an egress deny, and it is the mistake everyone makes once"networkpolicy.networking.k8s.io/default-deny createdDNS: ;; connection timed out; no servers could be reached--- DNS is the first casualty of an egress deny, and it is the mistake everyone makes onceecho -n "HTTP straight to the Pod IP: "; W=$(kubectl -n cks-app get pod web -o jsonpath='{.status.podIP}'); kubectl -n cks-client exec client -- sh -c "if wget -O /tmp/o -T 6 http://$W:8080/ 2>/tmp/e; then cat /tmp/o; else grep -m1 'wget:' /tmp/e; fi"; echo "--- and the connection itself is denied too, so this is not only a name-resolution problem"HTTP straight to the Pod IP: wget: download timed out--- and the connection itself is denied too, so this is not only a name-resolution problemExpected result
DNS: ;; connection timed out; no servers could be reached, and the direct request to the Pod IP returnswget: download timed out.Success conditionYou know exactly what a closed namespace looks like from inside it.
-
Allow DNS, and only DNS
The rule every default-deny needs next.
bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n name: allow-dns\n namespace: cks-client\nspec:\n podSelector: {}\n policyTypes: ["Egress"]\n egress:\n - to:\n - namespaceSelector:\n matchLabels:\n kubernetes.io/metadata.name: kube-system\n podSelector:\n matchLabels:\n k8s-app: kube-dns\n ports:\n - {protocol: UDP, port: 53}\n - {protocol: TCP, port: 53}\n' | kubectl apply -f - 2>&1 | tail -1; sleep 8; echo -n "DNS: "; kubectl -n cks-client exec client -- sh -c 'nslookup web.cks-app.svc.cluster.local 2>&1 | grep -E "^Address|timed out" | tail -1'; echo -n "HTTP: "; kubectl -n cks-client exec client -- sh -c 'if wget -O /tmp/o -T 6 http://web.cks-app.svc.cluster.local:8080/ 2>/tmp/e; then cat /tmp/o; else grep -m1 "wget:" /tmp/e; fi'; echo "--- the name resolves again and the request still fails. Two separate permissions, and DNS is the one nobody remembers"networkpolicy.networking.k8s.io/allow-dns createdDNS: Address: 10.111.167.47HTTP: wget: download timed out--- the name resolves again and the request still fails. Two separate permissions, and DNS is the one nobody remembersExpected resultThe name resolves again - and
HTTP: wget: download timed outstill.Success conditionYou can restore name resolution without opening anything else.
-
Allow the one thing this client is for
One more rule, scoped by label and port.
bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n name: allow-web\n namespace: cks-client\nspec:\n podSelector: {}\n policyTypes: ["Egress"]\n egress:\n - to:\n - namespaceSelector:\n matchLabels:\n kubernetes.io/metadata.name: cks-app\n podSelector:\n matchLabels:\n app: web\n ports:\n - {protocol: TCP, port: 8080}\n' | kubectl apply -f - 2>&1 | tail -1; sleep 8; echo -n "HTTP: "; kubectl -n cks-client exec client -- sh -c 'if wget -O /tmp/o -T 6 http://web.cks-app.svc.cluster.local:8080/ 2>/tmp/e; then cat /tmp/o; else grep -m1 "wget:" /tmp/e; fi'; echo "--- allowed, by label, to exactly one workload on exactly one port"networkpolicy.networking.k8s.io/allow-web createdHTTP: APP-BACKEND-OK--- allowed, by label, to exactly one workload on exactly one portkubectl -n cks-client get networkpolicy --no-headers; echo "--- three objects, and the rules are ADDITIVE: a connection is allowed if ANY policy allows it, and denied if none does"allow-dns <none> 23sallow-web <none> 8sdefault-deny <none> 42s--- three objects, and the rules are ADDITIVE: a connection is allowed if ANY policy allows it, and denied if none doesExpected result
HTTP: APP-BACKEND-OK, and three policies in the namespace -allow-dns,allow-web,default-deny.Success conditionA namespace that can reach DNS and exactly one workload.
-
The ingress half, and the assumption it breaks
Protect the service itself, then test from two directions.
bash Example session printf 'apiVersion: networking.k8s.io/v1\nkind: NetworkPolicy\nmetadata:\n name: web-ingress-from-client-ns\n namespace: cks-app\nspec:\n podSelector:\n matchLabels:\n app: web\n policyTypes: ["Ingress"]\n ingress:\n - from:\n - namespaceSelector:\n matchLabels:\n kubernetes.io/metadata.name: cks-client\n ports:\n - {protocol: TCP, port: 8080}\n' | kubectl apply -f - 2>&1 | tail -1; sleep 8; echo -n "from cks-client: "; kubectl -n cks-client exec client -- sh -c 'if wget -O /tmp/o -T 6 http://web.cks-app.svc.cluster.local:8080/ 2>/tmp/e; then cat /tmp/o; else grep -m1 "wget:" /tmp/e; fi'networkpolicy.networking.k8s.io/web-ingress-from-client-ns createdfrom cks-client: APP-BACKEND-OKkubectl -n cks-app run nosy --image=docker.io/library/busybox:1.37 --restart=Never --command -- sh -c 'sleep 300' >/dev/null; kubectl -n cks-app wait --for=condition=Ready pod/nosy --timeout=150s >/dev/null; W=$(kubectl -n cks-app get pod web -o jsonpath='{.status.podIP}'); echo -n "from inside cks-app: "; kubectl -n cks-app exec nosy -- sh -c "if wget -O /tmp/o -T 6 http://$W:8080/ 2>/tmp/e; then cat /tmp/o; else grep -m1 'wget:' /tmp/e; fi"; echo "--- the client namespace is allowed and a Pod in the SAME namespace is not. An ingress rule does not treat local traffic as trusted"from inside cks-app: wget: download timed out--- the client namespace is allowed and a Pod in the SAME namespace is not. An ingress rule does not treat local traffic as trustedExpected result
from cks-client: APP-BACKEND-OKandfrom inside cks-app: wget: download timed out.Success conditionYou can restrict a workload without depending on its clients behaving.
Troubleshooting
Everything in a namespace broke after adding default-deny.
Why: Egress to CoreDNS was denied along with everything else.
Fix:Add the DNS rule immediately - UDP and TCP 53, namespace and pod selector combined.
A policy is created and traffic still flows.
Why: The CNI does not implement NetworkPolicy, or the selector matches nothing.
Fix:Check the CNI first, then
kubectl describe netpolfor the resolved selector.Meant to allow one Pod and allowed a whole namespace.
Why:
namespaceSelectorandpodSelectorwritten as two list entries, so they OR.Fix:One list entry containing both keys ANDs them.
A Pod in the same namespace cannot reach a protected service.
Why: Ingress rules do not exempt local traffic.
Fix:Expected. Add the local namespace explicitly if it should be allowed.
Connection refused rather than a timeout.
Why: Not a policy result - something answered.
Fix:Check the Service has endpoints. A policy drop always times out.