Kubernetes NetworkPolicies — Zero-Trust Networking Between Pods
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
- EgressAn 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: 8080This 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: 5432Only 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: prodPods 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=prodEgress 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: 5432Always 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: 5432Traffic 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 5432Interpreting 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-systemCreate 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