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 kubectl configured 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 Ingress in policyTypes selects a pod, only the inbound traffic that some policy explicitly allows reaches it. The same applies to Egress.
  • 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.name is a label Kubernetes sets automatically on every namespace, so you do not need to label kube-system yourself.
  • CoreDNS pods carry the label k8s-app: kube-dns in kubeadm, k3s and most distributions. Check yours with kubectl 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:

NeedCalicoCilium
Cluster-wide default rulesGlobalNetworkPolicyCiliumClusterwideNetworkPolicy
Explicit deny rules and orderingYes (action: Deny, order)Yes (ingressDeny / egressDeny)
Allow egress by DNS nameYes (Calico Enterprise / Cloud)Yes (toFQDNs)
Layer 7 rules (HTTP paths, methods)LimitedYes
Flow visibilityFlow logsHubble (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). kubectl will not warn you.
  • bad address after 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 (usually 169.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.