Picture this: an attacker pops a single pod in your cluster, maybe through a vulnerable image or a leaked token. From that one foothold, they can reach every database, every internal API, every secret-fetching sidecar you run. Nothing stops them, because by default nothing tries to.

Network Policies are the thing that stops them. They turn “one compromised pod” into “one compromised pod, and that’s it.” Everyone knows they should use them. Almost nobody actually does, because the YAML looks scary and the behaviour is weird until the mental model clicks.

I’ve burned hours debugging policies that “should work” and didn’t. So this post builds the model up one layer at a time, with a diagram for each step. Start at the top, stop whenever you have enough.

The Default: Everything Talks to Everything

Here’s the part that surprises people. Out of the box, Kubernetes lets every pod talk to every other pod, across every namespace. No restrictions at all.

flowchart TD
    subgraph cluster["Cluster"]
        Pod1["Pod"] <--> Pod2["Pod"]
        Pod2 <--> Pod3["Pod"]
        Pod3 <--> Pod4["Pod"]
        Pod1 <--> Pod3
        Pod1 <--> Pod4
        Pod2 <--> Pod4
    end
    note["Everyone can talk"]

Great for getting something running on day one. Terrible the moment you care about blast radius. Compromise one pod and the attacker reaches the whole cluster. That’s exactly the flat trust model zero trust tells us to avoid: assume breach, then make sure the breach can’t spread.

Network Policies: The Firewall for Pods

A NetworkPolicy describes how pods are allowed to communicate. It’s a firewall rule that targets groups of pods by their labels instead of by IP, which matters because pod IPs are ephemeral and you’d go insane managing them by hand.

Four concepts carry the whole feature:

  • Ingress: traffic coming in to a pod
  • Egress: traffic going out from a pod
  • Selectors: how you pick which pods a rule applies to (by labels)
  • Policy Types: whether you’re controlling ingress, egress, or both

That’s the vocabulary. Everything below is just combinations of these four.

The Most Important Rule: Default Deny

The first policy you apply to any namespace is default deny. It blocks all traffic, and then you open up exactly what you need. This is the simplest possible policy and also the most important one.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}  # Empty = all pods
  policyTypes:
    - Ingress
    - Egress
flowchart TD
    subgraph production["production namespace"]
        Pod1["Pod"] x--x Pod2["Pod"]
        Pod2 x--x Pod3["Pod"]
        Pod3 x--x Pod4["Pod"]
    end
    note["All traffic blocked by default"]

Now nothing communicates. That feels broken, and that’s the point. You’re starting from zero and building up, which is the only way to know what your apps actually need. Anything you forget to allow will fail loudly during testing instead of quietly leaving a hole open in production.

Allowing Ingress: Who Can Talk TO This Pod?

Say you have a frontend that needs to receive traffic. You select that pod by its label and spell out who’s allowed to reach it.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-ingress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: frontend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              name: ingress-nginx
        - podSelector:
            matchLabels:
              app: ingress-controller
      ports:
        - protocol: TCP
          port: 8080
flowchart LR
    subgraph ingress-nginx["ingress-nginx ns"]
        IC["ingress controller"]
    end
    subgraph production["production ns"]
        FE["frontend<br/>app:frontend"]
        Other["other pods"]
    end
    IC -->|"✓"| FE
    Other x--x|"✗"| FE

The frontend now accepts traffic from the ingress controller on port 8080, and nothing else gets in. Other pods in the namespace can knock all they want; the door stays shut.

Allowing Egress: What Can This Pod Talk TO?

Ingress controls who reaches a pod. Egress controls where a pod is allowed to reach. The frontend needs to call the backend API, so we open that path and nothing more.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-egress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: frontend
  policyTypes:
    - Egress
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: backend
      ports:
        - protocol: TCP
          port: 3000
    - to:  # Allow DNS
        - namespaceSelector: {}
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
flowchart TD
    subgraph production["production namespace"]
        FE["frontend"] -->|"✓"| BE["backend"]
        BE x--x|"✗"| DB["database"]
        FE -->|"✓ DNS only"| DNS["kube-dns"]
    end

The frontend can reach the backend on port 3000 and resolve DNS. It cannot touch the database directly, which is the whole idea. If someone compromises the frontend, they’re stuck talking to the backend and can’t jump straight to your data.

The Complete Three-Tier Example

Now the layers come together. A realistic web app: ingress to frontend, frontend to backend, backend to database. Each tier gets a policy, each policy allows only its immediate neighbour.

# Frontend policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-policy
spec:
  podSelector:
    matchLabels:
      tier: frontend
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              name: ingress
      ports:
        - port: 80
  egress:
    - to:
        - podSelector:
            matchLabels:
              tier: backend
      ports:
        - port: 8080
    - to:  # DNS
        - namespaceSelector: {}
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - port: 53
          protocol: UDP
