#DevOps

Kubernetes Network Policies: A Firewall Between Pods

K8s allows all Pods to communicate by default, which is a production security risk. NetworkPolicy lets you declare which Pods may communicate. This article explains ingress and egress rules, default-deny mode, and zero-trust K8s networking practices.

Key Points

  • Topic Focus: DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Kubernetes Network Policies: A Firewall Between Pods

Here is an incident I witnessed firsthand. A payment service was running on K8s when an internal reporting script in the same cluster accidentally took it down. The script scanned every *:5432 port while collecting data, happened to hit the payment service's auxiliary database, exhausted its connection pool, and caused the payment API to return 502 errors for ten minutes. The first question in the postmortem was: "They weren't even in the same namespace. Why could it still connect?"

This is one of the most common misconceptions among K8s newcomers: a namespace is not a network boundary, and Pods can communicate with one another by default. Any Pod that knows an IP address can curl any port on any other Pod in the cluster. A compromised Pod effectively receives a pass to the internal network.

The tool for reducing the network to the "minimum necessary connectivity" is NetworkPolicy. This article explains its rule model, four matching dimensions, and the default-deny approach to zero trust. It also walks through a complete policy for a frontend → backend:8080 → DB:5432 call chain. By the end, you will understand why writing YAML is not enough and why you must choose the right CNI.

1. The default K8s network model: unrestricted Pod connectivity is a liability

Start with the right mental model. The K8s network model has a well-known set of "four rules." The most important is:

K8s does not isolate port conflicts. Any Pod can use any protocol to access any port on any other Pod, without NAT getting in the way.

In practice, K8s assigns every Pod a flat, globally unique IP address within the cluster, usually from an RFC 1918 private range. All Pods share the same L2/L3 overlay. kube-proxy handles only "Service-to-Pod" load balancing, not "Pod-to-Pod" access control.

text
        ┌────────────────────────────────────────────────────────────────┐
        │       Flat K8s cluster network (10.244.0.0/16)                 │
        │                                                                │
        │   podA ──→ podB    podC ──→ podD                               │
        │    10.0.1      10.0.2    10.0.3   10.0.4                      │
        │                                                                │
        │   Default: all Pods can reach all others, on any port.         │
        │   No controls.                                                 │
        └────────────────────────────────────────────────────────────────┘

This has several dangerous implications:

  • A compromised Pod, such as a web service with an RCE vulnerability, can scan the internal network and connect directly to databases or etcd if its mTLS is not configured correctly
  • A buggy script can exhaust the connection pool of any service
  • "I have three namespaces, dev/staging/prod, so they are isolated" - they are not. A Pod in dev can connect directly to the production database
  • Lateral movement encounters almost no resistance

A namespace is only a logical grouping. It is useful with RBAC and resource quotas, but it is not network isolation. Putting prod and dev together and expecting their networks to remain disconnected is as naive as placing different signs on two rooms and expecting them to become soundproof.

2. What is NetworkPolicy? A declarative Pod firewall

NetworkPolicy is a built-in K8s API (networking.k8s.io/v1) that lets you declare which inbound (ingress) and outbound (egress) traffic is allowed for a Pod. It is important to understand that this is only a declaration. K8s does not enforce it directly. Instead, it sends the declaration to the CNI plugin, discussed in Section 6, which translates it into iptables or eBPF rules in the kernel.

Consider the simplest example: allow a backend Pod to accept connections only from frontend Pods on port 8080.

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-allow-frontend
  namespace: prod
spec:
  podSelector:
    matchLabels:
      app: backend           # Which Pods this policy applies to (the protected side)
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend  # Who is allowed in (the source)
      ports:
        - protocol: TCP
          port: 8080          # Allow only this port

These five fields contain the essence of NetworkPolicy:

FieldMeaning
spec.podSelectorTarget: which Pods this policy protects. An empty {} selects every Pod in the namespace
spec.policyTypesWhich directions it governs: Ingress, Egress, or both
spec.ingress[].fromIngress rules: allowed sources, such as Pods, namespaces, or IP ranges. Multiple rules use OR semantics
spec.egress[].toEgress rules: allowed destinations
spec.ingress[].ports / spec.egress[].portsAllowed ports. Omission means all ports

The isolation semantics are the easiest part of NetworkPolicy to get wrong. Remember this rule:

Once any NetworkPolicy selects a Pod through podSelector, and that policy's policyTypes includes a direction, the Pod becomes "isolated" in that direction. After isolation, only traffic allowed by the union of all policies selecting that Pod may pass.

In other words, isolation does not mean "one policy adds one restriction." It means "one policy activates whitelist mode." Once activated, the default is deny, and only sources or destinations explicitly listed under from or to are allowed.

