How to Implement Network Policies in Kubernetes

How to Implement Network Policies in Kubernetes

Something that surprised me when I first started using Kubernetes was how open the default network is. By default, every Pod in a cluster can talk to every other Pod, across every namespace, with no restrictions at all. For a small side project that’s fine. For anything handling real user data, it’s a problem. NetworkPolicies are how you fix that, and in this guide I’ll walk through what they are, how the underlying model works, and how to write policies that actually lock things down without breaking your app.

Understanding the Model

A NetworkPolicy is a namespaced resource that selects a group of Pods (via labels) and defines rules for what traffic is allowed in (ingress) and out (egress). Two things trip people up constantly:

  1. NetworkPolicies are additive, never subtractive on their own. If no policy selects a Pod, all traffic is allowed. The moment any policy selects that Pod, it switches to default-deny for the traffic type (ingress or egress) that policy addresses, and only explicitly allowed traffic gets through.
  2. You need a CNI plugin that enforces NetworkPolicies. Not all of them do. Calico, Cilium, and Weave Net support it; the basic kubenet plugin does not. If you write policies and traffic doesn’t seem restricted at all, this is the first thing to check.

Step 1: Verify Your CNI Supports NetworkPolicies

kubectl get pods -n kube-system

Look for a Calico, Cilium, or Weave DaemonSet. If you’re on a managed cluster, check your provider’s docs — for example, GKE requires enabling “Network Policy” explicitly at cluster creation or via an update, since it isn’t on by default:

gcloud container clusters update mycluster --update-addons=NetworkPolicy=ENABLED

Step 2: Start with a Default-Deny Policy

A common and recommended starting point is denying all ingress traffic in a namespace, then explicitly allowing what’s needed:

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

An empty podSelector: {} means “select all Pods in this namespace.” With no ingress rules defined, nothing is allowed in.

kubectl apply -f default-deny-ingress.yaml

Test that this actually blocks traffic:

kubectl run test-pod --rm -it --image=busybox -n production -- wget -qO- --timeout=2 http://myapp-service

You should see a timeout, confirming the deny is active.

Step 3: Allow Specific Traffic

Now open up exactly what’s needed. Let’s say we have a frontend that needs to talk to a backend API:

# allow-frontend-to-backend.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-to-backend
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - protocol: TCP
          port: 8080

This says: Pods labeled app: backend will only accept ingress traffic from Pods labeled app: frontend, and only on port 8080. Everything else remains blocked by the default-deny policy from Step 2.

kubectl apply -f allow-frontend-to-backend.yaml

Step 4: Allow Traffic from Another Namespace

Cross-namespace communication needs a namespaceSelector, often combined with a podSelector:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-monitoring-scrape
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
          podSelector:
            matchLabels:
              app: prometheus
      ports:
        - protocol: TCP
          port: 9090

Note that combining namespaceSelector and podSelector inside the same from entry is an AND condition — traffic must come from a Pod matching both selectors. Listing them as separate entries in the from array would instead be an OR condition.

Step 5: Egress Policies

Restricting outbound traffic is just as important — it limits what a compromised Pod can reach. Here’s a policy that only allows a Pod to reach DNS and a specific external API:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: restrict-egress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    - to:
        - ipBlock:
            cidr: 203.0.113.0/24
      ports:
        - protocol: TCP
          port: 443

Always allow DNS (port 53) explicitly in egress policies — forgetting this is the single most common reason apps break immediately after applying a default-deny egress policy, since they can no longer resolve service names.

Step 6: Testing Policies Thoroughly

I always validate policies with a throwaway debug Pod, testing both allowed and denied paths:

kubectl run debug --rm -it --image=nicolaka/netshoot -n production -- bash

# Inside the pod
curl -v --max-time 2 http://backend-service:8080/health   # should succeed if from frontend
nslookup backend-service.production.svc.cluster.local     # should succeed (DNS allowed)
curl -v --max-time 2 http://unrelated-service:9000        # should time out if denied

Step 7: Combining Multiple Policies

Policies are additive per Pod — if multiple NetworkPolicies select the same Pod, the union of all their rules applies. This lets you build modular policies: one for basic DNS/monitoring access shared cluster-wide, and app-specific ones layered on top.

kubectl get networkpolicy -n production
NAME                          POD-SELECTOR   AGE
default-deny-ingress          <none>         10m
allow-frontend-to-backend     app=backend    8m
allow-monitoring-scrape       app=backend    5m
restrict-egress               app=backend    3m

Debugging NetworkPolicy Issues

kubectl describe networkpolicy allow-frontend-to-backend -n production

If traffic that should be allowed is still blocked, check:

  • Label mismatches — a typo in matchLabels is the most common cause by far.
  • Whether the CNI plugin actually enforces policies (test with a deliberately open policy to confirm enforcement works at all).
  • Whether both ingress AND egress need rules — if the source Pod has an egress-restricting policy too, it needs its own egress rule allowing the outbound connection, independent of the destination’s ingress rule.

