How to Write Kubernetes NetworkPolicies: Default Deny to Egress (2026)

Oct 20, 2026 · 14 min · Ajay Kumar

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.

How a connection is evaluated with ClusterNetworkPolicy installed New connection pod A to pod B egress of A and ingress of B are both checked 1. Admin tier ClusterNetworkPolicy tier: Admin, checked in priority order (0 first) 2. NetworkPolicy tier namespaced v1 policies written by app teams in each namespace 3. Baseline tier ClusterNetworkPolicy tier: Baseline overridable defaults Accept or Deny: final Pass or no match: go to next tier Pod selected: final allowed only if a rule allows it; else next tier Accept or Deny: final No match at all: traffic is allowed Without ClusterNetworkPolicy only tier 2 exists: a pod selected by any policy is default-deny in that direction. ClusterNetworkPolicy is v1alpha2 (network-policy-api v0.2.0): Calico 3.32 and later, Cilium 1.20 opt-in.
Tier order and actions as defined in the ClusterNetworkPolicy v1alpha2 CRD (network-policy-api v0.2.0); implementation status from the Calico 3.32 and Cilium 1.20 release notes.

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