---
# Backend policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-policy
spec:
  podSelector:
    matchLabels:
      tier: backend
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              tier: frontend
      ports:
        - port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              tier: database
      ports:
        - port: 5432
    - to:  # DNS
        - namespaceSelector: {}
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - port: 53
          protocol: UDP
---
# Database policy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: database-policy
spec:
  podSelector:
    matchLabels:
      tier: database
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              tier: backend
      ports:
        - port: 5432
  egress: []  # Database doesn't need to initiate connections
flowchart LR
    Internet --> Ingress
    Ingress --> Frontend
    Frontend --> Backend
    Backend --> Database
    Frontend -.->|"DNS only"| DNS["kube-dns"]
    Backend -.->|"DNS only"| DNS

Each tier talks only to the tier next to it. The frontend can’t reach the database. The database can’t reach the internet at all, which means even a full database compromise can’t exfiltrate data over an outbound connection. That’s least privilege expressed as a graph, and it’s the same single-step model from earlier, just repeated three times.

Common Gotchas

This is where my debugging hours went. None of these are obvious until they bite you.

Gotcha 1: Don’t Forget DNS

Your pods resolve service names through kube-dns. Apply a default-deny egress policy without allowing DNS and suddenly nothing resolves, which looks like a hundred different bugs before you realise it’s just blocked port 53.

egress:
  - to:
      - namespaceSelector: {}
        podSelector:
          matchLabels:
            k8s-app: kube-dns
    ports:
      - port: 53
        protocol: UDP

Without this, backend.production.svc.cluster.local never resolves and your app sits there timing out.

Gotcha 2: Policies Are Additive

Multiple policies on the same pod combine with OR logic. Policy A allows traffic from X, policy B allows traffic from Y, so the pod accepts both X and Y.

There’s no “deny” rule that overrides an “allow.” The only way to deny something is to never allow it in the first place. If you’re used to firewall systems with explicit deny rules and ordering, unlearn that here.

Gotcha 3: Empty Selector Means “All”

podSelector: {}  # All pods in this namespace
namespaceSelector: {}  # All namespaces

This one catches almost everyone. An empty selector matches everything, not nothing. It reads like “no pods” and behaves like “every pod,” which is exactly why default-deny uses podSelector: {} to grab the whole namespace.

Gotcha 4: Policies Only Affect Selected Pods

A NetworkPolicy only touches pods that match its podSelector. Any pod that no policy selects has zero restrictions and talks freely.

So you can write beautiful policies for half your namespace and leave the other half wide open without noticing. Default-deny fixes this by guaranteeing every pod is selected by at least one policy.

Testing Network Policies

Don’t trust a policy you haven’t tested. Spin up a throwaway pod and try to reach things that should and shouldn’t work.

# Create a test pod
kubectl run test-pod --image=busybox --rm -it -- sh

# Try to reach another service
wget -qO- --timeout=2 http://backend.production:8080/health

# Try to reach the database directly (should fail)
nc -zv database.production 5432

When you need more than busybox, netshoot ships with every networking tool you’d want:

kubectl run netshoot --image=nicolaka/netshoot -it --rm -- bash

The “should fail” tests matter more than the “should work” ones. It’s easy to confirm allowed traffic flows. The whole point is confirming the blocked paths are actually blocked.

CNI Requirements

One catch before you rely on any of this: Network Policies are enforced by your CNI, not by Kubernetes itself. If your CNI doesn’t implement the NetworkPolicy API, the cluster will happily accept your policies and ignore them entirely, which is a nasty way to discover you have no isolation.

  • Calico: Full support, plus its own extensions
  • Cilium: Full support, plus L7 policies
  • Weave: Full support
  • Flannel: No support (run Calico on top)

Check what you’re running before you assume a policy does anything. On my own clusters I run Cilium, partly for the L7 features below.

Beyond Basic Policies

Once the basics click, the CNI-specific extensions open up:

  • Cilium Network Policies: L7 (HTTP) filtering, DNS-based policies
  • Calico Network Policies: host protection, global policies
  • Service Mesh: Istio or Linkerd for mutual TLS and L7 policies

The vanilla Kubernetes NetworkPolicy API stays deliberately simple, which is a feature. It covers L3/L4, and when you genuinely need L7 you reach for a CNI that gives it to you. Don’t reach for it before you need it.

My Recommendation

Start small and build up, the same way this post did:

  1. Apply default-deny to every namespace
  2. Add policies that allow only the communication you actually need
  3. Test thoroughly, especially the paths that should fail, before production
  4. Use Cilium’s policy editor to visualise what you’re writing

Network Policies are one of the highest-value security improvements available in Kubernetes. No extra infrastructure to run, declarative so they live in Git like everything else, and they cut your blast radius down to almost nothing.

The mental model takes a few sessions of trial and error. Once it lands, you’ll feel a little uneasy running any cluster without it.


Network Policies put least privilege on the network. Every connection that doesn’t need to exist shouldn’t exist.