Network Policies

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Services and DNS and Service Discovery describe how any Pod finds and reaches any other Pod or Service; by default, nothing stops it from doing so. A NetworkPolicy (networking.k8s.io/v1) is the object that narrows that default down to an explicit allow-list, at the IP-and-port level (OSI layers 3-4), for a selected group of Pods. This page is written against Kubernetes 1.37; see the section index for the full version baseline. Neither O’Reilly book’s sampler chapters cover NetworkPolicy at all — it is one of the "topics neither book covers" the official documentation alone supplies — so everything below draws only on kubernetes.io, with no "book era" table to reconcile.

The default: every Pod is non-isolated

Before any NetworkPolicy selects a Pod, that Pod is non-isolated: every other Pod, on any namespace, and every external address, can open a connection to it on any port, and it can open a connection to anything in turn. There is no implicit firewall to opt out of — Kubernetes ships with an entirely flat, allow-all Pod network, and isolation is something a NetworkPolicy adds, never something a Pod starts with and a policy relaxes. That matters for reading the rules below correctly: a NetworkPolicy is not a stateful firewall rule with a default DROP, it is closer to a label selector that switches a Pod from "reachable from everywhere" to "reachable only from what an ingress rule names" — the moment spec.podSelector in any NetworkPolicy in the namespace matches a Pod for a given direction (Ingress or Egress), that Pod becomes isolated for that direction, and every rule from every matching policy is then combined additively: if any policy allows a connection, it is allowed, regardless of how many other policies stay silent about it.

The NetworkPolicy object

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: catalog-api-to-postgres
  namespace: bookshelf
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: postgres
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: catalog-api
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: bookshelf
          podSelector:
            matchLabels:
              app.kubernetes.io/name: catalog-api
      ports:
        - protocol: TCP
          port: 5432
Field Meaning

spec.podSelector

Which Pods, in this namespace only, the policy applies to. An empty selector ({}) means "every Pod in the namespace" — the shape used below for a default-deny policy. NetworkPolicy is always namespace-scoped: a policy in bookshelf can select peers from another namespace via namespaceSelector, but it can never select target Pods living in a different namespace.

spec.policyTypes

Which directions this policy governs: Ingress, Egress, or both. If omitted, it defaults to ["Ingress"] unless the policy also has an egress list, in which case both are inferred — spelling it out explicitly, as every example on this page does, avoids that easy-to-miss default and makes an egress-only policy’s intent unambiguous.

spec.ingress[].from / spec.egress[].to

A list of peers, each one of podSelector (Pods in the same namespace as this policy, unless paired with namespaceSelector in the same list entry, which narrows it to Pods matching both selectors and that namespace label), namespaceSelector alone (every Pod in any namespace matching the label), or ipBlock (a CIDR range with an optional except list of narrower CIDRs to carve back out). Entries in the same from/to list are OR’d together; a podSelector and a namespaceSelector given together in one list entry are AND’d, as the second entry above shows for "`catalog-api` Pods, but only within the bookshelf namespace".

spec.ingress[].ports / spec.egress[].ports

Which protocol (TCP, UDP or SCTP) and port (a number, a named port, or — since Kubernetes 1.25 — an endPort to close a contiguous range) the rule allows. Omitting ports entirely allows all ports to the matching peers.

A NetworkPolicy is enforced from both ends of a connection at once: catalog-api-to-postgres above only grants ingress into postgres; catalog-api still needs no egress policy of its own to reach it, because it remains non-isolated for egress until some other policy selects them (the allow-DNS-egress policy below is the first thing in Bookshelf that does). Traffic to and from the node a Pod runs on is always allowed regardless of any policy, a Pod’s reply to a connection it initiated is always allowed (NetworkPolicy has no notion of "half-open" a legitimate response can violate), and a Pod can never be blocked from reaching itself.

Default-deny-all

The single most common NetworkPolicy in any real cluster is not one that allows a specific flow, but one that switches a whole namespace from the flat, open default to "nothing, unless another policy says otherwise" — written with an empty podSelector and no rules at all:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: bookshelf
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

podSelector: {} matches every Pod in bookshelf; an empty ingress list under policyTypes: [Ingress] means "no rule allows anything", so every selected Pod becomes fully isolated for ingress and egress alike, with no traffic getting through until a more specific policy — catalog-api-to-postgres above, or the allow-DNS-egress policy below — opens a named hole back up. Applying this first, before any allow policy exists, is the standard way to move a namespace from implicit-allow to explicit-allow without a gap where everything is briefly reachable; applying it after other allow policies already exist changes nothing for the flows those policies already permit, since NetworkPolicies compose additively regardless of the order they were created in.

Allowing DNS egress

A default-deny-all policy has one immediate, easy-to-miss consequence: it also blocks the DNS lookups every Pod needs before it can reach anything by name, including the CoreDNS lookups DNS and Service Discovery describes for postgres.bookshelf.svc.cluster.local itself. An egress rule that allows only DNS, applied to every Pod alongside the default-deny policy, is the standard fix:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: bookshelf
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

