NetworkPolicy: Segmenting a Flat Network

Why every pod can reach every other pod by default, how policies compose additively, and the two mistakes that make a policy either useless or an outage.

advanced 18 min lesson hands-on task included

By default, every pod in a cluster can reach every other pod, in every namespace. Namespaces are a name scope, not a firewall. NetworkPolicy is how you change that, and its semantics are unusual enough to be worth learning carefully.


Topic 1: The Model

Four rules govern everything:

1. Default allow. With no policy selecting a pod, all traffic to and from it is permitted.

2. Selecting a pod flips it to default-deny — for that direction only. The moment any policy with policyTypes: [Ingress] selects a pod, all ingress not explicitly allowed is denied. Egress is untouched unless a policy names it.

3. Policies are additive. Multiple policies selecting one pod are unioned. There is no ordering, no priority, and no deny rule — you cannot write “allow everything except X”. You allow, and everything unallowed is denied.

4. Your CNI must implement it. NetworkPolicy objects are inert without a policy-capable CNI. Flannel does not implement them at all — you can apply policies to a Flannel cluster and they will be silently ignored, which is a genuinely dangerous false sense of security.

kubectl get pods -n kube-system | grep -E 'calico|cilium|weave'
# nothing? your policies may be doing nothing

Topic 2: Anatomy

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-allow
  namespace: payments
spec:
  podSelector:
    matchLabels: { app: api }        # WHICH pods this applies to
  policyTypes: [Ingress, Egress]     # WHICH directions become default-deny
  ingress:
    - from:
        - podSelector:
            matchLabels: { app: frontend }        # same namespace
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: gateway }
          podSelector:
            matchLabels: { app: envoy }           # AND — see below
        - ipBlock:
            cidr: 10.0.0.0/8
            except: [10.0.5.0/24]
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels: { app: postgres }
      ports:
        - { protocol: TCP, port: 5432 }

The YAML trap that decides whether your policy is right:

# TWO list items = OR
from:
  - namespaceSelector: { matchLabels: { team: web } }
  - podSelector:       { matchLabels: { app: frontend } }
# "any pod in a team=web namespace" OR "any app=frontend pod in THIS namespace"

# ONE list item with two selectors = AND
from:
  - namespaceSelector: { matchLabels: { team: web } }
    podSelector:       { matchLabels: { app: frontend } }
# "app=frontend pods in a team=web namespace"

One dash. The first version is far more permissive than most people intend, and it is the most common NetworkPolicy bug. Read your own policies looking specifically for this.

podSelector without namespaceSelector means the policy’s own namespace. To allow from another namespace you must include a namespaceSelector. Every namespace carries the automatic label kubernetes.io/metadata.name: <name>, which is the reliable way to target one.


Topic 3: The Default-Deny Baseline

# Deny all ingress in this namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: payments
spec:
  podSelector: {}            # {} = EVERY pod in the namespace
  policyTypes: [Ingress]

Then add targeted allows. This is the correct shape: deny by default, allow deliberately.

Default-deny egress breaks DNS, and this is the classic incident:

spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]

Every pod immediately loses DNS, because CoreDNS lives in kube-system and egress to it is no longer allowed. The symptom is total and confusing — every name lookup fails, nothing else changed, and applications report errors that look like everything is down.

Always pair egress deny with a DNS allow:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  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 }     # TCP too — large responses fall back to it

TCP/53 matters: responses over 512 bytes fall back to TCP, so a UDP-only rule produces intermittent failures on large record sets rather than a clean break.


Topic 4: Patterns Worth Copying

Three-tier isolation:

# frontend accepts only from the ingress controller
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: frontend-ingress, namespace: shop }
spec:
  podSelector: { matchLabels: { tier: frontend } }
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
      ports: [{ protocol: TCP, port: 8080 }]
---
# api accepts only from frontend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: api-ingress, namespace: shop }
spec:
  podSelector: { matchLabels: { tier: api } }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector: { matchLabels: { tier: frontend } }
      ports: [{ protocol: TCP, port: 8080 }]
