How to Use NetworkPolicies in Kubernetes

How to Use NetworkPolicies in Kubernetes

By default, Kubernetes networking is completely flat — every pod in a cluster can talk to every other pod, across every namespace, with no restrictions whatsoever. The first time that fact really landed for me was during a pentest engagement where a low-privilege compromised pod was used to reach an internal admin API three namespaces away, with nothing stopping the lateral movement. NetworkPolicies are the tool that fixes this, and understanding them thoroughly — not just copy-pasting examples — is one of the highest-leverage security investments you can make in a cluster.

What NetworkPolicies Actually Are

A NetworkPolicy is a namespace-scoped API object that specifies allowed traffic to and from a set of pods, selected via label selector. Critically: a pod with no NetworkPolicy selecting it is fully open (all traffic allowed). The moment any policy selects a pod, that pod becomes subject to default-deny for whichever traffic direction(s) the policy declares (Ingress, Egress, or both) — only what’s explicitly allowed by matching policies gets through.

This “implicit allow, explicit policies create implicit deny” model trips people up constantly, so it’s worth internalizing precisely:

Enforcement Requires a Compatible CNI

Same caveat as always: NetworkPolicy objects are inert without a CNI plugin that implements enforcement (Calico, Cilium, Weave Net, Antrea). Check what you’re running:

kubectl get pods -n kube-system -o wide
kubectl get daemonset -n kube-system

Anatomy of a NetworkPolicy

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: api-server
  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

Breaking this down:

Default Deny All (Both Directions)

The starting point for any zero-trust namespace:

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

Apply this and then layer on specific allow rules for each legitimate traffic path. This is genuinely the recommended baseline for any production namespace handling sensitive data.

Allowing Traffic from a Specific Namespace

Useful for shared services (e.g., an internal API gateway namespace calling into multiple backend namespaces):

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

Note: kubernetes.io/metadata.name is an automatically populated label on every namespace since Kubernetes 1.21+, making this pattern reliable without needing to manually label namespaces yourself.

Combining Pod and Namespace Selectors

A common point of confusion: when podSelector and namespaceSelector appear together within the same list item under from, they’re ANDed (must match both); when they’re separate list items, they’re ORed.

ingress:
  - from:
      - namespaceSelector:
          matchLabels:
            team: platform
        podSelector:
          matchLabels:
            app: monitoring-agent

This allows traffic only from pods labeled app: monitoring-agent that are also in a namespace labeled team: platform — both conditions required.

ingress:
  - from:
      - namespaceSelector:
          matchLabels:
            team: platform
      - podSelector:
          matchLabels:
            app: monitoring-agent

This allows traffic from any pod in a team: platform namespace, OR from any pod labeled app: monitoring-agent regardless of namespace — a much broader rule. Getting this distinction wrong is one of the most common NetworkPolicy authoring mistakes.

Allowing External Traffic via ipBlock

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-office-ip
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: admin-panel
  policyTypes:
    - Ingress
  ingress:
    - from:
        - ipBlock:
            cidr: 203.0.113.0/24
      ports:
        - protocol: TCP
          port: 443

ipBlock can also except specific sub-ranges:

ingress:
  - from:
      - ipBlock:
          cidr: 10.0.0.0/8
          except:
            - 10.0.5.0/24

Multi-Tier Application Example

A realistic three-tier app (frontend → backend → database) with least-privilege policies:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: frontend-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      tier: frontend
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - {}   # allow all ingress (e.g., from an Ingress controller)
  egress:
    - to:
        - podSelector:
            matchLabels:
              tier: backend
      ports:
        - protocol: TCP
          port: 8080
    - to:
        - namespaceSelector: {}
      ports:
        - protocol: UDP
          port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      tier: backend
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              tier: frontend
      ports:
        - protocol: TCP
          port: 8080
  egress:
    - to:
        - podSelector:
            matchLabels:
              tier: database
      ports:
        - protocol: TCP
          port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: database-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      tier: database
  policyTypes:
    - Ingress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              tier: backend
      ports:
        - protocol: TCP
          port: 5432

