Kubernetes Network Policy for Multi-Service Applications

Lock down pod-to-pod traffic before a breach spreads across your cluster.

Contributing Editor · · 11 min read
Cover illustration for “Kubernetes Network Policy for Multi-Service Applications”
Security Posture · October 6, 2026 · 11 min read · 2,482 words

A default Kubernetes cluster grants every pod unrestricted access to every other pod, across every namespace. That single fact is the starting point for everything else in this guide. Picture an attacker who has just landed inside one container through a vulnerable dependency or a leaked credential. In a flat cluster, that attacker is standing in an open office floor plan where every door is unlocked, every desk drawer is unlocked, and every filing cabinet sits open down the hall.

Kubernetes assigns each pod its own IP address and routes traffic between pods directly, without network address translation. That design makes service discovery simple: pods find and talk to each other without extra plumbing. It also removes any implicit boundary between workloads. Nothing in the default configuration stops a compromised container from probing other namespaces, internal APIs, databases, or secrets stores it was never meant to touch. The network itself places no questions in the attacker's path.

Most teams don't write a NetworkPolicy until an incident forces the question. By the time that incident happens, lateral movement has been possible for the entire life of the cluster, quietly, the whole time. The blast radius of a single compromised workload scales directly with how much of the cluster it can reach. An unrestricted flat network maximizes that radius by default, making network segmentation one of the few controls that changes the outcome of a breach.

How NetworkPolicy Works

NetworkPolicy is the Kubernetes API object built to restrict pod-to-pod and pod-to-endpoint traffic. It only works if the cluster's CNI plugin independently implements and enforces the specification. The Kubernetes API server will accept and store a NetworkPolicy manifest regardless of whether anything on the data plane can actually act on it. A cluster can look secured on paper, but every rule just sits there, silently ignored. Check the CNI plugin before writing a single rule.

NetworkPolicy operates at OSI Layer 3 and Layer 4. It controls TCP, UDP, and SCTP traffic using pod selectors, namespace selectors, and IP blocks. It does not inspect application-layer protocols, so it can't make decisions based on HTTP paths, headers, or payloads. Every policy is built from four components: a podSelector that defines which pods the policy applies to, ingress rules for allowed inbound connections, egress rules for allowed outbound connections, and policyTypes that declare whether the policy governs ingress, egress, or both.

Policies combine additively. If multiple policies select the same pod, the union of all ingress rules and the union of all egress rules applies. There's no first-match-wins ordering the way there is in a traditional firewall ACL, so adding a narrower policy never tightens access on its own. It only adds more allowed paths on top of what already exists.

Enforcement depends entirely on what's running underneath. A quick reference, by plugin:

| CNI plugin | Enforces NetworkPolicy? | Notes | |---|---|---| | Cilium 1.20.2 (stable) | Yes, by default | eBPF dataplane; supports Kubernetes ClusterNetworkPolicy (KCNP) starting in 1.20 | | Flannel v0.28.5 (standalone) | No | Must add the Flannel Helm chart's netpol.enabled option, which deploys the kube-network-policies controller, or chain with Calico or Cilium | | GKE Dataplane V2 (Cilium-based) | Yes | Default and recommended for Autopilot clusters | | Legacy Calico on GKE | Yes | Available only for Standard clusters |

Verify what's actually running before assuming anything:

kubectl get pods -n kube-system -o wide | grep -Ei 'cilium|calico|flannel|weave|antrea'
kubectl get daemonset -n kube-system

Flannel is the most common silent failure. Its own documentation states that it does not enforce NetworkPolicy on its own. Clusters committed to Flannel's routing model can still get enforcement by chaining Cilium onto Flannel's veth interfaces, but that configuration is not the default anywhere, and it has to be set up deliberately.

Starting with a default-deny baseline that makes every traffic flow visible

The only defensible starting point for a multi-service application is default-deny. Apply a policy that selects all pods in a namespace and permits no ingress and no egress, then build allow rules one at a time from there. Without this baseline, any service with no NetworkPolicy attached to it stays fully reachable. If you add policies piecemeal, service by service, the unaddressed pods sit in a default-allow state, and you won't see that gap until something exploits it.

A default-deny ingress policy looks like this:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
spec:
  podSelector: {}
  policyTypes:
    - Ingress

The empty podSelector: {} selects every pod in the namespace. With no ingress rules listed and policyTypes set to Ingress, nothing can reach any pod in that namespace unless a separate policy explicitly says otherwise. The egress version follows the same shape:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-egress
spec:
  podSelector: {}
  policyTypes:
    - Egress

