08

Network policies

By default every pod can talk to every other pod, in every namespace, and to the internet. A NetworkPolicy is a namespaced allow-list: the moment a policy selects a pod for a direction, that pod is isolated in that direction and only what some policy explicitly allows gets through. The API server only stores them — your CNI enforces them, in eBPF or iptables on each node.

Cluster traffic — click any arrow

namespace: shopnamespace: monitoringnamespace: kube-systemoutside the cluster ↓outside the cluster ↓HTTP :3000:8080:5432:5432:9090:909053/udp53/udp:443:443internet client198.51.100.7frontendapp=frontendapiapp=apidbapp=dbprometheusapp=prometheustoolboxapp=toolboxcorednsk8s-app=kube-dnspayments API203.0.113.10unknown host192.0.2.66
━ ━ allowed━━ ⊗ dropped (⊗ sits at the side that drops it)┅┅ allowed by policy, but the pod can't resolve the name

Why?

ALLOWED
frontend → api 8080/TCP

The frontend calls the API.

① egress from frontend: allowed
No policy selects this pod for egress, so it is not isolated for egress: everything is allowed by default.
② ingress to api: allowed
Isolated by 3 policies. Allowed because allow-frontend-to-api has a matching rule.

A connection succeeds only if both ① and ② allow it. Replies on an allowed connection are let through automatically (policies are stateful).

legit flows broken
0
bad flows open
1

Policies in effect

Ingress
  • isolates
  • allows ✓
  • isolates
    from:
Egress

YAML · default-deny-ingress

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: shop
spec:
  podSelector: {}          # every pod in "shop"
  policyTypes: [Ingress]   # ...is now isolated for ingress
  # no ingress rules => nothing is allowed in
Additive, never subtractive
Policies can only add allows. Ten policies selecting the same pod = the union of their rules. There is no deny rule and no ordering in plain NetworkPolicy.
Both ends must agree
A connection needs egress allowed at the source and ingress allowed at the destination. Try default-deny-egress without allow-egress-within-namespace: the API's ingress rule is still there, yet the frontend can't send.
The DNS gotcha
Default-deny egress also blocks lookups to CoreDNS. Apps then fail with name-resolution timeouts, not "connection refused" — which sends people debugging the wrong thing. Hit Forgot DNS above.
the pattern

Default deny, then punch holes

The idiomatic setup per namespace: one policy that isolates everything, one that re-allows DNS, then one small policy per legitimate edge in your app. Note the selectors match kubernetes.io/metadata.name, a label the API server sets on every namespace automatically, so you don't have to remember to label namespaces yourself.
namespace baseline: deny all + DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: shop
spec:
  podSelector: {}                 # all pods in the namespace
  policyTypes: [Ingress, Egress]  # isolate both directions, allow nothing
---
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}
verifying what is actually enforced
# list what's in force
kubectl get networkpolicy -A
kubectl describe networkpolicy -n shop allow-frontend-to-api

# probe from a throwaway pod that carries the frontend's labels
kubectl run probe -n shop --rm -it --restart=Never \
  --labels app=frontend --image=busybox:1.36 -- \
  sh -c 'nslookup api.shop.svc.cluster.local && wget -qO- -T 2 http://api:8080/healthz'

# with Cilium + Hubble: watch the drops live, with the reason
hubble observe --namespace shop --verdict DROPPED --follow
Ports are pod ports
Policies are evaluated after the Service is resolved to a pod (DNAT), so the rule says 8080 (containerPort), not the Service's 80. Named ports work too.
ipBlock and source IPs
ipBlock is meant for addresses outside the cluster. Traffic arriving through a LoadBalancer may already be SNATed to a node IP (externalTrafficPolicy: Cluster), so "allow only office IPs" can silently fail to match — see Services & LB.
yaml is load-bearing

The namespaceSelector / podSelector gotcha

Inside a from or to list, selectors in the same item are ANDed; separate items are ORed. The difference is a single -, and both versions are valid YAML that the API server accepts without complaint.

One dash, very different policy

# policy lives in namespace "shop"
ingress:
- from:
  - namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: monitoring
    podSelector:            # same list item (no dash)
      matchLabels:
        app: prometheus
ns/monitoring
✓ prometheusapp=prometheusgrafanaapp=grafanatoolboxapp=toolbox
ns/shop
frontendapp=frontendmetrics-agentapp=prometheus
ns/staging
prometheusapp=prometheusdebugapp=debug

1 pods may reach the API. One peer with both selectors: the pod must be in a namespace labelled monitoring AND carry app=prometheus. Exactly one pod matches.

cluster admins & CNI extensions

Beyond NetworkPolicy

Plain NetworkPolicy is owned by namespace owners and can only allow. Platform teams usually want guardrails that tenants can't override ("monitoring can always scrape", "nobody reaches the cloud metadata IP"). SIG Network's policy API working group defines cluster-scoped AdminNetworkPolicy and BaselineAdminNetworkPolicy that sit before and after NetworkPolicy. They are alpha CRDs (installed separately), with support in some CNIs — check yours before relying on them. Upstream has since merged both into a single ClusterNetworkPolicy (policy.networking.k8s.io/v1alpha2, network-policy-api v0.2.0, April 2026) with a tier: Admin | Baseline field; ANP/BANP are no longer actively developed, so expect CNIs to migrate.

