Kubernetes NetworkPolicies — Zero-Trust Networking Between Pods

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

By default, Kubernetes allows any pod to communicate with any other pod in the cluster. This open-network posture violates zero-trust security principles. NetworkPolicies act as firewalls between pods, controlling ingress and egress traffic based on labels and namespaces. Combined with network plugins like Cilium, they enforce Layer 3/4 policies and even Layer 7 application-layer rules. This post covers the theory and practice of NetworkPolicies in production Kubernetes deployments.

Default-Deny Ingress and Egress

The foundation of zero-trust networking is denying all traffic by default, then explicitly allowing only what is needed. Apply this policy to every production namespace.

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

An empty podSelector matches every pod in the namespace. This blocks all ingress and egress for every pod — nothing communicates until you add allow rules. This is the correct starting point.

For a typical web application tier that needs outbound connections but restricted inbound:

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

This allows traffic to api pods on port 8080 only from web pods in the same namespace. Any other source is blocked at the network level.

Allowlisting by Label Selector

Use pod labels to define allowed communication paths. Label-based rules are more maintainable than IP-based rules because pod IPs are ephemeral.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-api-to-db
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: postgres
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: api
          access: service
    ports:
    - protocol: TCP
      port: 5432

Only pods labeled app: api and access: service can reach PostgreSQL on port 5432. Any other pod — even one that knows the endpoint — is blocked. Removing the access: service label from a compromised pod immediately cuts off database access.

Namespace Isolation

Separate environments using namespace-level selectors. Staging must never reach production databases.

apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    isolation: strict
    env: prod
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-cross-namespace
  namespace: production
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          isolation: strict
          env: prod

Pods in production accept traffic only from other pods in namespaces labeled isolation: strict and env: prod. Staging and development namespaces are completely isolated.

Critical: namespace selectors require the namespace to actually have the matching labels. Apply them explicitly:

kubectl label namespace production name=production isolation=strict env=prod

Egress to External IPs

Control which pods can initiate connections to external networks. Unrestricted egress enables data exfiltration and lateral movement if a pod is compromised.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-external-egress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
  - Egress
  egress:
  # DNS must always be explicitly allowed
  - to:
    - namespaceSelector: {}
    ports:
    - protocol: UDP
      port: 53
    - protocol: TCP
      port: 53
  # External HTTPS with cloud metadata service blocked
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except:
        - 169.254.169.254/32
        - 10.0.0.0/8
    ports:
    - protocol: TCP
      port: 443
  # Internal database connections
  - to:
    - podSelector:
        matchLabels:
          app: postgres
    ports:
    - protocol: TCP
      port: 5432

Always allow DNS egress first. Without UDP port 53 and TCP port 53, pods cannot resolve any domain names — a subtle failure that manifests as mysterious connection timeouts rather than clear policy errors.

NetworkPolicy Design Patterns

Pattern: Multi-tier application with strict traffic flow

---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: ingress-to-frontend
  namespace: production
spec:
  podSelector:
    matchLabels:
      tier: frontend
  policyTypes:
  - Ingress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          name: ingress-nginx
    ports:
    - protocol: TCP
      port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-to-backend
  namespace: production
spec:
  podSelector:
    matchLabels:
      tier: backend
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          tier: frontend
    ports:
    - protocol: TCP
      port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-to-database
  namespace: production
spec:
  podSelector:
    matchLabels:
      tier: database
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          tier: backend
    ports:
    - protocol: TCP
      port: 5432

Traffic flows frontend to backend to database. The frontend cannot directly reach the database. A compromised frontend pod cannot exfiltrate database contents.

Testing NetworkPolicy with netshoot

Deploy the netshoot container to verify connectivity before and after applying policies.

apiVersion: v1
kind: Pod
metadata:
  name: netshoot
  namespace: production
spec:
  containers:
  - name: netshoot
    image: nicolaka/netshoot:latest
    command: ["sleep", "3600"]

Run connectivity tests from the netshoot pod:

# Test if netshoot can reach an API pod on port 8080
kubectl exec -it netshoot -n production -- nc -zv api-service.production.svc.cluster.local 8080
 
# Test DNS resolution (should work if DNS egress is allowed)
kubectl exec -it netshoot -n production -- nslookup google.com
 
# Test blocked connection (should timeout, not refused)
kubectl exec -it netshoot -n production -- nc -zv postgres.production.svc.cluster.local 5432
 
# Capture traffic to diagnose policy decisions
kubectl exec -it netshoot -n production -- tcpdump -i eth0 port 5432

Interpreting results: connection timeout means a policy is blocking traffic. Connection refused means the pod received the packet but nothing is listening. If you see connection refused where you expect blocked, the policy is not in effect.

Cilium for Layer 7 Policies

Standard Kubernetes NetworkPolicies only operate at Layer 3 and Layer 4. Cilium extends this to Layer 7, enabling HTTP method and path restrictions.

helm repo add cilium https://helm.cilium.io
helm install cilium cilium/cilium --namespace kube-system

Create a Cilium NetworkPolicy that restricts HTTP methods:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: allow-get-only
  namespace: production
spec:
  endpointSelector:
    matchLabels:
      app: api
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: frontend
    toPorts:
    - ports:
      - port: "8080"
        protocol: TCP
      rules:
        http:
        - method: GET
          path: "/api/.*"

This allows only GET requests to /api/*. POST, PUT, and DELETE are denied at the network level — the application never even sees them.

Common Gotchas

Forgetting DNS egress: applying an egress deny-all policy without re-allowing DNS causes pods to fail all domain name lookups, which manifests as confusing connection timeouts.

Empty podSelector in from rules: podSelector: {} matches all pods in the namespace. Using it in an ingress.from rule allows all pods to connect, defeating the purpose of the policy.

Namespace labels not applied: namespace selectors require labels on the namespace object itself. A policy referencing matchLabels: name=staging silently matches nothing if the staging namespace lacks that label.

NetworkPolicy with no CNI support: standard Kubernetes does not enforce NetworkPolicies by itself — a CNI plugin (Calico, Cilium, Weave) must be installed to actually enforce them.

Key Takeaways

  • Default-deny ingress and egress is the correct starting posture; add allow rules explicitly for each required communication path.
  • Always allow DNS egress (UDP port 53 and TCP port 53) or pods cannot resolve any domain names, causing opaque connection failures.
  • Label-based selectors are more maintainable than IP-based rules because pod IPs change on every restart.
  • Namespace labels must actually exist on the namespace object for namespace selectors to match.
  • Test with netshoot before and after every policy change; connection timeout means blocked, connection refused means the packet arrived.
  • Cilium enables Layer 7 policies including HTTP method, path, and header restrictions that standard NetworkPolicies cannot express.
  • Block the cloud metadata endpoint (169.254.169.254) in egress policies to prevent SSRF attacks from extracting instance credentials.
  • A compromised pod with zero-trust networking can only reach explicitly allowed endpoints, dramatically limiting blast radius.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading