# Kubernetes NetworkPolicies in Practice

URL: https://www.devopsness.com/blog/kubernetes-network-policies-in-practice
Author: Kiril Urbonas
Published: 2026-06-15
Updated: 2026-10-31
Categories: DevOps
Tags: Kubernetes, Networking, Security, Cilium, Network Policy

Summary: Default-deny, namespace isolation, egress control: the patterns we use, the gotchas around DNS, and where Cilium changed our calculus.

---
# Kubernetes NetworkPolicies in Practice

Kubernetes NetworkPolicies restrict pod traffic, but only if your CNI enforces them. The pattern that works: a default-deny policy per namespace for ingress and egress, an explicit DNS allow to kube-dns on port 53, then one allow rule per caller. Without any policy, every pod can reach every other pod, so a compromised pod can reach anything.

> [!VERIFIED]
> Kubernetes 1.37 and Cilium 1.20.4, against the kubernetes.io NetworkPolicy concept page and the docs.cilium.io DNS-based policy and policy audit mode pages. Checked October 2026.

The benefit of a cluster-wide rollout is real: a compromised pod stops being a compromised cluster. The operational cost is real too, and most of it lands in the first week. This is what we've learned.

## The mental model

NetworkPolicies are namespaced rules that select pods (by labels) and specify which traffic is allowed in/out. The semantics:

- If no NetworkPolicy selects a pod, all traffic is allowed (the default).
- If at least one policy selects a pod, **only** what's explicitly allowed is permitted.
- Policies are additive: if two policies select the same pod, the pod allows traffic that either policy permits.
- A connection needs both sides to agree: egress allowed on the source pod and ingress allowed on the destination pod.

The implication: your first NetworkPolicy for a pod moves it into deny-by-default.

## Step 1: pick a CNI that supports it

The Kubernetes API for NetworkPolicy is a contract; the CNI plugin enforces it. Not all CNIs do.

- **Calico** supports NetworkPolicy plus an extended `GlobalNetworkPolicy`. Mature.
- **Cilium** supports NetworkPolicy and a much richer `CiliumNetworkPolicy` (L7 rules, DNS-based egress, identity-based rules).
- **kube-router** enforces NetworkPolicy with fewer extras. Weave Net also did, but its repository was archived in 2024 after Weaveworks shut down, so don't start new clusters on it.
- **Flannel** on its own does NOT enforce NetworkPolicy. If your cluster uses plain Flannel, the resources exist but do nothing. The Kubernetes docs are blunt about it: creating a NetworkPolicy without a controller that implements it has no effect.

We use Cilium. The L7 + identity features pay back the operational complexity.

## Step 2: default-deny per namespace

The first policy we deploy in every namespace:

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

`podSelector: {}` matches all pods in the namespace. No `ingress` or `egress` rules means deny everything, in both directions. Now you add specific allow rules.

## Step 3: allow DNS (or break everything)

The first thing that breaks under default-deny is DNS: everything that uses Service names dies. The fix:

```yaml
# payments/allow-dns.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: payments
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
```

Allow egress to CoreDNS pods on port 53. We bake this into every namespace alongside the default-deny.

With NodeLocal DNSCache, also allow the local cache's IP.

## Step 4: ingress rules per service

For each Service that other pods talk to, add an ingress rule allowing the expected callers:

```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: payments-api-ingress
  namespace: payments
spec:
  podSelector:
    matchLabels:
      app: payments-api
  policyTypes:
  - Ingress
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          team: web
      podSelector:
        matchLabels:
          app: api-gateway
    ports:
    - protocol: TCP
      port: 8080
```

This allows ingress to `app: payments-api` on port 8080 only from `app: api-gateway` pods in namespaces labeled `team: web`. The matrix of "who calls what" becomes a set of these policies: painful to bootstrap, easy to maintain.

## Step 5: egress rules to external services

The harder one. Egress from your cluster to external endpoints (databases, third-party APIs, cloud services). Two patterns:

**By IP/CIDR.** Allow egress to specific IPs:
```yaml
egress:
- to:
  - ipBlock:
      cidr: 10.0.0.0/8
  ports:
  - port: 5432
    protocol: TCP
```

Fine for stable internal IPs, useless for cloud services and SaaS with dynamic IPs.

**By DNS (Cilium-specific).** With `CiliumNetworkPolicy`:
```yaml
egress:
- toFQDNs:
  - matchName: "api.stripe.com"
  toPorts:
  - ports:
    - port: "443"
      protocol: TCP
```

Cilium learns the IPs by watching DNS answers through its in-agent DNS proxy, so the same policy also needs a DNS rule on the kube-dns egress (`rules: dns: - matchPattern: "*"`) or the FQDN never resolves to an allowed IP. Note that `matchPattern: "*.github.com"` does not match `github.com` itself. Hugely more usable for external services; the trade-off is that it is Cilium-specific and not portable.

## Step 6: namespace isolation

For multi-tenant clusters or just defense-in-depth, deny cross-namespace traffic by default:

```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-from-other-namespaces
  namespace: payments
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector: {}  # any pod IN this namespace
```

