Kubernetes network policies: default-deny, keep DNS up

Lock down pod-to-pod traffic with a default-deny NetworkPolicy, then re-allow DNS and the flows your apps actually need.

Jul 3, 2026·Updated ·6 min readAdvanced·By SecOpsLog · command-tested

In a cluster without NetworkPolicy every pod can open a connection to every other pod, in every namespace, on every port. A compromised front-end can therefore reach the billing database directly, and the only thing stopping it is that nobody told it the address. NetworkPolicy objects are the fix, with two properties that shape the rollout: they are enforced by the CNI plugin, not by Kubernetes itself, and they are purely additive, so the first policy that selects a pod flips it from allow-all to deny-all for the direction the policy names, and every later policy only adds allowances.

Default deny with selective allow

Once a pod is selected by any policy for a direction, that direction is closed except for what the policies open. DNS egress is the allowance everyone forgets and everyone needs.

namespace: prod policy: default-deny ingress: from role=web egress :53 web pod role=web · matched reporting pod role=reporting · no rule public internet 0.0.0.0/0 · external CoreDNS kube-dns · UDP/TCP 53 payments app=payments policy target · deny-all + allow A pod is unrestricted until a policy selects it — then it is deny-all except the explicit allows.
Allowed — web ingress reaches payments Blocked — stopped at the default-deny boundary DNS — egress to CoreDNS on :53

Step 0: confirm something enforces it

A NetworkPolicy object is accepted by the API server whether or not the network plugin implements it. Calico, Cilium and the managed CNIs on the major clouds do; plain Flannel does not, and a cluster on Flannel will store your policies and enforce nothing. The test is empirical: apply a deny-all to a scratch namespace and check that a connection that worked before now times out. If it still connects, no amount of additional YAML will change that. One detail the run made obvious: test by ClusterIP, not by name. A deny of both directions also cuts the pod off from CoreDNS, so the first thing a probe by name reports is a failed lookup, which says nothing about enforcement; the timeout against the IP is the evidence.

bash — observed: flat network, then the enforcement test (kind 1.35.0 with kindnet; busybox nc from the web pod)observed
kubectl exec -n shop web -- nc -zv -w 3 db 5432
db (10.96.249.231:5432) open
kubectl apply -f default-deny.yaml && sleep 4
kubectl exec -n shop web -- nc -zv -w 3 db 5432
nc: bad address 'db'
the name no longer resolves: egress to CoreDNS is denied too, so this line proves nothing about the connection yet
kubectl exec -n shop web -- nc -zv -w 3 10.96.249.231 5432; echo exit=$?
nc: 10.96.249.231 (10.96.249.231:5432): Connection timed out
exit=1
timed out by IP after the deny: this CNI enforces policy. Still open: it does not, stop here

Step 1: deny both directions

default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: shop
spec:
podSelector: {} # every pod in the namespace
policyTypes: [Ingress, Egress]
# no ingress: or egress: rules, so nothing is allowed in either direction

Listing Egress in policyTypes is what most examples leave out and what makes this a real boundary: without it, a compromised pod can still reach every other namespace and the internet. With it, the namespace also loses DNS, because CoreDNS lives in kube-system and name resolution is an egress connection; that is the bad address above. Every application in the namespace now fails on the first lookup, so the next policy goes in the same change, not the next morning.

Step 2: give DNS back

allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: shop
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
podSelector:
matchLabels: { k8s-app: kube-dns }
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 } # large responses and DNS-over-TCP fall back to TCP

The to entry combines a namespaceSelector and a podSelector in one list item, which means both must match: CoreDNS pods, in kube-system. Written as two separate list items the same selectors would mean either, which allows every pod in kube-system plus every pod labelled k8s-app: kube-dns anywhere. The kubernetes.io/metadata.name label is set on every namespace by the API server, so it is the reliable way to select a namespace by name.

Step 3: allow the flows the architecture needs, on both ends

Policies match pod labels, not Service names, so the selector must match the labels on the Pod template of the Deployment, and a flow has two ends: the destination's ingress policy must admit the source, and, once egress is denied, the source's egress policy must admit the destination. Forgetting the second half produces the classic symptom of web timing out on db:5432 although db clearly allows it; the run reproduced it exactly, with db-accepts-web applied alone the probe still timed out, and it opened only when web-reaches-db followed.