This leads to two common patterns, corresponding to two engineering approaches:

  • Targeted policies: write fine-grained rules for specific Pods, as in the example above
  • Default-deny policies: write one policy that denies everything in a namespace, then add allow rules as needed, as shown in Section 4. This is the foundation of zero trust

3. Four matching dimensions: podSelector / namespaceSelector / ipBlock / ports

from and to can contain four types of selectors. Combining them determines the precision of a rule. This section examines them individually, including their most common pitfalls.

1. podSelector: select Pods by label

yaml
from:
  - podSelector:
      matchLabels:
        app: frontend

Pitfall: A standalone podSelector, without a namespaceSelector, matches Pods only in the namespace containing the policy. Cross-namespace matching requires a namespaceSelector.

2. namespaceSelector: select an entire namespace by namespace label

yaml
from:
  - namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: ingress-nginx

This means "allow all Pods in the ingress-nginx namespace". A typical use case is allowing an Ingress Controller.

Pitfall: K8s 1.21+ automatically assigns every namespace the label kubernetes.io/metadata.name=<name>. This is the only stable way to select a namespace by name. Do not write name: xxx yourself; a namespace's name is not a label.

3. namespaceSelector + podSelector: select specific Pods across namespaces with AND semantics

yaml
from:
  - namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: prod
    podSelector:
      matchLabels:
        app: frontend

The most important semantic detail: when namespaceSelector and podSelector appear in the same list element, they use AND semantics, forming an intersection: "Pods with the label app=frontend in the prod namespace." If they are written as two list elements instead:

yaml
from:
  - namespaceSelector:        # Element 1: all Pods in prod
      matchLabels:
        kubernetes.io/metadata.name: prod
  - podSelector:              # Element 2: Pods with app=frontend in this namespace
      matchLabels:
        app: frontend

The result uses OR semantics and forms a union. Moving the indentation by one line completely changes the meaning. This is the most common NetworkPolicy failure mode.

4. ipBlock: select IP addresses by CIDR

yaml
to:
  - ipBlock:
      cidr: 10.0.0.0/8
      except:
        - 10.0.5.0/24        # Exclude this range

Use this to allow external services, such as the IP range of a managed database, or clients outside the cluster. Pitfall: ipBlock uses a Pod's actual IP, not a Service IP. A Service IP, or ClusterIP, is virtual and will not match the rule. Allowing "access to a Service" therefore means allowing access to the Pods behind it. Using ipBlock is the wrong approach; select the Pods by label instead.

5. ports: ports and protocols

yaml
ports:
  - protocol: TCP
    port: 5432
  - protocol: UDP
    port: 53

port can be numeric or a named port, such as port: http, corresponding to a name under the container's ports. Omitting the ports field allows all ports. This is the second major failure mode: the policy may look strict even though it contains no port restriction.

4. Default-deny mode: deny-all plus allow as needed, the zero-trust approach

With the previous two rules understood, default-deny mode is straightforward. Start with a policy that denies everything:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: prod
spec:
  podSelector: {}             # Select every Pod in prod
  policyTypes:
    - Ingress
    - Egress
  # Note: no ingress / egress fields means deny everything in that direction

podSelector: {} selects everything, and omitting the ingress and egress fields denies everything. As soon as this policy is applied, the prod namespace becomes a black box: all inbound and outbound traffic is cut off.

This is the starting point for zero trust. You then add allow rules one at a time to open the required paths. The order matters:

Deny all first, then allow as needed. This is whitelist mode and the only correct approach for production.

The reverse approach, "allow everything first, then deny as needed," is blacklist mode. It is theoretically possible elsewhere, but K8s NetworkPolicy does not support blacklists at all. It has no deny rules, only allow rules. K8s is designed to force you into a whitelist. Only extension CRDs from Cilium and Calico can go beyond this limitation, as discussed in Section 6.

The minimum viable patch after denying everything is to allow DNS. DNS is the first service that breaks after default-deny: Pods can no longer resolve backend.default.svc.cluster.local, and the entire service mesh stops working. Add this policy:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: prod
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns     # CoreDNS label; varies by deployment
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

Whenever you apply default-deny-all to a namespace, apply this DNS allow policy at the same time. Otherwise, large numbers of Pods will fail to start or connect and report i/o timeout or no such host. You can spend hours troubleshooting before discovering that you blocked your own DNS.

5. Practical example: securing a complete frontend → backend:8080 → DB:5432 chain

Now combine everything above. Assume the prod namespace has three Deployments with these labels:

RolelabelListening port
frontendapp=frontend, tier=web3000
backendapp=backend, tier=api8080
db (Postgres)app=postgres, tier=data5432

