By default, Kubernetes networking is completely flat — every pod in a cluster can talk to every other pod, across every namespace, with no restrictions whatsoever. The first time that fact really landed for me was during a pentest engagement where a low-privilege compromised pod was used to reach an internal admin API three namespaces away, with nothing stopping the lateral movement. NetworkPolicies are the tool that fixes this, and understanding them thoroughly — not just copy-pasting examples — is one of the highest-leverage security investments you can make in a cluster.
What NetworkPolicies Actually Are
A NetworkPolicy is a namespace-scoped API object that specifies allowed traffic to and from a set of pods, selected via label selector. Critically: a pod with no NetworkPolicy selecting it is fully open (all traffic allowed). The moment any policy selects a pod, that pod becomes subject to default-deny for whichever traffic direction(s) the policy declares (Ingress, Egress, or both) — only what’s explicitly allowed by matching policies gets through.
This “implicit allow, explicit policies create implicit deny” model trips people up constantly, so it’s worth internalizing precisely:
- No policies selecting a pod → all traffic allowed
- One or more
Ingresspolicies selecting a pod → only traffic matching at least one policy’s rules is allowed inbound; everything else is dropped - Same logic applies independently for
Egress
Enforcement Requires a Compatible CNI
Same caveat as always: NetworkPolicy objects are inert without a CNI plugin that implements enforcement (Calico, Cilium, Weave Net, Antrea). Check what you’re running:
kubectl get pods -n kube-system -o wide
kubectl get daemonset -n kube-system
Anatomy of a NetworkPolicy
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-policy
namespace: production
spec:
podSelector:
matchLabels:
app: api-server
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
matchLabels:
app: database
ports:
- protocol: TCP
port: 5432
Breaking this down:
podSelector— which pods this policy applies to (empty{}means all pods in the namespace)policyTypes— which traffic directions this policy governsingress[].from— sources allowed to send traffic in; can bepodSelector,namespaceSelector, oripBlockegress[].to— destinations allowed to receive traffic out
Default Deny All (Both Directions)
The starting point for any zero-trust namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
Apply this and then layer on specific allow rules for each legitimate traffic path. This is genuinely the recommended baseline for any production namespace handling sensitive data.
Allowing Traffic from a Specific Namespace
Useful for shared services (e.g., an internal API gateway namespace calling into multiple backend namespaces):
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-gateway-namespace
namespace: production
spec:
podSelector:
matchLabels:
app: backend-api
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: api-gateway
ports:
- protocol: TCP
port: 8080
Note: kubernetes.io/metadata.name is an automatically populated label on every namespace since Kubernetes 1.21+, making this pattern reliable without needing to manually label namespaces yourself.
Combining Pod and Namespace Selectors
A common point of confusion: when podSelector and namespaceSelector appear together within the same list item under from, they’re ANDed (must match both); when they’re separate list items, they’re ORed.
ingress:
- from:
- namespaceSelector:
matchLabels:
team: platform
podSelector:
matchLabels:
app: monitoring-agent
This allows traffic only from pods labeled app: monitoring-agent that are also in a namespace labeled team: platform — both conditions required.
ingress:
- from:
- namespaceSelector:
matchLabels:
team: platform
- podSelector:
matchLabels:
app: monitoring-agent
This allows traffic from any pod in a team: platform namespace, OR from any pod labeled app: monitoring-agent regardless of namespace — a much broader rule. Getting this distinction wrong is one of the most common NetworkPolicy authoring mistakes.
Allowing External Traffic via ipBlock
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-office-ip
namespace: production
spec:
podSelector:
matchLabels:
app: admin-panel
policyTypes:
- Ingress
ingress:
- from:
- ipBlock:
cidr: 203.0.113.0/24
ports:
- protocol: TCP
port: 443
ipBlock can also except specific sub-ranges:
ingress:
- from:
- ipBlock:
cidr: 10.0.0.0/8
except:
- 10.0.5.0/24
Multi-Tier Application Example
A realistic three-tier app (frontend → backend → database) with least-privilege policies:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-policy
namespace: production
spec:
podSelector:
matchLabels:
tier: frontend
policyTypes:
- Ingress
- Egress
ingress:
- {} # allow all ingress (e.g., from an Ingress controller)
egress:
- to:
- podSelector:
matchLabels:
tier: backend
ports:
- protocol: TCP
port: 8080
- to:
- namespaceSelector: {}
ports:
- protocol: UDP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-policy
namespace: production
spec:
podSelector:
matchLabels:
tier: backend
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
tier: frontend
ports:
- protocol: TCP
port: 8080
egress:
- to:
- podSelector:
matchLabels:
tier: database
ports:
- protocol: TCP
port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: database-policy
namespace: production
spec:
podSelector:
matchLabels:
tier: database
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
tier: backend
ports:
- protocol: TCP
port: 5432
This ensures the database tier only ever accepts connections from the backend tier, and the frontend can’t reach the database directly at all — even if someone misconfigures the frontend application to try.
Applying and Verifying
kubectl apply -f frontend-policy.yaml -f backend-policy.yaml -f database-policy.yaml
kubectl get networkpolicy -n production
NAME POD-SELECTOR AGE
frontend-policy tier=frontend 10s
backend-policy tier=backend 10s
database-policy tier=database 10s
Test enforcement directly:
kubectl run test-pod --image=busybox --rm -it --restart=Never -n production -- wget -qO- --timeout=3 http://database-service:5432
This should hang/timeout if run from anywhere other than a labeled tier: backend pod, confirming the policy is enforced.
Debugging NetworkPolicy Issues
kubectl describe networkpolicy <name> -n production
kubectl get pods -n production --show-labels
Mismatched labels between the policy’s selector and actual pod labels is, by a wide margin, the most common reason a NetworkPolicy silently “does nothing” — always cross-check --show-labels output against the policy’s matchLabels.
For Cilium-based clusters:
cilium monitor --type drop
For Calico:
calicoctl get networkpolicy -o wide
kubectl logs -n kube-system -l k8s-app=calico-node | grep -i denied
Production Best Practices
- Start every new namespace with default-deny for both ingress and egress, then layer allow rules — retrofitting this onto an established, fully-open namespace is much riskier.
- Use
namespaceSelectorwith the built-inkubernetes.io/metadata.namelabel rather than custom namespace labels, since it’s guaranteed present without manual labeling. - Document each policy’s intent (an annotation or accompanying README) — NetworkPolicy YAML alone doesn’t explain why a rule exists, which matters a lot six months later during an audit.
- Combine with Pod Security Standards and RBAC for defense in depth — NetworkPolicy alone doesn’t prevent a compromised pod’s process from doing damage within its own container.
NetworkPolicies and Ingress Controllers
A frequent point of confusion: how do external users reach a pod behind a default-deny ingress policy? The Ingress controller itself is just another pod making a connection, so it needs to be explicitly allowed like any other source:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-ingress-controller
namespace: production
spec:
podSelector:
matchLabels:
tier: frontend
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
podSelector:
matchLabels:
app.kubernetes.io/name: ingress-nginx
ports:
- protocol: TCP
port: 8080
This is more precise than the ingress: [{}] allow-all example shown earlier in the multi-tier walkthrough — worth tightening once you know your actual Ingress controller’s namespace and labels.
CiliumNetworkPolicy: Layer 7 Rules
Standard NetworkPolicy only operates at Layer 3/4 (IP and port) — it can’t distinguish between an allowed GET /health and a disallowed DELETE /admin/users on the same port. Cilium’s CiliumNetworkPolicy extends this to Layer 7 for HTTP, gRPC, and Kafka traffic:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: restrict-admin-api
namespace: production
spec:
endpointSelector:
matchLabels:
app: admin-api
ingress:
- fromEndpoints:
- matchLabels:
app: frontend
toPorts:
- ports:
- port: "8080"
protocol: TCP
rules:
http:
- method: "GET"
path: "/api/v1/status"
This allows frontend pods to call only GET /api/v1/status on admin-api — any other method or path is dropped at the network layer, even if the application itself has no additional authorization logic for that specific route. This is a genuinely powerful defense-in-depth layer, though it does require Cilium specifically rather than being portable across all CNI plugins.
Testing Strategy for a Zero-Trust Rollout
Rolling out default-deny across an established cluster is risky enough that I follow a consistent process:
- Audit mode first (Cilium supports this natively via
CiliumNetworkPolicywith an audit-only annotation; for plain NetworkPolicy without native audit support, deploy to a staging namespace that mirrors production traffic patterns). - Log what would be blocked without actually blocking it, using CNI-specific flow logging (
cilium monitor, Calico’s flow logs to Elasticsearch) over at least one full business cycle (a week, ideally including any batch/cron jobs that only run periodically). - Build allow rules from observed legitimate flows, not from guessing at what “should” be needed — actual traffic patterns are often surprising, especially around health checks, metrics scraping, and DNS.
- Roll out namespace by namespace, starting with the least critical, watching error rates and connection timeout metrics closely after each rollout.
kubectl get events -n production --field-selector reason=NetworkPolicyDenied
(Availability of this specific event reason depends on your CNI’s integration with Kubernetes events — Calico and Cilium both support some form of this, though the exact mechanism varies.)
Common Mistakes
- Believing NetworkPolicy is enforced everywhere by default — it requires a compatible CNI, and this is often not the case in default cloud provider cluster configurations.
- Confusing the AND/OR semantics of combined
podSelector+namespaceSelectorrules (covered above) — this is genuinely one of the most misunderstood parts of the entire NetworkPolicy spec. - Forgetting that policies are additive across all matching NetworkPolicy objects — if any one policy allows a given path, it’s allowed, even if another stricter-looking policy also selects the same pod.
- Not allowing DNS in a default-deny egress setup, breaking service discovery cluster-wide.
Summary
NetworkPolicies are the mechanism that turns Kubernetes’ flat-by-default networking into something resembling real network segmentation — but only when paired with a CNI that actually enforces them, and only when authored with a solid grasp of the selector combination semantics. The pattern that scales well in practice is default-deny per namespace, explicit least-privilege allow rules per tier, and systematic label discipline so selectors reliably match the pods you intend.