Now ingress to payments pods is only from payments pods, which catches the "misconfigured dev service hit the production database" class of mistakes.

## What you can't do with standard NetworkPolicy

The Kubernetes-native API is L3/L4. It can't:

- **L7 rules.** "Allow GET /healthz but not POST /payments." Needs Cilium or a service mesh.
- **DNS-based egress.** (See above; Cilium-specific.)
- **Pod-identity-based rules.** "Allow service A's spiffe identity to call service B." Need service mesh or Cilium identity.
- **Logging of denied traffic.** The Kubernetes docs list logging blocked or accepted connections as out of scope. Cilium's Hubble flow logs add that observability.
- **Explicit deny rules.** The model is deny by default with allow rules only, so you cannot write "allow everything except X".

If you need any of these, you're using Cilium, Istio or similar. Cilium for the network-policy layer with a mesh on top is a common split, and [Cilium vs Istio](/blog/cilium-vs-istio) covers where each layer should own the rule.

## Gotchas we've hit

**Updating labels breaks policies silently.** If a pod's labels change and no longer match the policy's `podSelector`, the policy stops applying. The pod might become accidentally permissive or accidentally locked out. Whether existing connections survive a policy or label change is left to the CNI, so test label changes.

**Egress to the Kubernetes API.** Some pods (controllers, operators) need to call the Kubernetes API. The API server lives outside pod CIDRs; you need explicit allow rules.

**Egress through hostNetwork.** The docs call NetworkPolicy behavior for `hostNetwork: true` pods undefined. Most plugins ignore them in selectors and treat their traffic as node traffic. Avoid `hostNetwork` for application pods.

**Order of policies.** There is no precedence. A permissive policy that overlaps a strict one wins, so audit broad grants.

## What we monitor

- **Denied connections.** Hubble (Cilium) gives flow logs with deny reasons. A spike in denies means a recent change broke something, so alert on a sustained rate above your normal baseline.
- **Policy count per namespace.** A drift indicator; outliers warrant review.
- **Pods without policies.** Near zero on a locked-down cluster; audit periodically.

## Things we got wrong

**Writing policies pod by pod.** Doesn't scale. Write them against team and role labels.

**Default-deny without dry-run.** Enabling default-deny straight in production breaks DNS and then several other things. Use Cilium's policy audit mode first: it allows all traffic but logs what would be dropped, visible with `hubble observe flows -t policy-verdict` as `AUDITED` verdicts. Run it for a week, then flip to enforce. Cilium's docs say audit mode is not for permanent production use, because nothing is enforced while it is on.

**Forgetting kube-system traffic.** Infrastructure pods such as CSI drivers need system services too; expect a few mysterious failures until those allow rules exist.

**Policies without label discipline.** Inconsistently labeled pods get missed. Pair the rollout with a label audit.

## Questions people ask

### Why do Kubernetes NetworkPolicies have no effect?

Usually because the CNI does not enforce them. The API server accepts NetworkPolicy objects regardless, but enforcement is the network plugin's job, and plain Flannel ignores them. Check which CNI runs in `kube-system`. If it does enforce, check that the policy's `podSelector` actually matches the pod's labels and that `policyTypes` includes the direction you expect.

### Does a default-deny NetworkPolicy block DNS?

Yes. A policy with `podSelector: {}` and `Egress` in `policyTypes` blocks all egress, including lookups to CoreDNS, so every Service name stops resolving. Add an egress rule allowing UDP and TCP port 53 to pods labeled `k8s-app: kube-dns` in the `kube-system` namespace, selected with the `kubernetes.io/metadata.name` label. NodeLocal DNSCache needs its own allow rule.

### Can a NetworkPolicy allow traffic to a domain name?

Not with the standard API, which works on pod labels, namespaces and CIDR blocks only. Cilium's `CiliumNetworkPolicy` adds `toFQDNs` with `matchName` and `matchPattern`, learning the IPs from DNS answers through its DNS proxy. It ties you to Cilium.

## What to read next

- [Container resource limits: what they actually do](/blog/container-resource-limits-what-they-actually-do), adjacent defense in depth
- [Kubernetes HPA not scaling](/blog/kubernetes-hpa-not-scaling), operational discipline on Kubernetes
- [AWS security best practices checklist](/blog/aws-security-best-practices), broader cloud security context
- [HashiCorp Vault as a secrets backend for Kubernetes](/blog/vault-as-secrets-backend-for-kubernetes), paired secrets discipline

NetworkPolicies are one of those Kubernetes features that's optional on day one and load-bearing forever after you turn it on. Build them in incrementally, default-deny one namespace at a time, monitor what breaks, fix it. The destination is a cluster where compromise of one pod doesn't mean compromise of everything; the journey is months of iterative policy tightening. Worth it.

## Sources

- [Kubernetes docs: Network Policies](https://kubernetes.io/docs/concepts/services-networking/network-policies/)
- [Cilium docs: Locking down external access with DNS-based policies](https://docs.cilium.io/en/stable/security/dns/)
- [Cilium docs: Creating policies from verdicts (policy audit mode)](https://docs.cilium.io/en/stable/security/policy-creation/)