Evaluation order: admin tiers around NetworkPolicy

AdminNetworkPolicy rule
NetworkPolicy
BaselineAdminNetworkPolicy rule
  1. 1. AdminNetworkPolicycluster admin · priority-ordered · Allow / Deny / Pass

    No AdminNetworkPolicy rule matches — fall through.

  2. 2. NetworkPolicynamespace owner · allow-list only

    No NetworkPolicy selects the pod for this direction — it is not isolated, fall through.

  3. 3. BaselineAdminNetworkPolicycluster admin · singleton 'default' · Allow / Deny

    BaselineAdminNetworkPolicy rule matches with Deny — dropped. This is the cluster-wide 'default deny unless the team opts in' guardrail.

  4. 4. defaultallow everything
  5. → DROPPED
AdminNetworkPolicy + BaselineAdminNetworkPolicy (v1alpha1, superseded by ClusterNetworkPolicy)
apiVersion: policy.networking.k8s.io/v1alpha1
kind: AdminNetworkPolicy
metadata:
  name: always-allow-monitoring
spec:
  priority: 10                      # lower number = evaluated first
  subject:
    namespaces: {}                  # applies to every namespace
  ingress:
  - name: prometheus-scrapes-everything
    action: Allow                   # tenants can't block it
    from:
    - pods:
        namespaceSelector:
          matchLabels:
            kubernetes.io/metadata.name: monitoring
        podSelector:
          matchLabels:
            app: prometheus
---
apiVersion: policy.networking.k8s.io/v1alpha1
kind: BaselineAdminNetworkPolicy
metadata:
  name: default                     # singleton
spec:
  subject:
    namespaces:
      matchLabels:
        tenant: "true"
  ingress:
  - name: deny-cross-namespace-by-default
    action: Deny                    # applies only where no NetworkPolicy decides
    from:
    - namespaces: {}
CiliumNetworkPolicy: L7 HTTP rules + FQDN egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: api
  namespace: shop
spec:
  endpointSelector:
    matchLabels: {app: api}
  ingress:
  - fromEndpoints:
    - matchLabels: {app: frontend}
    toPorts:
    - ports: [{port: "8080", protocol: TCP}]
      rules:
        http:                       # L7: only these requests pass
        - method: GET
          path: "/products.*"
        - method: POST
          path: "/orders"
  egress:
  - toEndpoints:                    # DNS, and let Cilium's proxy see the lookups
    - matchLabels:
        k8s:io.kubernetes.pod.namespace: kube-system
        k8s-app: kube-dns
    toPorts:
    - ports: [{port: "53", protocol: ANY}]
      rules:
        dns:
        - matchPattern: "*"
  - toFQDNs:                        # egress by hostname, not IP
    - matchName: api.stripe.com
    toPorts:
    - ports: [{port: "443", protocol: TCP}]

CNI-native policy

Cilium adds CiliumNetworkPolicy / CiliumClusterwideNetworkPolicy: L7 rules (HTTP method/path, gRPC, Kafka, DNS) enforced by its Envoy proxy, toFQDNs egress that follows DNS answers instead of hard-coded IPs, explicit deny rules, and entities like world, host, kube-apiserver.

Calico adds NetworkPolicy and GlobalNetworkPolicy (projectcalico.org/v3) with an order, and actions Allow / Deny / Log / Pass, plus host-endpoint protection.

Trade-off: richer semantics, but your policies are now tied to one CNI. Start with portable NetworkPolicy and reach for the CRDs when you need L7, FQDNs or admin guardrails.

Calico GlobalNetworkPolicy: ordered, explicit deny
apiVersion: projectcalico.org/v3
kind: GlobalNetworkPolicy
metadata:
  name: deny-metadata-and-smtp
spec:
  order: 100                        # explicit ordering, explicit Deny
  selector: all()
  types: [Egress]
  egress:
  - action: Deny
    protocol: TCP
    destination:
      ports: [25]
  - action: Deny
    destination:
      nets: [169.254.169.254/32]
  - action: Pass                    # hand over to namespaced policies
the CNI decides

On Talos

Talos installs Flannel unless told otherwise. Flannel only builds the pod network — it ignores NetworkPolicy entirely, so your objects are accepted by the API server and silently do nothing. For enforcement, set the CNI to none and install Cilium or Calico (see Cilium on Talos).
machine config patch (controlplane + workers)
# Talos ships Flannel by default — it does NOT enforce NetworkPolicy.
# Disable it (and kube-proxy, if Cilium replaces it) and install a policy-capable CNI.
cluster:
  network:
    cni:
      name: none
  proxy:
    disabled: true
Silent no-op is the worst failure mode
Nothing warns you that policies aren't enforced. After installing a CNI, apply a default-deny to a scratch namespace and prove a request actually fails before trusting any policy in production.
  • why it mattersA pod is open until some policy selects it for a direction; then it's closed except for the union of allows. Both the sender's egress and the receiver's ingress must permit a connection.
  • why it mattersDefault-deny egress breaks DNS first. Always ship an allow-dns policy alongside it, on UDP and TCP 53.
  • why it mattersPolicies are enforced by the CNI, not Kubernetes. Talos' default Flannel ignores them — run Cilium or Calico, and test that a deny really drops traffic.