By default, every pod in a Kubernetes cluster can reach every other pod, in any namespace. A NetworkPolicy changes that: it selects a set of pods and lists exactly which inbound (ingress) and outbound (egress) connections they accept, acting as a firewall at the pod level. In this tutorial you will deploy a small frontend and backend in a test namespace, apply a default-deny policy, open only the paths the application needs, and test connectivity after every change so you can see each rule take effect.
Prerequisites
To follow this tutorial you need:
- A Kubernetes cluster (1.30 or newer) and
kubectlconfigured with admin access, for example from an Ubuntu 24.04 workstation or a CubePath VPS. - A network plugin (CNI) that enforces NetworkPolicy. Calico, Cilium and Canal do; k3s enforces policies with its built-in controller. Plain Flannel does not: the API accepts the objects, but no traffic is blocked.
Check which CNI your cluster runs:
kubectl get pods -n kube-system -o wide | grep -Ei 'calico|cilium|canal|flannel|kube-router'
If you only see Flannel pods, install a policy-capable CNI before continuing, or every test below will keep succeeding.
How NetworkPolicies are evaluated
Keep these rules in mind; most policy mistakes come from forgetting one of them:
- Pods not selected by any policy are non-isolated: all traffic is allowed.
- Once a policy with
IngressinpolicyTypesselects a pod, only the inbound traffic that some policy explicitly allows reaches it. The same applies toEgress. - Policies are additive. If several policies select a pod, traffic allowed by any of them is allowed. There are no deny rules in the standard API.
- Policies act on the pod IPs and ports behind a Service, so you write rules for the container port (for example 8080), not the Service port.
- Replies to an allowed connection are always permitted; you do not need a matching rule in the other direction.
Step 1 - Deploying a test application
Create a namespace and a backend running NGINX, exposed through a Service:
kubectl create namespace netpol-demo
kubectl create deployment backend --image=nginx:1.27 -n netpol-demo
kubectl expose deployment backend --port=80 -n netpol-demo
kubectl create deployment labels the pods app=backend, which is what the policies will select. Confirm the labels:
kubectl get pods -n netpol-demo --show-labels
NAME READY STATUS RESTARTS AGE LABELS
backend-6d8f7c9b8d-x2l4p 1/1 Running 0 20s app=backend,pod-template-hash=6d8f7c9b8d
Now test connectivity from two throwaway client pods: one labeled as the frontend, one with an unrelated label. Each runs wget with a 3 second timeout and is deleted when it exits:
kubectl run frontend --rm -i --restart=Never -n netpol-demo \
--image=busybox:1.36 --labels=app=frontend \
-- wget -qO- -T 3 http://backend | head -n 4
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
kubectl run intruder --rm -i --restart=Never -n netpol-demo \
--image=busybox:1.36 --labels=app=intruder \
-- wget -qO- -T 3 http://backend | head -n 4
Both succeed. That is the default, fully open behavior you are about to close.
Step 2 - Applying a default-deny policy
The safest pattern is to deny everything in the namespace first and then allow specific flows. An empty podSelector selects every pod in the namespace, and listing both types without any rules blocks all ingress and egress.
nano default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: netpol-demo
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
kubectl apply -f default-deny.yaml
Repeat the frontend test:
kubectl run frontend --rm -i --restart=Never -n netpol-demo \
--image=busybox:1.36 --labels=app=frontend \
-- wget -qO- -T 3 http://backend
wget: bad address 'backend'
pod netpol-demo/frontend terminated (Error)
The failure is bad address, not a timeout: egress is denied too, so the client cannot even reach the cluster DNS to resolve backend. You will fix that next.
Step 3 - Allowing DNS egress
Almost every pod needs DNS. Allow all pods in the namespace to send DNS queries, over UDP and TCP, only to the cluster DNS pods in kube-system:
nano allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: netpol-demo
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
Two details matter here:
kubernetes.io/metadata.nameis a label Kubernetes sets automatically on every namespace, so you do not need to labelkube-systemyourself.- CoreDNS pods carry the label
k8s-app: kube-dnsin kubeadm, k3s and most distributions. Check yours withkubectl get pods -n kube-system --show-labels | grep -i dns. CoreDNS listens on port 53 inside the pod.
kubectl apply -f allow-dns.yaml
Run the frontend test again:
kubectl run frontend --rm -i --restart=Never -n netpol-demo \
--image=busybox:1.36 --labels=app=frontend \
-- wget -qO- -T 3 http://backend
wget: download timed out
pod netpol-demo/frontend terminated (Error)
The name now resolves, but the HTTP connection is still blocked. That is expected: nothing allows traffic to the backend yet.
Step 4 - Allowing frontend to backend traffic
Traffic between two pods must be allowed on both ends when both are isolated: egress from the frontend and ingress to the backend. Create both rules in one file:
nano frontend-to-backend.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-allow-frontend
namespace: netpol-demo
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 80
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-allow-backend
namespace: netpol-demo
spec:
podSelector:
matchLabels:
app: frontend
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: backend
ports:
- protocol: TCP
port: 80
kubectl apply -f frontend-to-backend.yaml
Test from both clients:
kubectl run frontend --rm -i --restart=Never -n netpol-demo \
--image=busybox:1.36 --labels=app=frontend \
-- wget -qO- -T 3 http://backend | head -n 4
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
kubectl run intruder --rm -i --restart=Never -n netpol-demo \
--image=busybox:1.36 --labels=app=intruder \
-- wget -qO- -T 3 http://backend
wget: download timed out
pod netpol-demo/intruder terminated (Error)
Only pods labeled app=frontend can reach the backend, and only on TCP port 80. Review what is in place:
kubectl get networkpolicy -n netpol-demo
kubectl describe networkpolicy backend-allow-frontend -n netpol-demo
NAME POD-SELECTOR AGE
allow-dns-egress <none> 3m
backend-allow-frontend app=backend 40s
default-deny-all <none> 5m
frontend-allow-backend app=frontend 40s
Step 5 - Allowing traffic from other namespaces
Real backends often receive traffic from an ingress controller or a monitoring system in another namespace. Use a namespaceSelector for that. The following policy additionally allows any pod in a namespace named ingress-nginx to reach the backend on port 80:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-allow-ingress-controller
namespace: netpol-demo
spec:
podSelector:
matchLabels:
app: backend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 80
Replace ingress-nginx with the namespace your ingress controller or Gateway runs in.
AND versus OR in from and to
Indentation changes the meaning of a rule, and this is the most common NetworkPolicy bug. In this form, the namespace and pod selectors are in the same list item, so both must match (AND): only pods labeled app=prometheus in the monitoring namespace.
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
podSelector:
matchLabels:
app: prometheus
In this form they are two list items (note the second -), so either matches (OR): every pod in monitoring, plus any pod labeled app=prometheus in the policy's own namespace.
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: monitoring
- podSelector:
matchLabels:
app: prometheus
The DNS policy in Step 3 uses the AND form on purpose.
Step 6 - Controlling egress to the internet
With default deny in place, pods cannot reach anything outside the cluster. To let a workload call external HTTPS APIs while still blocking the cloud metadata address and private ranges, use an ipBlock with exceptions:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-allow-https-out
namespace: netpol-demo
spec:
podSelector:
matchLabels:
app: backend
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.169.254/32
ports:
- protocol: TCP
port: 443
ipBlock is meant for addresses outside the cluster. Do not rely on it to match pod IPs; use pod and namespace selectors for in-cluster traffic. The standard API cannot filter by domain name; for rules like "allow only api.example.com", you need CNI-specific policies such as Cilium's toFQDNs or Calico's domain-based rules.
Going further with Calico and Cilium
Calico and Cilium both enforce the standard networking.k8s.io/v1 NetworkPolicy used in this tutorial, and add their own policy resources for cases the standard API cannot express:
| Need | Calico | Cilium |
|---|---|---|
| Cluster-wide default rules | GlobalNetworkPolicy | CiliumClusterwideNetworkPolicy |
| Explicit deny rules and ordering | Yes (action: Deny, order) | Yes (ingressDeny / egressDeny) |
| Allow egress by DNS name | Yes (Calico Enterprise / Cloud) | Yes (toFQDNs) |
| Layer 7 rules (HTTP paths, methods) | Limited | Yes |
| Flow visibility | Flow logs | Hubble (hubble observe) |
Start with standard NetworkPolicies, which stay portable between CNIs, and use the extended resources only where you need them.
Cleaning up
Deleting the namespace removes the deployment, the service and all its policies:
kubectl delete namespace netpol-demo
Troubleshooting
- Policies are applied but nothing is blocked: your CNI does not enforce NetworkPolicy (plain Flannel).
kubectlwill not warn you. bad addressafter adding a deny policy: DNS egress is blocked. Apply the DNS policy from Step 3 and check the CoreDNS pod labels in your distribution. If your cluster uses NodeLocal DNSCache, also allow UDP and TCP 53 to its link-local address (usually169.254.20.10).- Traffic still blocked after adding an allow rule: check that both sides allow it (egress on the client, ingress on the server), that the port is the container port rather than the Service port, and that the labels match exactly with
kubectl get pods --show-labels. - Pods cannot reach the Kubernetes API: workloads that talk to the API server (operators, controllers) need an egress rule to the API server's address and port, typically TCP 6443 for kubeadm or k3s, or 443 on managed services.
Conclusion
You deployed a test application, isolated its namespace with a default-deny policy, reopened DNS, allowed only frontend to backend traffic, and saw each change confirmed with a real connection test. You also learned how selectors combine and how to control egress to the internet. Next, apply a default-deny and DNS policy to every application namespace, add policies for your ingress controller and monitoring stack, and explore Cilium Hubble or Calico flow logs to observe dropped traffic.