---
# database accepts only from api
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: db-ingress, namespace: shop }
spec:
  podSelector: { matchLabels: { tier: database } }
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector: { matchLabels: { tier: api } }
      ports: [{ protocol: TCP, port: 5432 }]

Restrict egress to the internet but allow internal traffic:

  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8          # not the VPC
              - 172.16.0.0/12
              - 192.168.0.0/16
              - 169.254.169.254/32  # ← BLOCK THE CLOUD METADATA ENDPOINT

That last line is worth its own mention. 169.254.169.254 is the instance metadata service; a pod that can reach it may be able to obtain the node’s IAM credentials, which is a well-known privilege escalation. Block it in egress policy, and prefer IRSA/Workload Identity with IMDSv2 hop limits.

Allow monitoring to scrape everything:

  ingress:
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: monitoring }
      ports: [{ protocol: TCP, port: 9090 }]

Forgetting this is why metrics vanish the day you enable default-deny.


Topic 5: What NetworkPolicy Cannot Do

LimitationConsequence
No deny rulesCannot express “allow all except X” — only allow-lists
No ordering or priorityPolicies are a union; you cannot override a broad allow
L3/L4 onlyNo paths, methods, or headers. /admin cannot be restricted
No egress FQDNipBlock takes CIDRs, not api.stripe.com
Selects pods, not identitiesA compromised pod with the right labels is allowed
Not enforced by all CNIsFlannel ignores them entirely

The FQDN limit is the one people hit fastest. Cloud provider IPs change constantly, so allow-listing an external API by CIDR is unmaintainable. CNI-specific extensions fill the gap (Cilium’s CiliumNetworkPolicy supports toFQDNs and L7 rules; Calico has GlobalNetworkPolicy with Deny actions and ordering). Using them means leaving the portable spec, which is a reasonable trade if you have made it deliberately.

For L7 authorisation — “only these callers may POST to /admin” — you need a service mesh with mTLS identity, not NetworkPolicy.


Topic 6: Testing and Debugging

Policies fail silently in both directions. A too-narrow policy causes an outage that looks like a network fault; a too-broad one gives you no security while looking correct. Test both.

# Prove the connection works BEFORE applying anything
kubectl run -it --rm probe --image=nicolaka/netshoot --restart=Never -n payments -- \
  curl -sS -m5 http://api:8080/healthz

# Apply, then prove the deny works
kubectl exec -it frontend-pod -n payments -- curl -sS -m5 http://api:8080/healthz   # should work
kubectl exec -it other-pod   -n payments -- curl -sS -m5 http://api:8080/healthz   # should TIME OUT

# What selects this pod?
kubectl get networkpolicy -n payments -o wide
kubectl describe networkpolicy api-allow -n payments

A blocked connection times out; it is not refused. connection refused means something answered — that is not a policy block. timed out with no response is the policy signature. That distinction alone tells you whether to look at NetworkPolicy or at the application.

CNI-specific tooling is far better than the generic view:

# Cilium
cilium connectivity test
kubectl exec -n kube-system ds/cilium -- cilium monitor --type drop
hubble observe --verdict DROPPED --namespace payments

# Calico
calicoctl get networkpolicy -A

hubble observe --verdict DROPPED is close to a superpower here — it shows you exactly which flow was dropped and which policy decided it.

A rollout order that avoids self-inflicted outages:

  1. Apply policies in a non-production namespace first.
  2. Add the allow policies before the default-deny, so nothing breaks when deny lands.
  3. Include DNS and monitoring allows in the same change as the deny.
  4. Apply the default-deny to one namespace at a time, and watch error rates.
  5. Keep a tested rollback (kubectl delete networkpolicy default-deny-egress -n x) in the change ticket.

Try it yourself: Apply a default-deny-egress policy to a test namespace with no DNS exception. Watch every pod fail name resolution while still being able to reach IPs directly. That distinction — IPs work, names do not — is the signature to recognise.

Common mistake: Writing from: with two list items when you meant an AND. The policy then allows any pod in the selected namespace or any pod with the selected label anywhere, which is dramatically broader than intended — and it looks correct at a glance. Check the dashes.