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
- Roll out default-deny incrementally, namespace by namespace, never cluster-wide in one shot — the blast radius of a mistake is every pod losing network access simultaneously.
- Always allow DNS egress before anything else; it’s the most common thing people forget and the most disruptive to omit.
- Prefer FQDN-based policies (Cilium) over static IP CIDRs for third-party SaaS dependencies whose IPs rotate.
- Combine egress policies with ingress policies for genuine zero-trust — egress alone doesn’t stop lateral movement into a pod.
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
- Applying a default-deny-egress policy without first allowing DNS, causing an immediate outage across the namespace.
- Assuming NetworkPolicy is enforced without checking that the CNI actually supports it.
- Using static IP CIDRs for external SaaS APIs that rotate IPs behind CDNs, causing intermittent, hard-to-diagnose connectivity failures.
- Forgetting that NetworkPolicies are namespace-scoped by default — a
podSelectorwithout anamespaceSelectorin a rule only matches pods in the same namespace as the policy.
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.