Workflow: Written-Down Paths
What NetworkPolicies Give You
- A small blast radius. When one pod goes wrong - a mistake, a bug, a compromised dependency - it can reach only the paths written down for it. The frontend’s pod can call the backend, and nothing else; if it is taken over, the databases are still out of reach.
- Environments that are separate networks.
develop,stagingandproductionshare one cluster but not their direct paths: no pod indevelopopens a connection to a pod inproduction- the incident shows the hard way what happens without that. They are not sealed off from each other: the backend indevelopmay still reach the public production site over HTTPS, through Cloudflare, like anyone on the internet. - Less to leak. A pod that may open connections only where it needs to cannot send data anywhere else either.
- A map that is true. The policies in Git are the list of who talks to whom, on which port, reviewed like every other change - and the cluster enforces them. Unlike a diagram, they cannot go out of date - as long as nobody adds a policy by hand: one that is not in Git adds allowances the map does not show (Guardrails That Stop It).
- A second layer after RBAC. Chapter 01 decided who may change what. NetworkPolicies decide which pod may connect to which - a different question, asked on every connection.
The price: a new path needs a policy before it works, and a missing one breaks a working path. That failure is loud and local - a probe finds it in minutes. The failure the policies prevent is silent and cluster-wide.
How a NetworkPolicy Works
A NetworkPolicy lives in one namespace and selects pods there by their labels (podSelector). For those pods it lists what is allowed, per direction:
- Ingress - who may connect to them, and on which ports.
- Egress - where they may connect to.
Five rules explain everything the lab shows:
- No policy, no isolation. A pod that no policy selects accepts and opens every connection. That is the flat network.
- Selected means isolated - in that direction. As soon as any policy selects a pod for ingress, the pod accepts only what some policy allows. The same for egress.
default-deny-allsimply selects every pod (podSelector: {}) for both directions and allows nothing. - Policies only add. Every policy adds allowances; none can take one away. Two policies are the union of both. So a deny can never “win” over an allow - and one policy that allows everything undoes all the others.
- Both ends must agree. A connection from an isolated pod to an isolated pod needs the egress allowed on the client and the ingress allowed on the server. The platform writes both:
allow-frontend-egress-backendandallow-backend-ingress. - The port is the container’s. Policies see the traffic after the Service has forwarded it: the backend’s Service listens on 80, the policy allows 8080.
And three details that cause most surprises:
podSelectoralone means “in this namespace”.allow-backend-ingressallows pods labelledapp=frontend- inproductiononly. A pod with the same label indevelopis not let in; other namespaces need anamespaceSelector.- The identity is a label. Any pod labelled
app=frontendgets the frontend’s paths. Who may create pods - and so set labels - in a namespace is RBAC’s job (Chapter 01): on Hetzner the everyday OIDC sign-in (members and admins) has the built-inadminrole indevelop, and nowhere else may create pods. No admission policy restricts theapplabel, so indevelopanyone signed in can start a pod labelledapp=frontend- and it gets the frontend’s path to the backend, as A4 of the lab shows. Flux deploys the workloads from Git into all three environments; the break-glass kubeconfig can do anything. - DNS is a path too. Under a default deny, a pod usually cannot even resolve a name - every connection then fails before it starts. That is why each environment has
allow-dns-egress, its own policy, separate from every application rule.
What NetworkPolicies are not. They decide which connection may be opened - nothing more. They do not authenticate the caller, encrypt the traffic or look at a request. They are one layer of a defence, not “zero trust” on their own. The Kubernetes API also records no blocked connection: a blocked packet is simply dropped, so you find it with a probe, not in events or logs.
The network plugin enforces them. A policy the plugin ignores is a comment - kind’s default plugin, kindnet, enforced them only in part. So the platform runs Cilium on every cluster: on kind (kindnet switched off), on Hetzner, and on GKE, whose Dataplane V2 is Cilium. The same policy then behaves the same everywhere - a default deny blocks DNS too, on your laptop as in production (you will see it in the lab). make cluster-conformance checks on each cluster that policies are enforced at all, egress included (its check C2).
Two address families. Every cluster is dual-stack: each pod has an IPv4 and an IPv6 address, and so does each Service that asks for both - the frontend, the backend and Traefik do; the others, the database’s among them, have IPv4 only. A policy that selects pods by label covers both families. An ipBlock covers one family only - 0.0.0.0/0 says nothing about IPv6 - so every IP range comes as a pair: the IPv4 rule and its IPv6 twin. Leave the twin out and the IPv6 path stays closed under the default deny: a host with an IPv6 address times out, now and then. Write ::/0 without cutting out the private IPv6 ranges and you open what the IPv4 rule closed.
The Platform’s Baseline
Every environment namespace builds the same set from flux/infrastructure/network-policies/base:
| Policy | Allows |
|---|---|
default-deny-all | nothing - isolates every pod, both directions |
allow-dns-egress | every pod → CoreDNS, port 53 (or the node-local DNS cache, where the cluster runs one - GKE does) |
allow-frontend-ingress | traefik namespace → frontend, TCP 8080 |
allow-frontend-egress-backend | frontend → backend, TCP 8080 |
allow-backend-ingress | frontend (same namespace; app=frontend or Flagger’s frontend-primary, Chapter 19) and traefik → backend, TCP 8080 |
allow-backend-metrics-from-observability | the observability namespace (Prometheus) → backend, TCP 8080 |
allow-backend-egress-postgres | backend → its database, TCP 5432 |
allow-postgres-ingress-backend, allow-postgres-ingress-cnpg-operator | backend and the CNPG operator → the database |
allow-backend-egress-https | backend → internet HTTPS (Uptrace), private ranges cut out |
allow-postgres-egress-https, allow-postgres-egress-apiserver | database → backup storage (443) and the API server (6443 on kind and k3s, 443 on managed clouds) |
allow-postgres-replication | the database’s instances ↔ each other, TCP 5432 - production runs a primary and a replica |
allow-postgres-metrics-from-observability | the observability namespace (Prometheus) → database metrics, TCP 9187 |
allow-backend-ingress is the contract for who may call the backend:
Backend ingress policy
Show allow-backend-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-backend-ingress
spec:
podSelector:
matchExpressions:
- key: app
operator: In
values: [backend]
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchExpressions:
- key: app
operator: In
values: [frontend, frontend-primary]
ports:
- protocol: TCP
port: 8080
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik
ports:
- protocol: TCP
port: 8080
DNS egress policy
Show allow-dns-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: coredns
# NodeLocal DNSCache (GKE turns it on): a DNS cache pod on every node answers the kube-dns
# Service address, so the query never reaches a kube-dns pod. Without this peer nothing resolves
# under default-deny - nginx in frontend stopped at "host not found in upstream backend" (GKE,
# 2026-10-15). On clusters without the cache no pod has this label.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: node-local-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Three rules open every address - deliberately. A NetworkPolicy cannot select a host name, only IP ranges, so a path to something outside the cluster is an ipBlock - one for IPv4 and its twin for IPv6:
- the backend → HTTPS on the internet, for Uptrace -
0.0.0.0/0with the private and link-local ranges cut out, and::/0with their IPv6 counterparts cut out (unique localfc00::/7, link-localfe80::/10, the NAT64 prefixes, and the cluster’s own pod range), so it can never reach anything inside the cluster or the provider’s network; - the database → HTTPS, for its backups in S3-compatible storage (
0.0.0.0/0and::/0); - the database → the API server, which is not a pod and has no fixed address across clusters - on 6443 (kind, k3s) or 443 (GKE, EKS, AKS). This one is written as two halves per family (
0.0.0.0/1+128.0.0.0/1,::/1+8000::/1): Cilium reads exactly0.0.0.0/0as “outside the cluster”, which leaves out the nodes - and on kind and k3s the API server runs on a node. The halves reach a node only because the platform sets Cilium’spolicyCIDRMatchMode: nodes(by default an IP rule never matches a node); conformance check C3 proves the path on each cluster.
Each is a trade-off with a remaining risk, written next to the rule in the repository: the backend and the database could send data to any public HTTPS host. Narrowing them needs a stable address range - or an egress proxy, which this platform does not run. “Never 0.0.0.0/0” is not the rule; “never without a port, a reason and the remaining risk written down” is.
One Base, Three Environments
The three environments build on the same base (Chapter 03): a policy changed there changes develop, staging and production at the next reconcile. There is no staged rollout of a base change. So:
- read
flux difffor each environment before the merge, and for everydeletedpolicy ask what it did: an allow policy that goes closes a path; a policy whose selector isolated pods - a default deny, or the only policy selecting some pods - opens them up instead. Read itspodSelectorandpolicyTypes, and what remains; - for a change that should start in
developalone, put it into thedevelopoverlay first, and move it intobaseonce it is proven.
Guardrails That Stop It
- The default deny (cluster). A path that nobody wrote down does not exist - including the one from the incident.
- Flux (cluster). A policy it manages is set back at the next reconcile when someone changes or deletes it by hand (Chapter 03). It does not remove a policy someone added by hand: Flux deletes only what it created. An “allow everything” policy applied with
kubectlstays until someone deletes it - verified on the kind cluster. - The checks on every pull request (CI). Flux Diff builds the policies and validates them against the schema (Chapter 05).
scripts/check-app-security.shrefuses a rule that opens every address (/0, or the/1halves) in one family without the other, and an IPv6 twin that leaves open the private ranges its IPv4 rule cuts out. A narrower range without its twin passes - that one is the reviewer’s. Whether a rule is too wide otherwise is a reviewer’s question - no check answers it. - RBAC (cluster). On Hetzner the everyday OIDC sign-in may write only in
develop(Chapter 01): with it, nobody adds an “allow everything” toproductionby hand. The break-glass kubeconfig could - which is why it is kept for emergencies.
Triage: a Connection Is Blocked
DNS first. Probe from the source’s namespace, with the source’s labels, to the target’s name and port - for example the frontend to the backend in
develop:scripts/np-probe.sh -n develop -l app=frontend backend 80. Replace the namespace, the labels (one-l key=valueper label), the name and the port with your own. OnDNS FAILED, check two things before anything else: is the name right (does the Service exist), and may the source reach CoreDNS - is it egress-isolated without a DNS allow that matches its labels? Only withDNS okgo on to the TCP path. A connection to a dual-stack Service that fails only now and then is often one family: probe each with-4and-6. Only there - for a Service with IPv4 only,-6finds no IPv6 address and printsDNS FAILED, which then says nothing about policies (kubectl get service <name> -o jsonpath='{.spec.ipFamilies}'shows the families).Which policies select the target? Name the two namespaces once - for the incident’s path, the source in
developand the target inproduction:SOURCE_NS=develop TARGET_NS=production kubectl --context kind-sre-control-plane -n "$TARGET_NS" get networkpolicy -o yamlThen read each one whose
podSelectormatches the target’s labels: is there an ingress rule for the source - its labels, its namespace, the container port?Which policies select the source? The same for egress on the source’s side:
kubectl --context kind-sre-control-plane -n "$SOURCE_NS" get networkpolicy -o yaml.Labels and namespaces.
kubectl --context kind-sre-control-plane -n "$SOURCE_NS" get pods --show-labelsand the same with"$TARGET_NS"; for anamespaceSelector, the namespace’s labels.Fix the exact pair in Git - and prove it with the probe after the merge.
Safe Workflow (Step-by-Step)
- Write down the path: source labels and namespace, target labels and namespace, container port.
- Write both ends: egress for the source, ingress for the target - in the overlay for one environment, or in
basefor all three. - Preview:
flux --context kind-sre-control-plane diff kustomization network-policies-develop --path ./flux/infrastructure/network-policies/develop, and the same withstagingandproductionfor every environment the change reaches. - Merge through a pull request (Chapter 05).
- Prove it: the new path open with the right labels, still blocked without them - with
scripts/np-probe.sh.
Instructions for Your AI Agent
The rules this chapter adds:
- Change NetworkPolicies only in Git, through a pull request; read
flux difffor every environment thebasefeeds. Never apply, edit or delete one in an environment namespace by hand. Enforced by: on Hetzner the OIDC roles may write only indevelop. Flux sets back a changed or deleted policy it manages - not one created by hand, which stays: instruction only. - To fix a blocked connection, propose the exact pair - source labels, target labels, port, both directions - never an empty
from/to, apodSelector: {}without ports, or a new0.0.0.0/0rule. Enforced by: the review of the pull request; no check refuses them - instruction only. - Diagnose before proposing: run
scripts/np-probe.sh(DNS and TCP separately) and report which policies select the source and the target. Enforced by: instruction only. - Never give a pod the labels of another application to get through a policy.
Enforced by: instruction only; on Hetzner the everyday OIDC sign-in can create pods only in
develop. - Experiments go into the namespace
lab, and what you created there is deleted afterwards. Enforced by: instruction only. - Every IP range in a policy comes with its twin of the other family -
0.0.0.0/0with::/0, and the private IPv4 ranges cut out meanfc00::/7,fe80::/10,::1/128, the NAT64 prefixes and the cluster’s own pod range (${cluster_ipv6_range}- global on GKE) cut out of::/0. Only a rule that must reach a node uses the/1halves. Never switch IPv6 off or leave a twin out to make a policy look safe. Enforced by:scripts/check-app-security.shin pre-commit and CI, only for the rules that open every address (/0or a/1half): one in IPv4 needs one in IPv6 and the reverse - their forms are not compared - and an IPv4 rule that cuts out the private ranges needs::/0withfc00::/7,fe80::/10and the NAT64 prefixes cut out. Matching forms, narrower twins,::1, the pod range and where the/1halves are used - instruction only.
The full list, chapter by chapter, is docs/agent-rules.md in your fork of the platform repository.
This builds on: CI/CD & Developer Guardrails (Chapter 05) - every policy change reaches the cluster through a pull request whose checks you can read. This enables: Security Context (Chapter 07) - the network limits where a pod may connect; the security context limits what it may do inside.