How a Kubernetes network policy is evaluated
A NetworkPolicy is a namespaced object that selects pods by label and lists the traffic those pods may send or receive. By default every pod is non-isolated: it accepts traffic from anywhere and can send traffic anywhere. A pod becomes isolated for ingress only when at least one policy selects it and includes Ingress in its policyTypes. Egress works the same way, independently. A pod can therefore be locked down for incoming traffic while its outbound traffic stays open.
Once a pod is isolated in a direction, it only allows connections that match at least one rule in at least one policy that selects it. Policies are additive. The allowed set is the union of all matching rules. There is no deny rule, no priority and no ordering, so adding a policy can only widen access, never narrow it.
A connection must pass two checks:
- The source pod's egress policy must allow it, if that pod is isolated for egress.
- The destination pod's ingress policy must also allow it.
Reply packets on an allowed connection are permitted automatically.
namespace: kube-system namespace: backend
+------------------+ +--------------------------+
| DNS pods :53 |<-----------| api ---------> orders |
+------------------+ egress | (egress) (ingress) | |
^ +------------------------|---+
|__________________________ orders egress ______|
|
v
external DB (ipBlock :5432)
Enforcement is done by the CNI plugin, not by the API server. If the plugin does not implement NetworkPolicy, the API server still accepts and stores your objects, but the plugin ignores them and traffic is not restricted. No error is reported. Before you rely on a policy, confirm that your cluster's plugin enforces it.
namespace: kube-system namespace: backend
+----------------------+ +--------------------------------------+
| +----------------+ | (1) | +---------+ (2) +----------+ |
| | DNS pods |<-+-------+--| api |---------->| orders | |
| | UDP/TCP :53 |<-+---+ | +---------+ +----------+ |
| +----------------+ | | | | | |
| | +---+----------(3)-------------+ | |
+----------------------+ +--------------------------------|-----+
(4) |
v
+----------------------+
| external database |
| ipBlock, TCP :5432 |
+----------------------+
(1) api egress rule allows DNS lookups in kube-system
(2) api egress rule AND orders ingress rule must both allow
(3) orders egress rule allows DNS lookups in kube-system
(4) orders egress rule allows the external DB by ipBlock
Reply packets on an allowed connection return automatically.
After api and orders are isolated, only the paths that match an allow rule work: DNS lookups from both pods, api to orders when api's egress rule and orders' ingress rule both permit it, and orders to the external database through an ipBlock egress rule.
Why and when to start each backend namespace with a default deny policy
By default, a pod accepts traffic from any source and can reach any destination. That openness is implicit: no object in the cluster records it, so nobody reviews it. A default deny policy reverses this. Every allowed flow then has to be written down as a NetworkPolicy, and the namespace's network surface becomes something you can read in a pull request.
The teams that gain most are those running multi-team clusters, where one compromised or misconfigured service should not be able to reach another team's database, and those in regulated environments, where auditors ask you to show which workloads can talk to which. Apply it when you create a backend namespace, before any workloads are deployed. At that point nothing depends on open traffic, so nothing breaks. Existing namespaces need the staged approach covered later.
Use two objects. The first, default-deny-ingress, has an empty podSelector: {}, which selects every pod in the namespace, and policyTypes: [Ingress] with no ingress rules. The second, default-deny-egress, is identical except that it sets policyTypes: [Egress].
The explicit policyTypes field matters most for egress. If you omit it, Kubernetes always assumes Ingress, and it adds Egress only when the spec contains egress rules. A deny-egress policy has no egress rules, so without the field it would silently become an ingress deny, and outbound traffic would stay open.
Keeping ingress and egress denial in separate objects makes debugging easier. During an incident you can delete or reapply one direction independently to find out which side is dropping packets. The object names also show your intent in kubectl get networkpolicy output.
# Two separate default-deny objects for a new backend namespace.
# Apply before any workloads are deployed.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: payments-backend
spec:
# Empty selector: applies to every pod in the namespace.
podSelector: {}
# Ingress listed with no ingress rules: all inbound traffic is denied.
policyTypes:
- Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: payments-backend
spec:
podSelector: {}
# Must be explicit: without this field Kubernetes would infer
# only Ingress (there are no egress rules), and outbound traffic
# would remain open.
# Note: this also blocks DNS until you add an egress allow rule for it.
policyTypes:
- Egress
Separate ingress-deny and egress-deny NetworkPolicies with an empty podSelector and explicit policyTypes, so each direction can be reviewed, applied, or removed independently.
Restoring DNS and service-to-service traffic with label-based allow rules
The first thing a default deny breaks is DNS. Every outbound call starts with a lookup, so the symptom is name-resolution timeouts, not connection refusals. The lookup packets are dropped silently, and clients wait out their resolver timeout before failing. Add an egress rule that allows UDP and TCP port 53. TCP is needed because large responses fall back to it. Scope the rule with a namespaceSelector and a podSelector in the same to entry, so traffic is allowed only to the DNS pods inside the DNS namespace. Don't guess the labels. Run kubectl get pods -n kube-system --show-labels and kubectl get namespace kube-system --show-labels. Many clusters label CoreDNS k8s-app=kube-dns, and namespaces carry kubernetes.io/metadata.name, but managed distributions vary.
For service-to-service traffic, write an ingress policy on app=orders that admits pods labelled app=api. Under egress isolation that is only half the path. The api pods also need an egress rule that permits traffic to app=orders. A connection succeeds only if the sender's egress and the receiver's ingress both allow it.
Ports trip people up. Policies are enforced after the Service address has been translated to a pod IP. That means you must list the container port, the Service's targetPort, and not the Service port. Named ports such as http resolve against each selected pod's container spec. That helps when pods expose the same name on different numbers. For contiguous ranges, set port to a number and add endPort. Named ports cannot be used with ranges.
# api pods: allow DNS and calls to orders on top of a default deny.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-egress
namespace: shop
spec:
podSelector:
matchLabels: {app: api}
policyTypes: [Egress]
egress:
- to: # one entry, so namespace AND pod labels must both match
- namespaceSelector:
matchLabels: {kubernetes.io/metadata.name: kube-system}
podSelector:
matchLabels: {k8s-app: kube-dns} # confirm with --show-labels
ports:
- {protocol: UDP, port: 53}
- {protocol: TCP, port: 53} # large responses fall back to TCP
- to:
- podSelector:
matchLabels: {app: orders}
ports:
- {protocol: TCP, port: http} # named port, resolved per pod (8080 here)
---
# orders pods: admit only api, on container ports.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-ingress
namespace: shop
spec:
podSelector:
matchLabels: {app: orders}
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: {app: api}
ports:
- {protocol: TCP, port: 8080} # targetPort, not the Service port (e.g. 80)
- {protocol: TCP, port: 9000, endPort: 9010} # numeric range only
The api pods get egress to the cluster DNS pods on UDP and TCP 53 and egress to orders. The orders pods get a matching ingress rule from api. Each rule uses container ports: a named port, a targetPort number and an endPort range.
Selector traps, cross-namespace access and egress rules outside the cluster
Selector placement inside a from or to entry decides whether conditions combine with AND or OR. If namespaceSelector and podSelector sit in the same list item, under one dash, a peer must match both. If you put a dash before podSelector as well, they become two list items, and the rule allows the selected pods in your own namespace or every pod in any matching namespace. That single extra dash can quietly open a backend to a whole environment.
| Peer structure | Meaning | Typical result |
|---|---|---|
One item: namespaceSelector + podSelector |
AND | Labelled pods in labelled namespaces only |
Two items: - namespaceSelector, - podSelector |
OR | All pods in those namespaces, plus labelled local pods |
podSelector alone |
Same namespace only | Cross-namespace callers blocked |
namespaceSelector: {} |
Every namespace | Effectively cluster-wide |
To pick a namespace by name, select on kubernetes.io/metadata.name. The control plane sets this label to the namespace's name. Custom labels such as team: payments are a weak boundary because anyone allowed to edit a namespace can add the label and gain access.
For destinations outside the cluster, such as managed databases or third-party APIs, use ipBlock with a cidr and optional except ranges. NetworkPolicy has no FQDN matching, so when a provider's IPs change, your rule goes stale. NAT and load balancers also change the addresses a policy sees. Ingress through a load balancer often arrives with a node's IP as the source rather than the client's. Egress to a hostname fronted by a load balancer matches the load balancer's IPs, not the backends behind it.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: orders-api
namespace: orders
spec:
podSelector:
matchLabels:
app: orders-api
policyTypes: [Ingress, Egress]
ingress:
- from:
# AND: one list item. Peer must be app=web AND live in namespace "frontend".
# The control plane sets kubernetes.io/metadata.name, so a team cannot
# get in by adding a custom label to its own namespace.
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: frontend
podSelector: # no dash here, so this is still the same peer
matchLabels:
app: web
# OR trap: writing "- podSelector:" above would create two peers:
# every pod in "frontend" OR any app=web pod in "orders".
ports:
- protocol: TCP
port: 8080
egress:
- to:
# Managed database range. Matched by IP only; there is no FQDN matching.
- ipBlock:
cidr: 10.20.0.0/16
except:
- 10.20.99.0/24 # admin subnet the backend must not reach
ports:
- protocol: TCP
port: 5432
- ports: # allow DNS lookups to any destination
- protocol: UDP
port: 53
A backend policy that uses the AND form to admit only web pods from the frontend namespace, selected by its kubernetes.io/metadata.name label. A comment marks where one extra dash would turn the rule into an OR, and an egress rule uses ipBlock with except to limit traffic to a database range.
A safe rollout sequence and how to test it
Rolling out a default deny without warning breaks things, and the outage is the expensive part. The sequence below catches most breakage in staging.
Rollout sequence
- Inventory real traffic. If your CNI provides flow logs or a flow visibility tool, collect several days of data, long enough to include batch jobs and cron schedules. If it doesn't, review each service's dependencies: config files, connection strings and client libraries. Write down every source and destination by label, not by IP.
- Apply allow rules first. Allow rules change nothing while no policy selects the pods, so they can be merged without risk.
- Add the default deny in staging. Only now does pod isolation start. Anything you missed fails here instead of in production.
- Test both directions. Start a throwaway pod with
kubectl run tmp --rm -it --image=busybox -- sh. Give it the labels of an allowed client and connect withnc -zvorwget. Then run it again with unlisted labels and confirm that the connection fails. A test that only checks allowed paths can pass while the deny rule does nothing. - Promote through version control. Keep policies in the same repository and review flow as the workloads they protect.
Traffic people forget
- Kubelet liveness and readiness probes. Many CNIs allow host-to-pod traffic implicitly, but some do not.
- Ingress controller and load-balancer health checks. These often arrive from node IPs.
- Metrics scrapers in a monitoring namespace.
- Service mesh sidecars. They need egress to the mesh control plane.
- Admission webhooks. The API server calls the webhook pod, frequently from outside the pod network.
Your CNI decides how host and control-plane traffic is treated. Confirm it in your CNI's documentation and in staging. Don't assume it.
When to avoid it and what NetworkPolicy cannot do
When strict egress deny is the wrong fit
Default-deny egress works when a workload's external dependencies are stable and reachable at known addresses. It breaks down in a few common cases:
- SaaS endpoints that change often.
ipBlocktakes CIDRs, not hostnames, so every IP change at the provider becomes an outage until someone updates the policy. - Large provider ranges. Allowing a cloud provider's published ranges opens traffic to every tenant on that provider, which removes most of the protection you were trying to add.
- CNIs that don't enforce policy. The API server accepts NetworkPolicy objects even when nothing implements them. You get a false sense of isolation and no error to warn you.
Built-in limits
- L3/L4 only. Rules match IPs, ports and protocols. They can't filter on HTTP paths, methods or headers.
- No explicit deny and no ordering. Policies are additive. Any policy that allows a flow permits it, and another policy can't take that permission back.
- No drop logging in the API. Visibility into denied traffic depends on your CNI.
- Behaviour varies by implementation. How kubelet probes, host-network pods and connections that were already open before a policy change are handled differs between CNIs. Test each of these on yours.
- Trust in labels. Selectors match labels, so anyone with RBAC permission to label pods or namespaces can grant themselves access. Restrict who can change labels.
NetworkPolicy limits lateral movement. It does not authenticate callers or encrypt traffic, so you still need mTLS or application-level auth for both.
Alternatives and impact on cost and engineering time
Standard NetworkPolicy is the portable baseline. Each alternative closes a specific gap and adds its own cost.
| Option | Scope | Portability | Operational burden |
|---|---|---|---|
| Standard NetworkPolicy | L3/L4; pod and namespace labels, CIDRs | Any CNI that enforces the API | Low: plain manifests |
| CNI-specific extensions | Adds FQDN egress, L7 rules, flow logging | Tied to one CNI | Medium: custom resources, coupled to plugin upgrades |
| Egress proxy | Outbound hostnames, audit trail | Independent of CNI | Medium: highly available proxy, client proxy settings |
| Service mesh authz + mTLS | Workload identity, L7, encryption | Tied to the mesh | High: data-plane components, certificate lifecycle |
| Cloud firewall / security groups | Node, subnet or VPC | Tied to the provider | Low per rule, but usually too coarse to separate pods on a shared node |
The main development cost is upkeep. Every new dependency needs a policy change. A missed change usually shows up as a timeout rather than a refusal, because dropped packets get no reply, so people debug the application first. A few broad policies are cheaper to write, but they hide which workload needs what. A convention of one policy per workload per direction, kept in version control next to the deployment, keeps each change small and gives it a clear owner.
Illustrative estimate (assumed numbers, not a benchmark): monthly cost = (new dependencies × minutes per change) + (missed changes × debugging minutes) = (6 × 15) + (1 × 120) = 210 minutes per team per month.
The return is in reviewable diffs, connectivity tests that can be re-run in CI, and faster incident scoping. If a pod is compromised, its policy files list what it could reach.
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.