Default-Deny Network Policies: A Practical Guide

Every namespace starts with default-deny. Here's how I layer Kubernetes NetworkPolicies and CiliumNetworkPolicies for least-privilege network access.

Default-Deny Network Policies: A Practical Guide

Every namespace in my cluster starts with the same thing: a default-deny NetworkPolicy that blocks all ingress and egress. Then I add explicit allow rules for exactly the traffic that should flow. Here's why, and how to do it practically.

Why Default-Deny?

Kubernetes namespaces have no network isolation by default. Every pod can talk to every other pod across every namespace. That means a compromised pod in your recipe manager can reach your password manager's database. Default-deny flips this: nothing communicates unless you explicitly permit it.

It's the network equivalent of the principle of least privilege.

The Base Policy

Every namespace gets this:

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

This blocks everything: no pod in the namespace can receive or send any traffic. Then we add back what's needed.

Allow Ingress from Traefik

Most apps need to receive HTTP traffic from the ingress controller:

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

Note the namespaceSelector: this targets the Traefik namespace specifically, not just any pod with a certain label.

Allow DNS Egress

Almost every pod needs DNS. Without this, nothing resolves. I use CiliumNetworkPolicy for egress since it supports toEntities and toEndpoints which are more expressive than standard K8s NetworkPolicy:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: allow-egress
  namespace: my-app
spec:
  endpointSelector:
    matchLabels:
      app: my-app
  egress:
    - toEndpoints:
        - matchLabels:
            "k8s:io.kubernetes.pod.namespace": kube-system
            k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: UDP
            - port: "53"
              protocol: TCP

Allow External HTTPS

Some apps need to reach the internet (for updates, API calls, or email delivery). Add a world egress rule limited to port 443:

    - toEntities:
        - world
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP

This lets the app reach any external HTTPS endpoint but nothing else. No arbitrary outbound connections.

Inter-Service Communication

For apps with a database (like Miniflux with PostgreSQL), you need ingress rules on the database pod from the app pod, and egress rules on the app pod to the database port. Keep these scoped to the specific namespace and port.

Health Check Access

Don't forget your monitoring tools. Uptime Kuma and Homepage both need to reach app health endpoints. Add ingress rules from those namespaces:

  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: uptime-kuma
      ports:
        - protocol: TCP
          port: 8080

Gotcha: Cilium Evaluates Pod Ports

One thing that tripped me up: Cilium evaluates egress policies against the pod's target port after DNAT, not the Service port. If your Service is on port 80 but the pod listens on 8080, your egress policy needs to allow port 8080.

Testing

After applying policies, verify with:

# Check if a pod can reach another service
kubectl exec -n my-app deploy/my-app -- wget -qO- --timeout=5 http://other-service.other-ns.svc.cluster.local:80

# Check DNS resolution
kubectl exec -n my-app deploy/my-app -- nslookup kubernetes.default

If something breaks, check the network policy first. It's almost always a missing rule.

The Full Pattern

Every namespace in my cluster follows this template:

  1. Default-deny all ingress and egress
  2. Allow ingress from Traefik (app port)
  3. Allow ingress from Uptime Kuma (app port)
  4. Allow ingress from Homepage (app port)
  5. CiliumNetworkPolicy: DNS egress to kube-dns
  6. CiliumNetworkPolicy: HTTPS egress to world (if needed)
  7. Any inter-service rules specific to the app

It takes 5 minutes to set up per namespace and it means every app is isolated by default. When something goes wrong, the blast radius is contained.