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:
- Apply
default-denyto a non-production namespace - Watch Hubble for
DROPPEDflows - Add allow rules for each legitimate flow
- Repeat until no unexpected drops
- 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