Applying default-deny egress breaks something almost immediately: DNS. Pods lose the ability to resolve service names because queries to the kube-dns service, typically port 53 over UDP and TCP in the kube-system namespace, get blocked along with everything else. This isn't an edge case to patch later: you need to fix it first, before you test anything else, because nothing downstream works without name resolution. The carve-out allows egress to the kube-dns pod (selected by label in kube-system) on port 53 UDP and TCP, and it should get tested right away with a debug pod running dig or nslookup against a known service name.

Once DNS resolves again, the baseline does more than restore basic function. It turns every allowed communication path into a discrete, version-controlled manifest. From here, every section that follows is about adding allow rules for a specific tier of the application, deliberately, one connection at a time.

Building allow rules for a three-tier application: frontend, backend, and database

The three-tier web application, frontend, backend, and database, is the clearest model for seeing how allow rules stack into a working, segmented topology. Each tier gets its own namespace, and each tier's policy permits exactly the one upstream connection it needs. Nothing more.

Placing each tier in a separate namespace, with labels like app: frontend, app: backend, and app: database, gives two independent enforcement layers working together: namespace boundaries and NetworkPolicy. It also makes namespaceSelector rules unambiguous, since there's no question about which pods belong to which tier. The allowed traffic flow runs one direction only: frontend to backend, backend to database. There's no reverse path, so frontend has no shortcut to reach the database directly.

The backend's ingress rule allows connections from pods in the frontend namespace, matched with a namespaceSelector on the frontend namespace's label, restricted to the backend's service port:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-allow-frontend
  namespace: backend
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: frontend
      ports:
        - protocol: TCP
          port: 8080

The database's ingress rule mirrors this, allowing traffic from the backend namespace on the database port, with a labeled selector such as tier: db marking the database pods. It's worth writing an explicit deny for the frontend namespace at the database tier too, even though frontend pods have no direct route under the current rules. That explicit deny documents intent, and it keeps protecting the database if the topology changes later and someone opens a path that shouldn't exist.

The frontend's egress rule is the narrowest of all three: it allows egress to the backend namespace on the backend's service port, plus the DNS carve-out from the default-deny baseline, and nothing else. This pattern, a compromised front-end service unable to reach a billing or accounting service several tiers down, is exactly the kind of containment GKE's own network policy documentation points to as the practical payoff of defense in depth.

Verification matters as much as the rules themselves. Run a netshoot debug pod and test curl and nc from each tier toward every other tier after each policy goes in. A connection that gets denied when it should be denied counts as a passing test, not a problem to fix.

Namespace isolation on its own is not enough if two unrelated applications end up sharing a namespace. Two different services running in the same namespace can still reach each other's pods unless pod-level podSelector rules are layered in alongside the namespace boundaries.

Namespace-Level Segmentation Versus Pod-Level Segmentation

Namespace isolation is an essential first layer, but it doesn't prevent unauthorized communication between different applications that happen to land in the same namespace. Closing that gap takes pod-level podSelector rules applied alongside namespace selectors, together, not as a substitute for one another.

A namespaceSelector by itself allows any pod in the permitted namespace to reach the target. If that namespace hosts several unrelated services, which is common in clusters that group by team or environment rather than by application, the rule ends up wider than intended. The fix is combining a namespaceSelector (to constrain which namespace traffic can come from) with a podSelector (to constrain which pods within that namespace) inside the same from clause. This is an AND relationship between the two selectors, not an OR.

A rule with a namespaceSelector matching backend and a podSelector matching app: backend-api only allows traffic from pods carrying both the namespace membership and the label. Splitting those into two separate from entries instead of one combined entry makes the rule an OR: any pod in the backend namespace, or any pod anywhere carrying that label. That's a much wider hole than most teams intend to open, and the configuration difference between the two is small enough to miss in review.

Multi-tenant clusters make this distinction matter even more. A tenant-per-namespace model that relies only on namespace-level policies still lets one tenant's pods reach another tenant's pods if both end up in a shared namespace, whether from misconfiguration or from namespace reuse during migrations. AKS's network security guidance recommends applying both ingress and egress network policies together so that only authorized pods and namespaces, matched jointly, can reach a ClusterIP service's backing pods. The "and" there is deliberate.

