Default-deny NetworkPolicy without breaking DNS
Locking a namespace to default-deny and adding traffic back safely, why DNS breaks first, and the selector mistake that silently allows far more than intended.
- Allow DNS egressApply this first, on its own, and confirm resolution still works.
- Deny all ingressNothing can reach the namespace until you name it.
- Allow known callersOne policy per legitimate source, by namespace and pod label.
- Deny all egressThe half most teams skip, and the half that limits a breach.
- Allow known destinationsDatabases, APIs, and the CIDRs they live on.
Everyone’s first default-deny NetworkPolicy takes the cluster down in the same way: the pods stay Running, the readiness probes keep passing, and every outbound call starts failing with a name resolution error.
DNS is the reason, and the order you apply the policies in is the fix.
- Ingress allowed
- Named namespaces
- Named pod labels
Everything not listed here is refused once any policy selects the pod.
- The namespace
- api
- worker
- postgres
Selection is what switches enforcement on. Unselected pods still accept everything.
- Egress allowed
- kube-dns :53
- postgres :5432
- Internet :443, minus RFC1918
DNS goes first. Miss it and every outbound call fails as a name lookup.
Two rules that explain most of the confusion
Rules are additive, and there is no deny. A NetworkPolicy only ever allows. You cannot write a rule that blocks something specific; you restrict by removing the allow that covered it. Two policies selecting the same pod produce the union of what they permit.
Selection is what switches enforcement on. A pod with no policy selecting it accepts everything. The moment any policy selects it for a direction, that direction becomes deny-by-default and only the listed rules get through. This is why an empty policy is the deny-all primitive:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: payments
spec:
podSelector: {} # every pod in the namespace
policyTypes: ['Ingress'] # ingress now defaults to deny
podSelector: {} means all pods here, not no pods. That distinction is the one people misread.
Why DNS breaks and what to do about it
Cluster DNS runs as CoreDNS pods in kube-system. A pod resolving postgres.data.svc.cluster.local is making an outbound UDP call on port 53 to that namespace.
Apply a default-deny egress policy and that call is denied along with everything else. The application sees no such host, which reads like a broken Service rather than a firewall.
Apply this before anything else, and verify it on its own:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: payments
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
Two details worth getting right. TCP 53 matters because resolvers fall back to it for large responses, and omitting it produces failures that only appear under specific queries. And kubernetes.io/metadata.name is set automatically on every namespace by the API server, so you do not need to label kube-system yourself.
Check the CoreDNS pod labels on your distribution before copying this — k8s-app: kube-dns is the common value, but not universal:
kubectl get pods -n kube-system -l k8s-app=kube-dns
The selector mistake that allows everything
These two blocks look nearly identical and mean very different things.
# A: pods labelled app=api, in ANY namespace that is labelled team=core
- from:
- namespaceSelector:
matchLabels:
team: core
podSelector:
matchLabels:
app: api
# B: ANY pod in a namespace labelled team=core,
# OR pods labelled app=api in THIS namespace
- from:
- namespaceSelector:
matchLabels:
team: core
- podSelector:
matchLabels:
app: api
One list item with two selectors is an AND. Two list items are an OR. The difference is a single hyphen, and B is far more permissive than most people writing it intend.
Review every policy for this specifically. It does not produce an error, an event, or any symptom at all — the traffic you wanted works, and so does traffic you did not think about.
Egress is the half that matters after a breach
Most teams write ingress policies and stop. Ingress limits who can reach a compromised pod. Egress limits what that pod can reach once someone is inside it, which is the part that decides whether an incident stays local.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-egress
namespace: payments
spec:
podSelector:
matchLabels:
app: api
policyTypes: ['Egress']
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: TCP
port: 443
The second rule allows outbound HTTPS to the internet while excluding private ranges, so a compromised pod cannot scan the internal network. It is a blunt instrument and a good default.
Note that ipBlock matches IP addresses, not names. There is no way to allow api.stripe.com by hostname in a standard NetworkPolicy. If you need name-based egress control, that is a service mesh or a CNI-specific CRD — Cilium and Calico both offer one, and both take you off portable Kubernetes.
Verifying, rather than hoping
A policy that does nothing looks exactly like a policy that works. Test both directions explicitly:
# Should succeed
kubectl run t --rm -it --image=nicolaka/netshoot --restart=Never \
-n payments -- nslookup postgres.payments.svc.cluster.local
# Should hang and time out
kubectl run t --rm -it --image=nicolaka/netshoot --restart=Never \
-n payments -- curl -m 5 http://internal-admin.other-ns.svc.cluster.local
A connection that is denied by policy hangs and times out rather than being refused. An immediate Connection refused means you reached something and it said no, which means your policy did not apply.
That distinction is the single most useful diagnostic here: timeout means blocked, refused means allowed-but-nothing-listening.
One thing that will surprise you
NetworkPolicy is enforced by the CNI plugin, not by Kubernetes. On a cluster whose CNI does not implement it, every policy you apply is accepted by the API server, appears in kubectl get netpol, and does nothing whatsoever.
Confirm enforcement before you rely on it. Apply a deny-all, then try to reach something. If it still works, your policies are decorative.
This material sits in CKS’s largest domain, and the default-deny exercise is the one worth doing on a cluster you can afford to break before you do it on one you cannot.
Next steps
Practise it
Run the CKS track in a real terminal
Every objective on CertLabs is graded against live system state rather than the command you typed, on a sandboxed cluster that resets between exercises. The CKS track covers RBAC, policies, hardening.
Open CertLabsCertLabs is our own practice platform.
Get help
Running this in production?
We operate Kubernetes and OpenShift for clients across the EU and the Gulf, and train the teams who inherit them. Platform assessments, migrations and hands-on enablement.
Talk to us