CiliumNetworkPolicy: default-deny and allowlist model

Published: 2026-02-28

Kubernetes NetworkPolicy is a good idea poorly executed: the syntax is verbose, the semantics are confusing (ingress/egress are from the pod's perspective, not the rule's perspective), and cluster DNS still works in mysterious ways. CiliumNetworkPolicy extends the standard API with identity-based rules, L7 filtering, and FQDN-based egress. Here's the pattern we use.


The problem with no policies

By default, every pod can reach every other pod in the cluster. A compromised application can freely talk to:

  • The Vault API
  • The Prometheus API (which has metric labels leaking internal service names)
  • Other namespaces' databases
  • External endpoints without restriction

Zero-trust networking starts with default-deny. The blast radius of any compromised pod is contained to what its policy explicitly allows.


Step 1: default-deny all traffic in a namespace

yamlapiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: default-deny
  namespace: app
specs:
  - description: "Default deny all ingress and egress"
    endpointSelector: {}   # applies to all pods in the namespace
    ingress:
      - {}
    egress:
      - {}

ingress: [{}] looks like "allow all", but with CiliumNetworkPolicy semantics, an empty rule list means deny all. A single {} entry without any fromEndpoints or fromCIDR matches nothing. The effect: all ingress and egress is dropped for everything in the namespace.

After applying this, pods in app can't talk to DNS either. Fix that next.


Step 2: allow DNS

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

Without this, service discovery breaks and pods fail to start with DNS resolution errors. This rule applies to all pods in the namespace (endpointSelector: {}).


Step 3: allow ingress from the ingress controller

yamlapiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: allow-from-ingress
  namespace: app
spec:
  endpointSelector:
    matchLabels:
      app: my-service
  ingress:
    - fromEndpoints:
        - matchLabels:
            "k8s:io.kubernetes.pod.namespace": ingress-apisix

Now only the APISIX ingress controller can reach my-service. Direct pod-to-pod traffic from other namespaces is blocked.


Step 4: allow inter-service communication

Frontend needs to call backend:

yamlapiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: allow-frontend-to-backend
  namespace: app
spec:
  endpointSelector:
    matchLabels:
      app: backend
  ingress:
    - fromEndpoints:
        - matchLabels:
            app: frontend
      toPorts:
        - ports:
            - port: "8080"
              protocol: TCP

Only frontend pods, only port 8080. Nothing else gets in.


Step 5: allow egress to databases

Backend needs PostgreSQL:

yamlapiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: allow-backend-to-postgres
  namespace: app
spec:
  endpointSelector:
    matchLabels:
      app: backend
  egress:
    - toEndpoints:
        - matchLabels:
            "k8s:io.kubernetes.pod.namespace": databases
            app: postgresql
      toPorts:
        - ports:
            - port: "5432"
              protocol: TCP

FQDN-based egress (calling external APIs)

For calls to external services, use toFQDNs:

yamlapiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: allow-external-api
  namespace: app
spec:
  endpointSelector:
    matchLabels:
      app: my-service
  egress:
    - toFQDNs:
        - matchName: "api.stripe.com"
        - matchName: "api.sendgrid.com"
        - matchPattern: "*.s3.amazonaws.com"
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP

Cilium resolves the FQDNs on the fly and updates the eBPF maps. Pods can only reach the explicitly allowlisted external endpoints. matchPattern supports wildcards.


L7 HTTP policy

Cilium can enforce at the HTTP layer — specific methods and paths only:

yamlegress:
  - toEndpoints:
      - matchLabels:
          app: payment-service
    toPorts:
      - ports:
          - port: "8080"
            protocol: TCP
        rules:
          http:
            - method: "POST"
              path: "/api/v1/charge"

An app trying to GET /admin on the payment service will be dropped at the network layer, before the request even reaches the pod.


Debugging with Hubble

When a policy blocks unexpected traffic:

bash# See all dropped flows in the namespace
hubble observe --namespace app --verdict DROPPED

# Follow live traffic
hubble observe --namespace app -f

# See what's being blocked from a specific pod
hubble observe --namespace app --from-pod app/my-service --verdict DROPPED

Shows: source pod, destination pod/FQDN, port, and the policy verdict with POLICY_DENIED. No guessing, no adding logging to applications.


Roll out incrementally

Don't apply default-deny to production namespaces immediately. The workflow:

  1. Apply default-deny to a non-production namespace
  2. Watch Hubble for DROPPED flows
  3. Add allow rules for each legitimate flow
  4. Repeat until no unexpected drops
  5. Move to production

Cilium also supports audit mode per-policy while you're building the allowlist. In audit mode, traffic is allowed but violations are logged — useful for discovering what to allow before going enforce-only.

yaml# Audit mode: logs drops but doesn't enforce
metadata:
  annotations:
    policy.cilium.io/policy-enforcement: audit