The Ingress Controller runs in the ingress-nginx namespace with the label app.kubernetes.io/name=ingress-nginx.

The expected connectivity, and nothing else, is:

text
   Internet → Ingress → frontend:3000
                          │
                          ▼
                       backend:8080
                          │
                          ▼
                       postgres:5432

The database must not initiate connections anywhere. The backend must not accept traffic from Pods other than the frontend, and the frontend must not connect directly to the database. Bypassing the backend is a typical privilege violation. Step one: apply the default-deny policy and DNS allow policy from Section 4. Step two: add allow rules layer by layer.

Policy 1: allow the Ingress Controller to reach the frontend

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-allow-ingress
  namespace: prod
spec:
  podSelector:
    matchLabels: { app: frontend }
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
          podSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
      ports:
        - protocol: TCP
          port: 3000

Policy 2: allow frontend egress to backend:8080

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-egress-to-backend
  namespace: prod
spec:
  podSelector:
    matchLabels: { app: frontend }
  policyTypes: [Egress]
  egress:
    - to:
        - podSelector:
            matchLabels: { app: backend }
      ports:
        - protocol: TCP
          port: 8080

Policy 3: allow the frontend to reach backend port 8080

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-allow-frontend
  namespace: prod
spec:
  podSelector:
    matchLabels: { app: backend }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels: { app: frontend }
      ports:
        - protocol: TCP
          port: 8080

Policy 4: allow backend egress to postgres:5432

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-egress-to-db
  namespace: prod
spec:
  podSelector:
    matchLabels: { app: backend }
  policyTypes: [Egress]
  egress:
    - to:
        - podSelector:
            matchLabels: { app: postgres }
      ports:
        - protocol: TCP
          port: 5432

Policy 5: allow only the backend to reach Postgres port 5432

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: db-allow-backend-only
  namespace: prod
spec:
  podSelector:
    matchLabels: { app: postgres }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels: { app: backend }
      ports:
        - protocol: TCP
          port: 5432

Note two practical details in this configuration. Both have caused painful production incidents:

  1. Declare both directions. NetworkPolicy is directional. Allowing frontend egress to backend:8080 does not mean the backend allows ingress from the frontend. Both policies must exist before the connection works.
  2. Do not write rules for return packets. Although both directions need allow policies, reverse traffic for an established connection does not need a separate allow rule. CNIs are stateful and use connection tracking, or conntrack, so response traffic for an established connection is allowed automatically. Otherwise, NetworkPolicy would be unbearable to maintain.

After applying the policies, verify them by launching a temporary Pod that simulates a "malicious Pod." Deliberately give it the label app=evil. It must not be able to reach the database:

bash
kubectl run evil --image=nicolaka/netshoot -it --rm --labels=app=evil -n prod -- curl -v postgres:5432
# It should time out rather than return connection refused, because the CNI drops it during the SYN phase

If it can connect, suspect two things first: whether the CNI supports NetworkPolicy, as discussed in Section 6, and whether the labels in the policy match the Pod's actual labels, which you can check with kubectl get pods --show-labels.

6. CNI support: writing YAML is useless without the right network plugin

This is NetworkPolicy's least visible pitfall: K8s defines only the NetworkPolicy API and data structure. It does not enforce a single line of it. The CNI, or Container Network Interface, plugin translates the YAML into kernel rules. If your CNI does not support NetworkPolicy, applying this YAML produces no error but has no effect whatsoever. That is more dangerous than an explicit rejection because it creates a false sense of security.

Support for native NetworkPolicy among mainstream CNIs:

CNINative NetworkPolicyCharacteristics
Calico✅ Full supportThe de facto industry standard, plus the GlobalNetworkPolicy extension CRD for cross-namespace rules, deny rules, and ordering
Cilium✅ Full supporteBPF-based with strong performance; CiliumNetworkPolicy adds L7 support for HTTP methods and paths
Weave Net✅ SupportedAn established solution with declining maintenance activity
Antrea✅ SupportedOVS-based and associated with the VMware ecosystem
kube-router✅ SupportedLightweight
Flannel❌ Not supportedThe most common trap: applying NetworkPolicy on plain Flannel has no effect
Cloud provider defaultsDependsGKE uses Calico when optionally enabled; EKS uses VPC CNI, which does not support it by default and requires Calico; AKS uses Azure CNI with partial support

The most direct way to verify support is to apply a deny-all policy and test connectivity, as in the evil Pod experiment from Section 5. If Pods can still communicate after deny-all, your CNI does not support NetworkPolicy or is not installed correctly. The YAML is merely documentation at that point.

