How to Set Up Egress Network Policies in Kubernetes

How to Set Up Egress Network Policies in Kubernetes

Most teams lock down ingress traffic early — it’s the obvious attack surface. Egress gets ignored far more often, which is exactly backwards from a security standpoint: if an attacker compromises a pod, unrestricted egress is what lets them exfiltrate data or call out to a command-and-control server. I didn’t take egress policies seriously until a security audit flagged that literally every pod in one of our clusters could reach the public internet freely, including pods that had no legitimate reason to make any outbound calls at all. This article covers how NetworkPolicy egress rules actually work and how to build a default-deny posture without breaking your applications.

How NetworkPolicies Work Internally

NetworkPolicy is a Kubernetes API object, but it does nothing on its own — it requires a CNI plugin that implements NetworkPolicy enforcement (Calico, Cilium, Weave Net). The default kubenet and many basic CNI setups silently ignore NetworkPolicy objects entirely, which is a common source of “I applied a policy and nothing happened” confusion.

When enforced, the CNI plugin’s agent (running as a DaemonSet, e.g., calico-node) programs iptables rules, eBPF programs, or equivalent packet-filtering logic on each node, matching traffic against the policy’s pod selectors and rules before allowing or dropping packets.

Pod A (egress attempt)
      │
      ▼
CNI Agent on Node (iptables/eBPF rules from NetworkPolicy)
      │
   ┌──┴──┐
 allow   deny
   │       │
   ▼       ▼
Destination  (dropped)

Verifying Your CNI Supports NetworkPolicy

Before writing any policy, confirm the CNI plugin actually enforces them:

kubectl get pods -n kube-system -o wide

Look for Calico, Cilium, or Weave Net pods. If you’re running plain kubenet or an unconfigured flannel, NetworkPolicies will be silently accepted by the API but never enforced — you’ll need to switch CNI plugins first.

Default Deny All Egress

The foundational policy for any zero-trust posture is a default-deny that applies to all pods in a namespace:

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

An empty podSelector: {} matches every pod in the namespace. With no egress rules defined and policyTypes: [Egress] set, this blocks all outbound traffic from every pod — including DNS, which will immediately break most applications. This is intentional: you now explicitly allow only what’s needed.

kubectl apply -f default-deny-egress.yaml

Allowing DNS (Almost Always Required First)

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

Without this, every pod loses the ability to resolve service names or external hostnames, which manifests as mysterious NXDOMAIN errors across the entire namespace.

Allowing Egress to Specific Pods/Services

For a frontend pod that needs to reach a backend API pod within the cluster:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-to-backend
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: frontend
  policyTypes:
    - Egress
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: backend-api
      ports:
        - protocol: TCP
          port: 8080

Allowing Egress to External IP Ranges (Third-Party APIs)

When a pod legitimately needs to call an external service — a payment gateway, for example — restrict it to specific CIDR ranges rather than opening egress broadly:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-payment-gateway
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: checkout-service
  policyTypes:
    - Egress
  egress:
    - to:
        - ipBlock:
            cidr: 203.0.113.0/24
      ports:
        - protocol: TCP
          port: 443

Since public API providers’ IP ranges can change, I usually pair this with a documented review cadence, or use a CNI plugin (like Cilium) that supports FQDN-based egress policies instead of raw CIDRs — much more maintainable.

FQDN-Based Egress with Cilium

Cilium extends the standard NetworkPolicy model with CiliumNetworkPolicy, supporting domain names directly instead of brittle IP ranges:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: allow-stripe-api
  namespace: production
spec:
  endpointSelector:
    matchLabels:
      app: checkout-service
  egress:
    - toFQDNs:
        - matchName: "api.stripe.com"
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP
    - toEndpoints:
        - matchLabels:
            k8s:io.kubernetes.pod.namespace: kube-system
            k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: UDP

This is dramatically easier to maintain than tracking CIDR ranges for third-party services that rotate IPs behind CDNs or load balancers.

Namespace-Scoped Egress (Multi-Tenant Clusters)

For multi-tenant clusters, restrict egress to only within the same namespace by default, then explicitly open cross-namespace or external paths as needed:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: same-namespace-only
  namespace: team-a
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: team-a

Testing Egress Policies Safely

Roll out egress policies in a non-blocking observe mode first if your CNI supports it (Cilium’s CiliumNetworkPolicy supports audit mode; Calico supports policy staging via Calico Enterprise). Without that tooling, I test in a staging namespace first:

kubectl apply -f default-deny-egress.yaml -n staging
kubectl exec -n staging deploy/frontend -- curl -v https://api.example.com
kubectl exec -n staging deploy/frontend -- nslookup backend-api.staging.svc.cluster.local

Watch for connection timeouts (indicating a blocked path) versus connection refused (usually application-level, not policy-level).

Debugging Blocked Egress

kubectl exec -it <pod> -- curl -v --max-time 5 https://example.com

A hang followed by timeout strongly suggests a NetworkPolicy is silently dropping the packets. Check applicable policies:

kubectl get networkpolicy -n production
kubectl describe networkpolicy default-deny-egress -n production

For Calico, you can inspect actual enforcement via:

calicoctl get networkpolicy -o wide

For Cilium:

cilium monitor --type drop

This shows live packet drops with the policy that caused them, which is by far the fastest way to debug a “why can’t my pod reach X” ticket.

Production Best Practices

Egress Policies for Common Platform Services

A few patterns I end up writing on nearly every cluster, since almost every namespace needs some subset of these:

Allow egress to the Kubernetes API server (needed for any pod using a Kubernetes client, like an operator or a controller):

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-kube-api
  namespace: production
spec:
  podSelector:
    matchLabels:
      needs-kube-api: "true"
  policyTypes:
    - Egress
  egress:
    - to:
        - ipBlock:
            cidr: 10.0.0.1/32   # replace with your actual API server IP/CIDR
      ports:
        - protocol: TCP
          port: 443

Note the API server’s IP isn’t reliably discoverable via a namespaceSelector/podSelector since it typically runs outside the pod network entirely (especially on managed Kubernetes) — an ipBlock pointing at the API server’s actual endpoint is usually required. Check kubectl cluster-info for the exact address in your environment.

Allow egress to a monitoring/logging sidecar collector in another namespace:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-otel-collector
  namespace: production
spec:
  podSelector: {}
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: observability
          podSelector:
            matchLabels:
              app: otel-collector
      ports:
        - protocol: TCP
          port: 4317

Allow egress to a container registry for image pulls — note this is actually enforced at the kubelet/container-runtime level for the initial pull, not through pod-level NetworkPolicy, so this specific rule matters more for runtime registry calls (e.g., an application dynamically pulling plugins) than for the base container image pull itself.

Auditing Egress Policy Coverage Across a Cluster

For a fleet-wide security review, I check which namespaces have any egress restriction at all versus which are still fully open:

for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do
  count=$(kubectl get networkpolicy -n "$ns" -o json | jq '[.items[] | select(.spec.policyTypes[] == "Egress")] | length')
  echo "$ns: $count egress policies"
done

Namespaces reporting 0 are exactly the ones a security review should flag first — they represent unrestricted blast radius if any pod in them is ever compromised.

Common Mistakes

Summary

Egress NetworkPolicies flip your cluster’s default posture from “any pod can reach anything” to “pods can only reach what’s explicitly allowed” — a genuinely significant security improvement that most clusters skip. The mechanics are straightforward once you internalize that NetworkPolicy needs CNI-level enforcement, that default-deny requires an explicit DNS allow rule, and that FQDN-based policies (via Cilium) beat brittle IP CIDRs for anything talking to third-party services.

References

Exit mobile version