web-to-db.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: db-accepts-web
namespace: shop
spec:
podSelector:
matchLabels: { app: db }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: web }
ports:
- { protocol: TCP, port: 5432 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: web-reaches-db
namespace: shop
spec:
podSelector:
matchLabels: { app: web }
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels: { app: db }
ports:
- { protocol: TCP, port: 5432 }

Step 4: prove the denied path

A policy that has only been tested from the pod it allows has not been tested. Run the same probe from a pod without the app: web label and expect a timeout; run it from another namespace and expect the same. The two results together are what the policy claims, and they are what to re-run after every CNI upgrade.

bash — observed: the allowed path, the denied paths, and the way backobserved
kubectl exec -n shop web -- nc -zv -w 3 db 5432 # allow-dns, db-accepts-web and web-reaches-db applied
db (10.96.249.231:5432) open
kubectl run probe -n shop --rm -i --image=busybox:1.37 --labels=app=probe -- nc -zv -w 3 db.shop.svc 5432
nc: db.shop.svc (10.96.249.231:5432): Connection timed out
kubectl run probe -n default --rm -i --image=busybox:1.37 -- nc -zv -w 3 db.shop.svc 5432
nc: db.shop.svc (10.96.249.231:5432): Connection timed out
kubectl exec -n shop web -- nc -zv -w 3 10.96.249.231 5433 # a port the policy does not list
nc: 10.96.249.231 (10.96.249.231:5433): Connection timed out
unlabelled pod in the namespace: denied. Pod from another namespace: denied. Allowed pod, wrong port: denied. That is the policy
kubectl delete networkpolicy --all -n shop && sleep 4
networkpolicy.networking.k8s.io "allow-dns" deleted from shop namespace
networkpolicy.networking.k8s.io "db-accepts-web" deleted from shop namespace
networkpolicy.networking.k8s.io "default-deny" deleted from shop namespace
networkpolicy.networking.k8s.io "web-reaches-db" deleted from shop namespace
kubectl run probe -n default --rm -i --image=busybox:1.37 -- nc -zv -w 3 db.shop.svc 5432
db.shop.svc (10.96.249.231:5432) open
the reversal of a deny that broke something is the deletion of the policy objects: on the fixture cluster every path was open again by the time the fixture re-probed, four seconds later, including the ones you did not mean to reopen. Restore the intended set in one apply

Mistakes that look like a broken cluster

SymptomCauseFix
Everything hangs after the denyegress denied, DNS includedapply allow-dns in the same change
Policy applied, traffic still flowsCNI does not enforce NetworkPolicya CNI that does; the YAML is inert until then
web times out although db allows itonly the ingress half existsan egress policy on web to db
A rule allows far more than intendednamespaceSelector and podSelector as separate list items (OR)one list item with both selectors (AND)
Selector matches nothingselector uses Service name or Deployment labelslabels from the Pod template
502s from the Ingress after the denythe ingress controller’s pods are no longer allowed in (kubelet probes come from the node and are usually unaffected)allow ingress from the controller’s namespace and pods on the app port

When a policy change breaks the namespace: recovery in order of preference

SymptomDiagnosisReversal and verification
every lookup fails right after a deny (bad address)egress is denied and allow-dns is missing; kubectl get networkpolicy -n <ns> shows only the denyapply allow-dns; verify with a probe by name from an affected pod (executed: the name resolved again, the connection stayed denied until its own policy came)
a flow that should be allowed times out although the destination policy admits itonly the ingress half exists; kubectl describe networkpolicy on the source shows no egress rule to the destinationapply the source egress policy; verify with the probe from the allowed pod (executed)
the namespace is down and the right set of policies is unclearmore than one policy was changed at oncekubectl delete networkpolicy --all -n <ns> restores the flat network (executed on kindnet: open again at the four-second re-probe; how fast another CNI converges is its own property), then re-apply the reviewed set in one commit; verify the allowed and the denied probes both
policies applied, nothing changesthe CNI does not enforce NetworkPolicynothing to revert; the probe by ClusterIP after a deny is the test, and until a CNI that enforces is installed the objects are inert (documentation-backed for CNIs other than kindnet)
What was run for this article
Kubernetes 1.35.0 on a single-node kind v0.31.0 cluster created and deleted by the fixture (kindest/node:v1.35.0 by digest, kubectl v1.35.0, Docker Engine 28.5.2 on linux/arm64). kind’s kindnet CNI enforces NetworkPolicy, so the four policies of this article were applied one at a time in a namespace holding a busybox listener on 5432 behind a Service and a busybox client. The terminal blocks marked observed are copied from that run; seventeen exit codes are asserted: open, DNS lost, timeout by IP, DNS back, the ingress half alone, both halves, the three denied paths, and the deletion. Calico, Cilium, Flannel and the managed CNIs were not tested; their enforcement is documentation-backed, and the wording of nc errors is busybox’s.
Write the matrix down before the third namespace
Each policy is small; the set of them is the only map of which workload may talk to which. Keep that matrix in the repository next to the policies, review changes to it the way you review firewall rules, and generate the policies from it if the cluster grows past a handful of namespaces.

NetworkPolicy limits where a compromised pod can connect. It says nothing about what that pod can ask the API server for (RBAC) or whether it runs as root with host mounts (Pod Security Admission); the three together are the boundary around one bad workload.

Related posts

Quick reference