How to Write Kubernetes NetworkPolicies: Default Deny to Egress (2026)
This guide is for platform and application engineers who need to lock down pod-to-pod traffic in Kubernetes and want policies that actually do what they say. By the end you will have a namespace that denies everything by default, allows DNS, accepts traffic only from named clients, restricts egress, and (optionally) sits under cluster-wide guardrails written with the new ClusterNetworkPolicy API. Every step includes a test so you can see the policy work. I wrote and checked it against Kubernetes 1.37 on kind v0.33.0, Cilium 1.20.3, Calico 3.33.0, netshoot v0.16 and network-policy-api v0.2.0. You need kubectl, a cluster you are allowed to break (kind is fine), and a CNI plugin that enforces NetworkPolicy.
Step 1: Confirm your CNI enforces NetworkPolicy
A NetworkPolicy is just an object in the API server. The Kubernetes docs are explicit that creating one without a network plugin that implements it has no effect, and nothing will warn you. So the first job is knowing what enforces your policies.
For a disposable lab, kind has shipped built-in NetworkPolicy support since v0.24 via kube-network-policies, and v0.33.0 defaults to Kubernetes 1.37.0:
# kind v0.33.0; pin the node image by digest as the release notes require
kind create cluster --name netpol \
--image kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5
# See which CNI is running (look for kindnet, cilium, calico-node, etc.)
kubectl get pods -n kube-system -o wide | grep -Ei 'kindnet|cilium|calico|antrea|ovn'
On real clusters the common enforcers are Cilium and Calico. Both implement standard networking.k8s.io/v1 NetworkPolicy and add their own CRDs on top (CiliumNetworkPolicy, Calico NetworkPolicy and GlobalNetworkPolicy). Managed services vary: some enable enforcement by default, some make it an add-on or a cluster flag, so check your provider's documentation rather than assuming. Step 3 below doubles as an enforcement test: if a default-deny policy does not block traffic, your CNI is not enforcing.
Step 2: Deploy a test app and a netshoot probe
You want a server, a client in another namespace, and a baseline that proves traffic works before you start denying it.
kubectl create namespace demo
kubectl create namespace client
# Server: nginx in "demo", pods get label app=web, container name "nginx"
kubectl -n demo create deployment web --image=nginx:1.30 --port=80
kubectl -n demo expose deployment web --port=80
# Client: netshoot (curl, dig, nc, tcpdump) in "client"
kubectl -n client run probe --image=nicolaka/netshoot:v0.16 \
--labels=app=probe --command -- sleep infinity
kubectl -n demo rollout status deploy/web
kubectl -n client wait --for=condition=Ready pod/probe
# Baseline: should print 200
kubectl -n client exec probe -- \
curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://web.demo.svc.cluster.local
Expected output is 200. If this fails before any policy exists, fix cluster networking first; nothing below will make sense otherwise.
Step 3: Apply a default deny policy for ingress and egress
The default policies section of the docs gives the canonical shape: an empty podSelector selects every pod in the namespace, and listing a policy type with no rules allows nothing in that direction.
# default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: demo
spec:
podSelector: {} # every pod in "demo"
policyTypes:
- Ingress
- Egress # no ingress/egress rules = nothing allowed
kubectl apply -f default-deny.yaml
# Now expect a timeout (exit code 28) instead of 200
kubectl -n client exec probe -- \
curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://web.demo.svc.cluster.local
# curl: (28) Connection timed out after 3002 milliseconds
Three rules of the model are worth memorising here. Policies are additive allow-lists: there is no deny rule in v1 NetworkPolicy, and order does not matter. A pod becomes isolated in a direction only when some policy selects it for that direction. And a connection needs both sides to agree: the source pod's egress and the destination pod's ingress must each allow it. Reply traffic for an allowed connection is implicitly allowed, so you never write rules for return packets.
Step 4: Allow DNS egress
A default-deny egress policy also blocks DNS, which the docs call out in a caution box, and it is the most common reason "the app broke after we added policies". Allow UDP and TCP 53 to the cluster DNS pods:
# allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: demo
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system # immutable label set by the control plane
podSelector:
matchLabels:
k8s-app: kube-dns # CoreDNS label on kubeadm/kind/most distros
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
kubectl apply -f allow-dns.yaml
# Test from inside a demo pod using an ephemeral netshoot container.
# Ephemeral containers share the pod's network namespace, so the pod's policies apply.
POD=$(kubectl -n demo get pod -l app=web -o name | head -1)
kubectl -n demo debug -it "$POD" --image=nicolaka/netshoot:v0.16 --target=nginx -- \
dig +short +timeout=2 kubernetes.default.svc.cluster.local
# Expected: the ClusterIP of the kubernetes Service, e.g. 10.96.0.1
Two variations catch people. If you run NodeLocal DNSCache, pods send DNS to a link-local address on the node rather than to CoreDNS pods, so you need an ipBlock for that address as well. And policies match the pod's port after Service translation, so if your DNS pods listen on a non-standard container port, allow that port, not the Service port.
Step 5: Allow ingress from specific namespaces and pods
Now let the probe in client reach web on port 80, and nothing else. This is where YAML indentation decides your security posture:
# allow-web-from-client.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-web-from-client-probe
namespace: demo
spec:
podSelector:
matchLabels:
app: web
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector: # ONE list item with both selectors = AND:
matchLabels: # pods labelled app=probe IN namespace "client"
kubernetes.io/metadata.name: client
podSelector:
matchLabels:
app: probe
ports:
- protocol: TCP
port: 80
kubectl apply -f allow-web-from-client.yaml
kubectl -n client exec probe -- \
curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://web.demo.svc.cluster.local
# 200
# Prove the AND: a pod in "client" without the app=probe label is still blocked
kubectl -n client run stranger --image=nicolaka/netshoot:v0.16 --rm -it --restart=Never -- \
curl -sS -m 3 -o /dev/null -w '%{http_code}\n' http://web.demo.svc.cluster.local
# curl: (28) Connection timed out
If you put a dash in front of podSelector, you get two list items, which the docs describe as OR: any pod in namespace client, or any app=probe pod in the policy's own namespace. That one character is the most common NetworkPolicy bug I see in review. Run kubectl describe networkpolicy -n demo allow-web-from-client-probe and read how Kubernetes interpreted it.
Select namespaces by the kubernetes.io/metadata.name label rather than custom labels. The control plane sets it on every namespace and nobody can change it, whereas a custom team: payments label can be added by anyone with namespace edit rights.
Step 6: Control egress to external services
Egress is where policies earn their keep against data exfiltration and credential theft. Standard NetworkPolicy can only express external destinations as CIDRs:
# allow-web-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-web-egress-https
namespace: demo
spec:
podSelector:
matchLabels:
app: web
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.169.254/32 # cloud instance metadata: never needed by app pods
- 10.0.0.0/8 # adjust to your pod/service/VPC ranges
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 443
The docs warn that ipBlock is meant for cluster-external IPs and that NAT may happen before or after policy evaluation depending on your plugin and cloud, so test it on your actual platform. Blocking the metadata endpoint matters: it is a classic route from a compromised pod to node or cloud credentials.
CIDRs are a poor fit for SaaS APIs whose IPs change. For DNS-name egress you need a CNI extension. In Cilium, a toFQDNs rule works together with a DNS rule so the agent can see which names resolve to which IPs:
# cilium-fqdn.yaml (Cilium 1.20)
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: web-to-github-api
namespace: demo
spec:
endpointSelector:
matchLabels:
app: web
egress:
- toEndpoints: # let DNS through the Cilium DNS proxy
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
"k8s:k8s-app": kube-dns
toPorts:
- ports:
- port: "53"
protocol: ANY
rules:
dns:
- matchPattern: "*"
- toFQDNs:
- matchName: "api.github.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
Calico documents DNS-based policy for Calico Cloud and Enterprise; check your edition before relying on it.
Step 7: Add cluster-wide guardrails with ClusterNetworkPolicy
Namespaced policies have two gaps the docs list as things NetworkPolicy cannot do: default policies applied to all namespaces, and explicit deny. The SIG Network Policy API project fills both. Its v0.2.0 release (April 2026) merged AdminNetworkPolicy and BaselineAdminNetworkPolicy into one resource, ClusterNetworkPolicy in policy.networking.k8s.io/v1alpha2, with a tier field of Admin or Baseline, Allow renamed to Accept, a new protocols field replacing ports, and a limit of 25 rules and peers. It is still alpha.
Implementation support is real but uneven. Calico 3.32 implements ClusterNetworkPolicy and removed its older ANP and BANP support, so existing ANP/BANP objects must be rewritten before upgrading. Cilium 1.20 supports it, but the Helm value k8sClusterNetworkPolicy.enabled defaults to false because the API is alpha. The implementations page also lists kube-network-policies and Kube-OVN.
# Install the v1alpha2 CRD (standard channel) from the v0.2.0 tag
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/network-policy-api/v0.2.0/config/crd/standard/policy.networking.k8s.io_clusternetworkpolicies.yaml
# Cilium only: turn the feature on (it is off by default in 1.20)
helm upgrade cilium cilium/cilium --version 1.20.3 -n kube-system \
--reuse-values --set k8sClusterNetworkPolicy.enabled=true
# cluster-guardrails.yaml
apiVersion: policy.networking.k8s.io/v1alpha2
kind: ClusterNetworkPolicy
metadata:
name: block-instance-metadata
spec:
tier: Admin # evaluated before any namespaced NetworkPolicy
priority: 10 # 0-1000, lower = checked first
subject:
namespaces: {} # every pod in every namespace
egress:
- name: deny-imds
action: Deny # cannot be overridden by namespace owners
to:
- networks:
- 169.254.169.254/32
---
apiVersion: policy.networking.k8s.io/v1alpha2
kind: ClusterNetworkPolicy
metadata:
name: baseline-deny-cross-namespace-ingress
spec:
tier: Baseline # applies only where no NetworkPolicy selects the pod
priority: 10
subject:
namespaces:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: ["kube-system"]
ingress:
- name: deny-all-ingress-by-default
action: Deny
from:
- namespaces: {}
The Admin policy is a hard rule: namespace owners cannot reopen the metadata endpoint. The Baseline policy is a default that any namespaced NetworkPolicy overrides, which gives you cluster-wide default-deny without writing a policy into every namespace. Roll the Baseline one out to a test cluster first; it will cut traffic to any pod that has no NetworkPolicy of its own.
Step 8: Test and verify policies continuously
Manual curls prove a policy once. To keep proving it, script the checks you just ran (allowed path returns 200, forbidden path times out) and run them in CI against a kind cluster whenever policy YAML changes. A short loop is enough:
#!/usr/bin/env bash
# netpol-smoke.sh: exit non-zero if any expectation fails
set -u
check() { # namespace pod url expected(allow|deny)
if kubectl -n "$1" exec "$2" -- curl -s -m 3 -o /dev/null "$3"; then got=allow; else got=deny; fi
[[ "$got" == "$4" ]] && echo "PASS $2 -> $3 ($4)" || { echo "FAIL $2 -> $3 want $4 got $got"; fail=1; }
}
fail=0
check client probe http://web.demo.svc.cluster.local allow
check client probe http://169.254.169.254/ deny # needs the Step 7 Admin guardrail
exit $fail
For live debugging on Cilium, Hubble shows each drop with the policy verdict: hubble observe --namespace demo --verdict DROPPED. The network-policy-api repo also ships a policy-assistant tool for analysing which policies affect a given flow, useful once you have dozens of policies. Note that Kubernetes itself does not log policy decisions; the docs list logging as something the API cannot do, so visibility depends entirely on your CNI.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Policies applied, nothing is blocked | CNI does not enforce NetworkPolicy (or enforcement is disabled) | Check the CNI in kube-system; enable policy support or install Cilium/Calico |
| Everything fails with DNS timeouts after default-deny | Egress denied to CoreDNS | Add the Step 4 DNS policy (UDP and TCP 53) |
| DNS still fails with a DNS policy in place | NodeLocal DNSCache, or CoreDNS uses a different label or port | Add an ipBlock for the node-local address; match the real pod labels and container port |
| Pods from unexpected namespaces get in | namespaceSelector and podSelector written as two list items (OR) |
Make them one list item (AND); verify with kubectl describe networkpolicy |
| Allowed client still times out | Only one side allows: source namespace has default-deny egress | Add a matching egress rule in the client namespace |
| Traffic via LoadBalancer or Ingress blocked | Source IP is rewritten to a node or LB IP before policy evaluation | Allow from the ingress controller pods by namespace and label, or from node CIDRs |
| Health checks fail after default-deny | Kubelet probes come from the node | Ingress from the pod's own node is allowed by design; check for host-network probes or a CNI-specific setting |
| ipBlock rule to an external IP has no effect | Destination is a Service IP rewritten by NAT, behaviour is plugin-defined | Test on your platform; use FQDN policy from your CNI for SaaS endpoints |
| ClusterNetworkPolicy objects ignored | CRD missing, Cilium flag off, or CNI only supports old ANP/BANP | Install v0.2.0 CRD; set k8sClusterNetworkPolicy.enabled=true; upgrade Calico to 3.32+ |
| New pod has no connectivity for a few seconds | Allow rules land after isolation rules (documented race) | Make apps retry on start, or use an init container that waits for dependencies |
FAQ
Does a Kubernetes NetworkPolicy deny traffic by default?
No. With no policies, all traffic is allowed. A pod becomes isolated for ingress or egress only when at least one policy selects it for that direction, and then only explicitly allowed traffic gets through. That is why every namespace needs an explicit default-deny policy.
Can NetworkPolicy block traffic to a domain name?
Not the standard API, which only understands pods, namespaces and CIDRs. Cilium's toFQDNs rules do it, Calico offers DNS policy in its commercial editions, and ClusterNetworkPolicy has domainNames egress peers in its experimental channel.
Should I use AdminNetworkPolicy or ClusterNetworkPolicy?
ClusterNetworkPolicy. AdminNetworkPolicy and BaselineAdminNetworkPolicy are the v1alpha1 APIs that v0.2.0 replaced, and Calico 3.32 already dropped them. The maintainers plan to base the beta on ClusterNetworkPolicy, so treat it as alpha, but write new guardrails in it.
Do network policies apply to hostNetwork pods?
Usually not. The docs say behaviour is undefined, and the most common implementation treats host-network pod traffic as node traffic and ignores those pods in selectors. Use ipBlock with node IPs if you need to allow or restrict them.
When I review a cluster, the first thing I check is whether a default-deny policy exists in the namespaces that run controllers and admission webhooks, because that is where a compromised pod does the most damage; IngressNightmare was exploitable from any pod because nothing restricted traffic to the webhook. The wider threat picture is in my companion post, Kubernetes Security in 2026. After that, I check that DNS policies exist, that the metadata endpoint is blocked cluster-wide, and that a smoke test like the one above runs in CI. The official references are the Kubernetes Network Policies concept page, the Network Policy API site, and your CNI's policy docs. For admission-side guardrails that complement this, see Policy as Code for AI Agents in 2026.
Related: Container vs microVM vs gVisor: Choosing Agent Isolation, Kubernetes as the Agent Control Plane in 2026 and Ingress NGINX Is Retired: What Replaces It.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Set Up cert-manager with Let's Encrypt and Fix Errors (2026)
Install cert-manager v1.21 with Helm, create Let's Encrypt ClusterIssuers for HTTP-01 (Gateway API or Ingress) and DNS-01, then debug a Certificate stuck not ready by walking CertificateRequest, Order and Challenge, with rate limits, ARI and alerts.
14 minOct 20, 2026How to Use External Secrets Operator with Vault on Kubernetes (2026)
Step-by-step External Secrets Operator v2.13 setup with HashiCorp Vault, OpenBao or AWS Secrets Manager: Helm install, Kubernetes auth, ClusterSecretStore, ExternalSecret, rotation and reloads, ESO's 2025 release pause, alternatives, and fixes for SecretSyncedError.
14 minOct 20, 2026How to Detect Runtime Threats with Falco on Kubernetes (2026)
Step-by-step Falco 0.45 setup on Kubernetes: Helm chart 9.2.0 with the modern eBPF driver, default and custom rules, Falcosidekick alerts to Slack, tuning noise, Falco Talon response actions, drop metrics, and a troubleshooting table for driver errors.
15 min