Both UDP/53 and TCP/53 are listed because CoreDNS answers most lookups over UDP but falls back to TCP for answers too large for a single UDP datagram; the namespaceSelector + podSelector pair targets CoreDNS’s own Pods specifically (labeled k8s-app: kube-dns in kube-system on a stock kubeadm or kind cluster) rather than opening egress to the whole kube-system namespace. kubernetes.io/metadata.name, the label every namespace carries automatically since Kubernetes 1.22 with its own name as the value, is what makes a namespaceSelector usable against a specific namespace at all without that namespace having to be labelled by hand first.

The Bookshelf policies: only catalog-api reaches PostgreSQL

Putting the pieces together, Bookshelf's bookshelf namespace ends up with three NetworkPolicies: the default-deny-all above, the allow-DNS-egress above, and catalog-api-to-postgres from the top of this page, narrowing PostgreSQL’s reachable clients down to catalog-api alone. Two details finish the picture:

  • Nothing above grants catalog-api egress to postgres explicitly — only ingress into postgres is restricted. Since catalog-api is itself selected by default-deny-all’s empty `podSelector, it also needs an egress policy allowing it out to postgres:5432, mirroring catalog-api-to-postgres from the other direction:

    apiVersion: networking.k8s.io/v1
    kind: NetworkPolicy
    metadata:
      name: catalog-api-egress-to-postgres
      namespace: bookshelf
    spec:
      podSelector:
        matchLabels:
          app.kubernetes.io/name: catalog-api
      policyTypes:
        - Egress
      egress:
        - to:
            - podSelector:
                matchLabels:
                  app.kubernetes.io/name: postgres
          ports:
            - protocol: TCP
              port: 5432
  • reviews-api, web and notifier are deliberately left with no rule mentioning postgres at all: once default-deny-all isolates every Pod, the absence of an allowing rule is the block — there is no separate "explicit deny" object to write, unlike a traditional firewall’s explicit DENY rule. Attempting psql -h postgres.bookshelf … from a reviews-api or web Pod after these four policies are applied times out rather than being refused, which is normal for NetworkPolicy: packets are silently dropped, not rejected with a TCP RST or an ICMP error.

Selectorless Services and ipBlock peers

Not every peer a policy needs to reference is a Pod inside the cluster. ipBlock names a raw CIDR range instead of a label selector, which is how a policy allows or restricts traffic to something outside the Pod network entirely — a selectorless Service pointing at a legacy database reachable only by IP, or a partner API Bookshelf calls out to on a known address range:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: notifier-egress-to-partner-api
  namespace: bookshelf
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: notifier
  policyTypes:
    - Egress
  egress:
    - to:
        - ipBlock:
            cidr: 203.0.113.0/24
            except:
              - 203.0.113.128/28
      ports:
        - protocol: TCP
          port: 443

except carves a narrower range back out of the allowed block — here, a /28 reserved for the partner’s own internal tooling, not notifier’s traffic to reach. `ipBlock addresses are always evaluated against a Pod’s observed source or destination IP, never against a Service’s ClusterIP, since a NetworkPolicy sits below the level a Service’s virtual IP is rewritten at; the deprecated Endpoints API and its EndpointSlice successor (Services) exist to make that distinction moot for selectorless Services targeting Pods, but an external, non-Kubernetes address is exactly where ipBlock is the only option.

CNI requirement: does kind’s default kindnet enforce NetworkPolicy?

A NetworkPolicy object is only ever a request — nothing in the API server itself blocks a packet. Enforcement is entirely the job of the CNI plugin, which must watch NetworkPolicy objects and program the node’s packet filtering (iptables, nftables or an eBPF dataplane) to match. Applying the manifests above to a cluster whose CNI ignores NetworkPolicy silently does nothing: kubectl get networkpolicy still shows the objects, kubectl describe still shows their rules, and every connection this page describes as blocked keeps working exactly as before.

kind’s default CNI, kindnetd, gained out-of-the-box NetworkPolicy enforcement in kind v0.24.0 (August 2024), built on the sigs.k8s.io/kube-network-policies controller, with a DNS-handling fix following in v0.25.0, so a default kind create cluster on any recent kind release enforces every policy above with no extra installation step. Confirm this on any given cluster rather than taking it on faith, since it depends on which kind version actually created the cluster and whether networking.disableDefaultCNI was set:

kind version
kubectl get pods -n kube-system -l app=kindnet -o wide
kubectl get pods -n kube-system -l app=kindnet \
  -o jsonpath='{.items[0].spec.containers[0].image}'
