[email protected]
BelgiumFranceSwitzerlandUnited Arab Emirates
LinkedInFacebook
Kube IT Consulting
My coursesContact us

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.

The order to apply policies in
  1. Allow DNS egressApply this first, on its own, and confirm resolution still works.
  2. Deny all ingressNothing can reach the namespace until you name it.
  3. Allow known callersOne policy per legitimate source, by namespace and pod label.
  4. Deny all egressThe half most teams skip, and the half that limits a breach.
  5. 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.

What a locked-down namespace still permits
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 CertLabs

CertLabs 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