What a Kubernetes network policy is and who enforces it
A NetworkPolicy is a namespaced Kubernetes object that declares which traffic is allowed to and from a set of pods. The API server only validates and stores it. Enforcement belongs entirely to the CNI plugin. If your plugin doesn't implement NetworkPolicy, the API server still accepts every policy you apply, and nothing changes on the wire.
Isolation semantics
By default, a pod is non-isolated: it accepts all ingress and can send all egress. A pod becomes isolated for a direction only when at least one policy selects it through podSelector and includes that direction in policyTypes. From then on, only traffic that some rule explicitly allows is permitted in that direction.
NetworkPolicy has no deny rules and no priority ordering. Policies are additive, so a pod's effective allowlist is the union of every policy that selects it. A broad policy added later can widen access, and no other policy can take it back.
Why use it, and who owns it
Policies limit lateral movement. A compromised pod can reach only the services it legitimately calls, rather than every database in the cluster. Policies also enforce namespace isolation between teams and environments. A workable split is for platform engineers to own the default-deny baselines per namespace, while backend teams own the allow rules for their services, because they know the actual call graph.
Prove enforcement first
Before writing any real rules, run a deliberate failure test. Start two test pods and confirm that one can reach the other. Then apply an ingress policy that selects the target and has no rules, and retry the connection with a short timeout. If the connection still succeeds, your CNI is not enforcing policy.
#!/usr/bin/env bash
# Deny test: confirm the CNI actually enforces NetworkPolicy.
set -euo pipefail
NS=netpol-test
kubectl create namespace "$NS"
# Target pod (labelled so the policy can select it) and a client pod.
kubectl -n "$NS" run web --image=nginx --labels=app=web
kubectl -n "$NS" run client --image=busybox -- sleep 3600
kubectl -n "$NS" wait --for=condition=Ready pod/web pod/client --timeout=90s
WEB_IP=$(kubectl -n "$NS" get pod web -o jsonpath='{.status.podIP}')
# Baseline: with no policy, web is non-isolated, so this must succeed.
kubectl -n "$NS" exec client -- wget -q -O- -T 3 "" >/dev/null \
&& echo "baseline: reachable (expected)"
# Select web for Ingress with no rules. Nothing is allowed in.
kubectl -n "$NS" apply -f - <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-web-ingress
spec:
podSelector:
matchLabels:
app: web
policyTypes:
- Ingress
EOF
sleep 5 # give the CNI a moment to program the rules
# This must now time out. Success means the CNI ignores NetworkPolicy.
if kubectl -n "$NS" exec client -- wget -q -O- -T 3 "" >/dev/null 2>&1; then
echo "FAIL: still reachable, policy is NOT enforced"
else
echo "OK: connection blocked, CNI enforces NetworkPolicy"
fi
# Clean up afterwards with: kubectl delete namespace netpol-test
A throwaway namespace confirms the client can reach the web pod, applies an ingress policy with no rules that selects web, and then checks that the same request times out, which proves the CNI enforces NetworkPolicy.
Start every namespace at default deny ingress and egress
With enforcement confirmed, give every namespace the same baseline: deny everything, then add back only what each workload needs.
The default deny ingress policy has an empty podSelector ({}), policyTypes: [Ingress], and no ingress rules. An empty selector has no label conditions to fail, so it matches every pod in the policy's namespace, including pods created later. Once a pod is selected by any policy that lists Ingress, only traffic explicitly allowed by some policy can reach it. With no rules, nothing is allowed. Policies are additive, so later allow rules open specific paths without editing this baseline.
Default deny egress has the same shape with policyTypes: [Egress]. Apply it together with a DNS allowance. Allow UDP and TCP port 53 to pods in kube-system. Select the namespace with namespaceSelector on the automatically applied kubernetes.io/metadata.name: kube-system label, and put a podSelector for your DNS pods' labels in the same to entry. Check those labels on your cluster first. TCP matters because large responses fall back to it.
DNS breaks first because almost every outbound call starts with a name lookup. The application then reports timeouts or "host not found" rather than a policy denial, which sends debugging in the wrong direction.
After DNS, these egress dependencies commonly break next:
- API server: used by controllers, operators, and sidecars. Some CNIs match the backend control-plane IPs rather than the Service IP.
- Cloud metadata endpoints such as
169.254.169.254: used for credentials and instance identity. - External APIs: payment, email, and other third-party services.
- Telemetry exporters: traces, metrics, and logs sent to collectors that are often in another namespace.
# Baseline for one namespace: deny all ingress and egress, then re-allow DNS.
# Empty podSelector has no label conditions, so it selects every pod here.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments
spec:
podSelector: {}
policyTypes: [Ingress, Egress] # no rules listed, so nothing is allowed
---
# Additive allow: DNS lookups to cluster DNS pods in kube-system.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: payments
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
# Same list item = AND: pods with this label, in kube-system only.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns # verify: kubectl get pods -n kube-system --show-labels
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53 # large or truncated responses retry over TCP
A default-deny policy covering ingress and egress for every pod in the namespace, followed by an additive policy that re-allows UDP and TCP port 53 to the cluster DNS pods in kube-system.
How it works in practice: a three-tier frontend, api and database example
Assume three namespaces, web, app and data, each already carrying the default-deny policies from the previous section. Nothing flows yet, so every arrow below has to be opened explicitly, and from both ends.
+------------- web -------------+ +------------- app -------------+ +------------ data -------------+
| default deny in/out | | default deny in/out | | default deny in/out |
| | | | | |
| [frontend] --egress 8080--->|---|--> ingress 8080 [api] | | |
| | | [api] --egress 5432-------->|---|--> ingress 5432 [postgres] |
+-------------------------------+ +-------------------------------+ +-------------------------------+
The model is a chain of two narrow hops, each defined by a pair of rules:
- frontend → api. The api namespace gets an ingress policy selecting
app: apipods. It admits only pods labeledtier: frontendinweb, and only on TCP 8080. Inweb, an egress policy ontier: frontendpermits TCP 8080 toapp: apipods inapp. - api → database. In
data, an ingress policy onapp: postgresadmits onlyapp: apipods fromapp, on TCP 5432. The api namespace gets the matching egress rule.
Both halves of each pair are required because policies are evaluated independently at each end of the connection. With egress denied by default, a correct ingress rule on the server still drops every packet the client never sends. Replies need no rules of their own, because enforcement is connection-aware and return traffic on an allowed connection is permitted.
Notice what is missing. The frontend has no path to the database, and the database has no egress at all. A compromised frontend pod therefore cannot reach Postgres directly, even if it has the credentials. Each label and port in these rules removes a lateral-movement path. That is why you should resist widening them to "all pods in the namespace."
# Hop 1 (server side): in namespace app, admit frontend pods from web on TCP 8080 only.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-allow-ingress-from-frontend
namespace: app
spec:
podSelector:
matchLabels: {app: api}
policyTypes: ["Ingress"]
ingress:
- from:
# One list item holding both selectors means AND:
# pods labeled tier=frontend that are in namespace web.
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: web}
podSelector:
matchLabels: {tier: frontend}
ports:
- {protocol: TCP, port: 8080}
---
# Hop 1 (client side): in namespace web, let frontend send to api pods in app on TCP 8080.
# Without this rule, default-deny egress drops the packets before the ingress rule is checked.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: frontend-allow-egress-to-api
namespace: web
spec:
podSelector:
matchLabels: {tier: frontend}
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: app}
podSelector:
matchLabels: {app: api}
ports:
- {protocol: TCP, port: 8080}
The frontend-to-api hop as a matched pair of policies, with an ingress rule in app that admits only tier=frontend pods from web on TCP 8080 and an egress rule in web that lets those pods reach app=api on the same port. The api-to-postgres hop on TCP 5432 follows the same pattern.
Namespace isolation, the AND vs OR trap, and ipBlock
When traffic has to cross a namespace boundary, select the namespace by the kubernetes.io/metadata.name label. The control plane sets this label automatically to the namespace's name, so you don't have to rely on a team remembering to add a custom label such as team: payments.
The most dangerous detail is in how from entries are structured. YAML indentation decides whether two selectors are combined with AND or with OR:
| Structure | Meaning | Effect |
|---|---|---|
One from entry containing both namespaceSelector and podSelector |
AND | Only pods with the matching label, and only in the matching namespace |
Two from entries, one per selector |
OR | Every pod in the matching namespace, plus matching pods in the policy's own namespace |
The only difference is a leading - on the podSelector line. Both versions are valid YAML and pass schema validation, and in a diff they look almost the same. Reviewers should check the dashes in every from and to block on purpose.
For traffic leaving the cluster, use ipBlock with a cidr and except ranges. One common use is to allow 0.0.0.0/0 for egress while excluding private ranges and the cloud metadata address. Avoid ipBlock for pod-to-pod rules. Pod IPs change whenever a pod is rescheduled, and NAT, load balancers, or ingress proxies can rewrite source addresses before the CNI evaluates the packet.
Label-based rules are only as trustworthy as your RBAC. If someone can create pods with arbitrary labels or relabel a namespace, they can place a workload inside an allowed selector. Restrict create, update, and patch permissions on pods and namespaces accordingly.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-allow-frontend
namespace: payments
spec:
podSelector:
matchLabels:
app: api
policyTypes: [Ingress, Egress]
ingress:
# AND: a single from entry. Only app=frontend pods in namespace "web".
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: web # set automatically by the control plane
podSelector: # no leading dash, so it belongs to the same entry
matchLabels:
app: frontend
# OR (too permissive): writing "- podSelector:" creates a second entry,
# which allows every pod in "web" plus app=frontend pods in "payments".
egress:
# External HTTPS only. Private ranges and cloud metadata are excluded.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.169.254/32
ports:
- protocol: TCP
port: 443
# DNS to cluster DNS needs its own egress rule. Not shown here.
A NetworkPolicy that uses a single from entry to AND a namespaceSelector on kubernetes.io/metadata.name with a podSelector, points out the one-dash change that turns it into OR, and limits egress to external HTTPS with ipBlock except ranges.
A repeatable testing workflow for every rule
Policies fail silently, so treat every rule as untested until you have probed it from both sides. The simplest probe is a throwaway pod: kubectl run with --labels and -n lets you impersonate any workload in any namespace, and --rm -it removes it when you exit. From inside, use curl --max-time 3 for HTTP or nc -z -w 3 for raw TCP. The timeout matters because a dropped packet does not fail fast. Without one, a "deny" looks like a hang, and you cannot tell it apart from a slow service.
Write the expected outcome of every probe down before you run it. A matrix like this becomes the input to a script you rerun after each policy change:
| Source (namespace, labels) | Destination | Port | Expected |
|---|---|---|---|
shop, app=frontend |
api |
8080 | allow |
shop, app=frontend |
db |
5432 | deny |
shop, app=api |
db |
5432 | allow |
other, app=api |
db |
5432 | deny |
Include deny cases for every allow. A probe from the right label in the wrong namespace catches the selector mistakes that a happy-path test misses.
When a result surprises you, the API offers no drop log. Unless your CNI exposes flow logs, debugging means changing one variable at a time. Edge behavior also differs between implementations. Verify these on your own cluster rather than assuming:
- Whether host-network pods are matched by policies.
- Whether node-local agents and kubelet health checks bypass the rules.
- Whether applying a policy cuts connections that are already established.
When to avoid it, its limits, and alternatives such as Calico network policy
The built-in API is deliberately narrow, and those limits shape where it fits. Policies are purely additive allow-lists, so you cannot write a rule that denies one source while allowing everything else. There is no cluster-scoped object either, so a default deny must be copied into every namespace, and a namespace created without one is wide open. Matching stops at IPs, ports and labels: there is no HTTP path or method, no DNS name, and nothing in the API logs a dropped packet. That last gap slows debugging, because a dropped connection usually shows up as a timeout with no further information.
CNI-specific CRDs such as Calico network policy fill these gaps. They add explicit deny actions, numeric ordering so rules evaluate predictably, and global policies that apply cluster-wide. The cost is a second policy model to learn and review. These resources also don't carry over if you change CNIs.
| Capability | Built-in NetworkPolicy | Calico-style CRDs |
|---|---|---|
| Explicit deny | No | Yes |
| Rule ordering | No, rules are a union | Yes |
| Cluster-wide scope | No | Yes |
| Logging of matched traffic | No | Available as a rule action |
| Portable across CNIs | Yes | No |
Network policy limits which pods can reach each other at the network level. It does not tell you who is calling. Keep mTLS, service-mesh authorization and application-level auth in place, because a compromised pod that is allowed to connect still needs credentials.
Hold off on strict policies in two cases. The first is when your CNI does not enforce them: the API server accepts the objects, but nothing is blocked. The second is when you have no inventory of real traffic yet, because rules written from guesses tend to break dependencies nobody documented.
Rolling out to an existing cluster: cost and engineering time
Rollout order
Start with the inventory. Use your CNI's flow logs, mesh telemetry, or node connection tables to list each workload's real sources, destinations, and ports. Then write allow rules that cover every observed flow. Remember that any policy that selects a pod isolates it in that direction, so each allow set must be complete before it is applied. Add default deny last. If the inventory was right, the deny changes nothing for legitimate traffic.
Roll out one namespace at a time, starting with the one that has the fewest dependents. Keep each policy in the same repository and directory as the workload manifests it protects, so a new dependency and its policy change ship in one review.
Cost
Most of the upfront time goes into the inventory and into debugging the flows it missed. Egress denies carry the most outage risk. Forgotten DNS, external APIs, and cloud metadata endpoints fail at runtime, often on rarely used code paths. After rollout, every new dependency also needs a policy review.
Illustrative estimate (assumed numbers, not a benchmark): upfront hours = namespaces × (inventory hours + debug hours) = 10 × (4 + 3) = 70 hours. ongoing hours per month = policy changes × review hours = 8 × 0.5 = 4 hours.
Where it pays back
During an incident, a compromised or misbehaving pod can reach only its declared peers, which narrows the investigation. Traffic flows are documented as code instead of tribal knowledge. The test matrix from section 5 can run in CI, where it catches a regression before the deploy that would have broken production.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to kubernetes network policy, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.