kind v0.33.0 go1.24.1 darwin/arm64
NAME                READY   STATUS    RESTARTS   AGE   IP           NODE
kindnet-7f9x2       1/1     Running   0          5m    172.18.0.3   bookshelf-control-plane
kindnet-p2m6q       1/1     Running   0          5m    172.18.0.4   bookshelf-worker
kindest/kindnetd:v20260528-9350166c

The most direct check is the temporary-Pod test below: apply default-deny-all, confirm a Pod that used to succeed now times out, then delete the policy and confirm it succeeds again. If enforcement is genuinely absent — an older kind version, or a cluster created with disableDefaultCNI: true and no NetworkPolicy-capable CNI installed in its place — the standard fallback is to install Calico on the kind node network instead of kindnet, following Calico’s own kind quickstart:

kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.30.3/manifests/calico.yaml
kubectl -n kube-system rollout status daemonset/calico-node

Source: kind Docs — Configuration (disableDefaultCNI) and Calico Docs — Install Calico Networking and Network Policy for Kubernetes. Cilium is a common alternative offering the same NetworkPolicy enforcement plus its own CRDs for L7 and DNS-aware policies; see Cluster Networking, CNI and Service Mesh for a fuller comparison of CNI plugins beyond NetworkPolicy enforcement alone.

AdminNetworkPolicy, in brief

NetworkPolicy objects are always namespace-scoped and owned by whoever can create objects in that namespace — there is no cluster-wide policy a platform team can set that an application team’s own namespaced policies cannot override or leave gaps in. The sigs.k8s.io/network-policy-api subproject (a separate CRD-based API, group policy.networking.k8s.io, not part of core Kubernetes) fills that gap with AdminNetworkPolicy and BaselineAdminNetworkPolicy — cluster-scoped policies a platform operator sets, evaluated before (admin tier) or after (baseline tier, as a fallback default) every namespace’s own NetworkPolicy objects. As of this project’s current v1alpha2 iteration, those two resources have been superseded in the API’s own roadmap by a unified ClusterNetworkPolicy resource carrying both an Admin and a Baseline tier in one object; none of this family is part of the core, stable networking.k8s.io/v1 API this page otherwise describes, and support depends entirely on the CNI in use — Calico, Cilium and OVN-Kubernetes each implement some portion of it, kindnet does not. Bookshelf does not need a cluster-scoped policy, so this section stops at naming the mechanism rather than working an example.

Testing with a temporary Pod

Every policy above is worth confirming against the live cluster rather than trusting the manifest, since a typo in a label selector fails silently the same way an unenforced CNI does — both look like "nothing happened" from kubectl apply’s point of view. A short-lived, throwaway Pod is the standard tool, created with `--rm so it is deleted the moment the shell session exits:

# from inside the bookshelf namespace, as catalog-api would see it
kubectl run netpol-test -n bookshelf --rm -it --restart=Never \
  --image=nicolaka/netshoot --labels="app.kubernetes.io/name=catalog-api" -- \
  sh -c 'nc -zv -w3 postgres.bookshelf.svc.cluster.local 5432'

# from web, which no policy allows into postgres
kubectl run netpol-test -n bookshelf --rm -it --restart=Never \
  --image=nicolaka/netshoot --labels="app.kubernetes.io/name=web" -- \
  sh -c 'nc -zv -w3 postgres.bookshelf.svc.cluster.local 5432'
Connection to postgres.bookshelf.svc.cluster.local 5432 port [tcp/postgresql] succeeded!

nc: connect to postgres.bookshelf.svc.cluster.local port 5432 (tcp) timed out: Operation now in progress

The --labels flag matters as much as the image: catalog-api-to-postgres’s ingress rule matches on `app.kubernetes.io/name, so a test Pod carrying the wrong label is indistinguishable, from the policy’s point of view, from any other unauthorised Pod, regardless of which real Deployment it is meant to stand in for. Repeating the same check with the default-deny-all and allow-dns-egress policies temporarily deleted (kubectl delete networkpolicy --all -n bookshelf) and confirming every combination now succeeds is a useful sanity check that the failures above came from the policies and not from a Service or DNS problem (Debugging and Troubleshooting covers that failure mode, "NetworkPolicy block", as one row of its failure-state table).

📊 The allowed flows

flowchart LR subgraph "bookshelf namespace (default-deny-all + allow-dns-egress)" CA["catalog-api"] RA["reviews-api"] WEB["web"] NOT["notifier"] PG["postgres StatefulSet"] end DNS["CoreDNS (kube-system)"] PARTNER["partner API (ipBlock 203.0.113.0/24)"] CA -->|"TCP 5432, allowed"| PG RA -.->|"TCP 5432, blocked"| PG WEB -.->|"TCP 5432, blocked"| PG CA -->|"UDP/TCP 53"| DNS RA -->|"UDP/TCP 53"| DNS WEB -->|"UDP/TCP 53"| DNS NOT -->|"UDP/TCP 53"| DNS NOT -->|"TCP 443, allowed"| PARTNER