For production CNI selection, here is the direct recommendation:

  • For small and medium clusters where stability matters: Calico. It has the most mature ecosystem, the most complete documentation, and searchable answers to common problems
  • For performance-sensitive environments that need L7 controls or observability: Cilium. eBPF is the future, and Hubble exposes the traffic topology directly, making it an exceptional NetworkPolicy debugging tool
  • Do not run plain Flannel in production unless you explicitly do not need network policies

In addition to native NetworkPolicy, Calico and Cilium provide extensions for capabilities unavailable in the native API:

  • Deny rules: the native API has only allow rules, while extension CRDs can explicitly deny a segment of traffic
  • Global policies: the native API is scoped by namespace, while GlobalNetworkPolicy can apply one policy across the entire cluster
  • L7 policies: the native API stops at L4 ports, while Cilium can express HTTP-level rules such as "allow only GET /api/health and deny DELETE /api/users"
  • Ordering control: the native API relies on union semantics, while extensions can specify rule order

At this point, you are no longer merely writing NetworkPolicy; you are building a simplified service mesh. More is not always better. Most teams can prevent 95% of lateral-movement risk by using native NetworkPolicy correctly with a reliable CNI.

7. Debugging and troubleshooting: verifying that policies actually work

NetworkPolicy debugging is painful because it is "silent": when a policy takes effect, connections are dropped, but nothing appears in the application logs. The following workflow is the troubleshooting routine I have developed over the years.

Step one: list all active policies

bash
kubectl get networkpolicy -A                    # Cluster-wide view
kubectl describe networkpolicy <name> -n prod   # Inspect specific rules

Check every field in the describe output, especially whether podSelector actually selects all intended Pods and whether from/to uses OR or AND semantics.

Step two: confirm that the target Pod is actually "isolated"

bash
# Check whether a policy's podSelector matches the Pod's labels
kubectl get pods -n prod --show-labels

In 80% of cases where NetworkPolicy appears ineffective, the cause is a mistyped label, such as camel case versus underscores or app versus app.kubernetes.io/name. --show-labels is the first check to run.

Step three: launch a netshoot Pod and test connectivity directly

bash
kubectl run debug --image=nicolaka/netshoot -it --rm -n prod \
  --labels=app=debug -- bash

# Test from inside the Pod:
curl -v backend:8080           # Should work
curl -v postgres:5432          # Should not work (app=debug was not allowed)
nc -zv postgres 5432           # Probe the port
dig kubernetes.default         # Check whether DNS works

Launch debug Pods with different labels to simulate different identities and precisely test which role can reach each destination. This is ten times faster than reasoning through the YAML by inspection.

Step four: inspect the actual rules with CNI tools

  • Calico: use calicoctl get policy -o yaml, or inspect Felix logs with kubectl logs -n kube-system calico-node-xxx
  • Cilium: use cilium monitor, cilium policy get, and Hubble. hubble observe -f --pod prod/debug shows in real time which policy drops or forwards each packet. It is the most powerful NetworkPolicy debugging tool

Step five: connection timeout and connection refused mean different things

  • Timeout: the CNI is silently dropping traffic, so the policy is active and blocking you
  • Connection refused: the packet reached the target Pod, but the target process is not listening on that port. The policy is not blocking it; the problem is in the Pod itself

Understanding this distinction can save half a night of troubleshooting.

Summary

  • K8s allows all Pods to communicate by default, and a namespace is not a network boundary. A compromised Pod becomes a pass to the internal network, making this the default network model's greatest security risk
  • NetworkPolicy is a declarative Pod firewall divided into ingress and egress. It uses podSelector to select controlled Pods and from/to to list allowed sources and destinations. Once a Pod is selected for a declared direction, it enters whitelist-based isolation
  • The four matching dimensions are podSelector for the same namespace, namespaceSelector for cross-namespace selection, ipBlock for external IPs rather than Service IPs, and ports. Items within one list element use AND semantics, while separate elements use OR semantics. Moving the indentation by one line changes the entire meaning
  • The zero-trust approach is to deny all first and then allow as needed, while immediately allowing DNS. K8s does not support blacklists; the native API has allow rules but no deny rules, intentionally encouraging this good practice
  • Writing YAML alone is useless; the CNI must support it. Flannel does not, and EKS's default VPC CNI does not. For production, prefer Calico for stability or Cilium for power and observability. Hubble makes policy debugging dramatically easier

In one sentence: Security is not "adding a firewall"; it is "requiring every connection to provide a reason why it is authorized." NetworkPolicy lets you write that requirement into YAML, and the CNI enforces it in the kernel. If either part is missing, the resulting sense of security is false.

Related

Related Content