For Cilium specifically, cilium monitor and Hubble give much deeper visibility into allowed/denied flows in real time, which I’d recommend over blind trial and error once policies get complex.

Security Best Practices

  • Start every namespace with default-deny for both ingress and egress, then open explicitly — deny-by-default is dramatically safer than trying to enumerate every bad actor.
  • Label Pods consistently and intentionally; NetworkPolicy security is only as good as your labeling discipline.
  • Combine NetworkPolicies with RBAC and Pod Security Standards for defense in depth — network isolation alone doesn’t stop a compromised service account from misusing the Kubernetes API.
  • Document policies alongside application architecture diagrams so on-call engineers understand why traffic is blocked when debugging incidents.
  • Regularly audit policies for overly broad rules like an empty podSelector: {} in a from block, which allows all Pods in a namespace.

Common Mistakes

  • Assuming NetworkPolicies work without checking CNI support — the most common “why isn’t this working” issue.
  • Forgetting DNS egress rules and breaking service discovery cluster-wide.
  • Writing ingress-only policies while ignoring egress, leaving lateral movement wide open in the outbound direction.
  • Applying default-deny to kube-system and breaking core cluster functionality — usually best left more permissive unless you know exactly what you’re isolating.

Layer 7 Policies and Beyond Basic NetworkPolicy

The built-in Kubernetes NetworkPolicy resource operates at Layer 3/4 — IP addresses, ports, protocols. It has no concept of HTTP methods, paths, or application-layer identity. If you need Layer 7 controls (like “only allow GET requests from this service, block POST”) or stronger identity guarantees than label matching provides, you’re looking at a service mesh like Istio, Linkerd, or Cilium’s own Layer 7-aware policies, layered on top of basic NetworkPolicy rather than replacing it. Cilium in particular extends its CiliumNetworkPolicy custom resource well beyond the standard API, supporting DNS-aware egress rules (allow only traffic to *.amazonaws.com, for example) and HTTP-aware rules natively, without needing a full sidecar-based service mesh:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: allow-specific-http
  namespace: production
spec:
  endpointSelector:
    matchLabels:
      app: backend
  ingress:
    - fromEndpoints:
        - matchLabels:
            app: frontend
      toPorts:
        - ports:
            - port: "8080"
              protocol: TCP
          rules:
            http:
              - method: "GET"
                path: "/api/v1/.*"

This is worth knowing about even if you start with plain NetworkPolicy, since migrating later to something more expressive doesn’t require throwing away your existing label-based segmentation model.

A Realistic Multi-Tier Application Policy Set

To make this concrete, here’s how I’d typically structure NetworkPolicies for a standard three-tier app (frontend, backend API, database), all in one namespace:

# 1. Default deny everything
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  podSelector: {}
  policyTypes: ["Ingress", "Egress"]
---
# 2. Allow all pods to reach DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: production
spec:
  podSelector: {}
  policyTypes: ["Egress"]
  egress:
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
---
# 3. Frontend can be reached from Ingress controller, can reach backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: frontend
  policyTypes: ["Ingress", "Egress"]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: backend
      ports:
        - protocol: TCP
          port: 8080
---
# 4. Backend only reachable from frontend, only talks to database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes: ["Ingress", "Egress"]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              app: database
      ports:
        - protocol: TCP
          port: 5432
---
# 5. Database only reachable from backend, no outbound needed
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: database-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: database
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: backend
      ports:
        - protocol: TCP
          port: 5432

This set enforces a strict linear trust chain — Ingress reaches frontend, frontend reaches backend, backend reaches database, and nothing skips a tier or reaches anything it doesn’t strictly need. It’s the kind of layout that turns “a compromised frontend Pod” into a contained incident rather than a path straight to the database.

Auditing Policy Coverage Across a Cluster

For clusters with many namespaces, periodically check which namespaces have no NetworkPolicies at all — these are your fully-open blind spots:

for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do
  count=$(kubectl get networkpolicy -n "$ns" --no-headers 2>/dev/null | wc -l)
  echo "$ns: $count policies"
done

Namespaces reporting zero policies are worth prioritizing, roughly in order of how sensitive the data they handle is.

Summary

NetworkPolicies give you the equivalent of a namespace-aware firewall for Pod-to-Pod traffic. The keys to using them well are understanding that they’re default-allow until any policy selects a Pod (at which point it becomes default-deny for that traffic direction), always allowing DNS explicitly, and testing thoroughly with a debug Pod before trusting a policy in production. Start with default-deny and layer in precise allow rules — it’s a more secure posture than trying to block specific bad actors after the fact.

References

Total
2
Shares

Leave a Reply

Previous Post
How to Set Up Horizontal Pod Autoscaling in Kubernetes

How to Set Up Horizontal Pod Autoscaling in Kubernetes

Next Post
How to Use Helm for Package Management in Kubernetes

How to Use Helm for Package Management in Kubernetes

Related Posts