This ensures the database tier only ever accepts connections from the backend tier, and the frontend can’t reach the database directly at all — even if someone misconfigures the frontend application to try.

Applying and Verifying

kubectl apply -f frontend-policy.yaml -f backend-policy.yaml -f database-policy.yaml
kubectl get networkpolicy -n production
NAME                POD-SELECTOR      AGE
frontend-policy     tier=frontend      10s
backend-policy      tier=backend       10s
database-policy     tier=database      10s

Test enforcement directly:

kubectl run test-pod --image=busybox --rm -it --restart=Never -n production -- wget -qO- --timeout=3 http://database-service:5432

This should hang/timeout if run from anywhere other than a labeled tier: backend pod, confirming the policy is enforced.

Debugging NetworkPolicy Issues

kubectl describe networkpolicy <name> -n production
kubectl get pods -n production --show-labels

Mismatched labels between the policy’s selector and actual pod labels is, by a wide margin, the most common reason a NetworkPolicy silently “does nothing” — always cross-check --show-labels output against the policy’s matchLabels.

For Cilium-based clusters:

cilium monitor --type drop

For Calico:

calicoctl get networkpolicy -o wide
kubectl logs -n kube-system -l k8s-app=calico-node | grep -i denied

Production Best Practices

NetworkPolicies and Ingress Controllers

A frequent point of confusion: how do external users reach a pod behind a default-deny ingress policy? The Ingress controller itself is just another pod making a connection, so it needs to be explicitly allowed like any other source:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-from-ingress-controller
  namespace: production
spec:
  podSelector:
    matchLabels:
      tier: frontend
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
          podSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
      ports:
        - protocol: TCP
          port: 8080

This is more precise than the ingress: [{}] allow-all example shown earlier in the multi-tier walkthrough — worth tightening once you know your actual Ingress controller’s namespace and labels.

CiliumNetworkPolicy: Layer 7 Rules

Standard NetworkPolicy only operates at Layer 3/4 (IP and port) — it can’t distinguish between an allowed GET /health and a disallowed DELETE /admin/users on the same port. Cilium’s CiliumNetworkPolicy extends this to Layer 7 for HTTP, gRPC, and Kafka traffic:

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

This allows frontend pods to call only GET /api/v1/status on admin-api — any other method or path is dropped at the network layer, even if the application itself has no additional authorization logic for that specific route. This is a genuinely powerful defense-in-depth layer, though it does require Cilium specifically rather than being portable across all CNI plugins.

Testing Strategy for a Zero-Trust Rollout

Rolling out default-deny across an established cluster is risky enough that I follow a consistent process:

  1. Audit mode first (Cilium supports this natively via CiliumNetworkPolicy with an audit-only annotation; for plain NetworkPolicy without native audit support, deploy to a staging namespace that mirrors production traffic patterns).
  2. Log what would be blocked without actually blocking it, using CNI-specific flow logging (cilium monitor, Calico’s flow logs to Elasticsearch) over at least one full business cycle (a week, ideally including any batch/cron jobs that only run periodically).
  3. Build allow rules from observed legitimate flows, not from guessing at what “should” be needed — actual traffic patterns are often surprising, especially around health checks, metrics scraping, and DNS.
  4. Roll out namespace by namespace, starting with the least critical, watching error rates and connection timeout metrics closely after each rollout.
kubectl get events -n production --field-selector reason=NetworkPolicyDenied

(Availability of this specific event reason depends on your CNI’s integration with Kubernetes events — Calico and Cilium both support some form of this, though the exact mechanism varies.)

Common Mistakes

Summary

NetworkPolicies are the mechanism that turns Kubernetes’ flat-by-default networking into something resembling real network segmentation — but only when paired with a CNI that actually enforces them, and only when authored with a solid grasp of the selector combination semantics. The pattern that scales well in practice is default-deny per namespace, explicit least-privilege allow rules per tier, and systematic label discipline so selectors reliably match the pods you intend.

References

Exit mobile version