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.
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
- EgressThis 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: 8080Note 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: TCPAllow 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: TCPThis 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: 8080Gotcha: 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.defaultIf 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:
- Default-deny all ingress and egress
- Allow ingress from Traefik (app port)
- Allow ingress from Uptime Kuma (app port)
- Allow ingress from Homepage (app port)
- CiliumNetworkPolicy: DNS egress to kube-dns
- CiliumNetworkPolicy: HTTPS egress to world (if needed)
- 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.