None of this works without label discipline. Pod-level segmentation is only as precise as the labels applied to pods. Inconsistent or missing labels make selectors match too broadly or too narrowly, and either failure defeats the purpose of writing the rule. You should enforce label standards in the deployment pipeline, not leave it to individual teams to remember. And one pattern to flag during review: an empty podSelector ({}) selects every pod in a namespace. That's correct and intentional for a default-deny policy. It should never appear in a targeted allow rule meant to reach one specific service.

Egress controls: managing outbound traffic to external services and the API server

Egress policy is the half of the segmentation model most teams skip. An uncontrolled egress path from any pod means a compromised workload can reach external endpoints, the Kubernetes API server, or cloud metadata services without restriction, regardless of how tightly ingress has been locked down. Teams that skip this step often find out during a compliance audit or an incident review, well after the gap has been open.

Once the default-deny egress baseline is in place, every outbound connection a pod actually needs has to be permitted explicitly: DNS, the API server if the workload calls it, external databases, third-party APIs, image registries for pulling updated containers. Each of these is a deliberate line item, not a blanket allowance.

The Kubernetes API server deserves particular attention as a lateral movement target. Workloads that don't need to call the API server should have egress to its IP and port explicitly blocked. Workloads that do need it should have that access scoped to the minimum required verbs, enforced through RBAC and egress policy working together. AKS's best practices recommend that you run a private cluster to keep API server traffic on a private endpoint, and configure authorized IP ranges if you need a public endpoint. Egress NetworkPolicy at the pod level adds to that control. It doesn't replace it.

Cloud metadata endpoints are reachable from pods by default on every major cloud provider. A pod that can reach the metadata service may be able to retrieve instance credentials, which makes unrestricted metadata access a meaningful risk on its own. GKE requires that egress to the metadata server be explicitly allowed when a workload uses network policy together with Workload Identity Federation, a concrete case where omitting the carve-out breaks the workload.

For dependencies that live outside the cluster, FQDN-based egress policies, available in GKE through its FQDN network policy feature, let egress rules reference domain names in place of fixed IP blocks. That matters for third-party SaaS dependencies, since their IP ranges shift over time, and IP-based rules would otherwise need constant updates just to keep working.

Sensitive workloads, anything handling PHI or payment data, should have egress restricted to a known allowlist: internal services plus a defined, reviewed set of external endpoints. That kind of restriction materially limits the paths available for exfiltration if a workload is ever compromised. Egress completeness is what turns a default-deny posture into something genuinely zero-trust, rather than a policy that only watches the front door while the back one stays open.

Layer

NetworkPolicy operates at Layer 3 and Layer 4. It decides which IPs, ports, and protocols can talk to each other, and it has no visibility into what rides on top of that traffic, no HTTP paths, no headers, no payloads, no application-level identity. That boundary is a limitation to plan around.

For teams that need policy decisions based on application-layer detail, such as which HTTP method an upstream service is calling, or want mutual TLS between every pod-to-pod connection, that functionality lives above NetworkPolicy, at Layer 7, typically through a service mesh or a Layer 7-aware proxy. These tools work alongside NetworkPolicy. They enforce rules NetworkPolicy structurally cannot express. One research effort has pointed out that most Kubernetes clusters do not enforce any network policy. Layer 7 tooling only pays off once Layer 3 and Layer 4 are already solid underneath it.

That ordering matters. Layer 3 and Layer 4 controls are the foundation: cheaper to reason about, broadly supported by CNI plugins like Cilium, and enough on their own to stop the lateral movement scenario this guide opened with. Layer 7 tools add precision on top of that foundation, but they don't substitute for it. A cluster running a sophisticated service mesh with no NetworkPolicy underneath still has the flat, default-open network described at the start of this guide. The mesh governs the traffic it knows about. Everything outside its visibility moves exactly as freely as it did before the mesh was installed.

Building the segmentation model in order, default-deny baseline first, tier-by-tier allow rules second, namespace and pod selectors combined third, egress locked down fourth, gives a cluster a network posture that's deliberate at every layer instead of only at the one that happens to be easiest to configure.

Diagram: Four Steps to a Deliberate Network Posture. Visualizes: Show the four-stage sequence for building Kubernetes network segmentation described at the article's close: (1) default-deny baseline, (2) tier-by-tier allow rules, (3) namespace and…

Sources

  1. Control communication between Pods and Services using network policies
  2. Enabling Network Policy Enforcement in Service Meshes
Filed underSecurity Posture

More in Security Posture