Restricting Kubernetes Pod Egress with NetworkPolicy
Introduction
When I was building my Talos Kubernetes homelab cluster, I wrote about
setting it up
and turning on
Flannel’s native NetworkPolicy enforcement along the way, with one generic example:
allow HTTPS out, block the home LAN. That was a decent first pass, but it didn’t say
anything about what a specific app actually needs. Without a policy, every pod on that
cluster still gets Kubernetes’ default “allow all egress”. A pod can reach anything else
in the cluster, or anything on my LAN, just as easily as the one service it’s actually
supposed to talk to. This post is the real version of that lock, for one real app:
Chatwoot
, which needs the cluster’s shared Postgres and its
own Redis, and nothing else.
Prerequisites
- A Talos cluster with Flannel’s policy enforcement turned on. I covered that when I first built this cluster, in the post about setting it up .
- An app already running that you want to lock down (Chatwoot, in this example).
kubectlpointed at the cluster.
What Chatwoot actually talks to
Chatwoot’s web and worker pods need three things to work: the
shared CloudNativePG cluster
in the postgres namespace, a Redis instance in Chatwoot’s own namespace for the
Sidekiq/Bull job queue and cache, and DNS to resolve both of those. It also needs
outbound SMTP and HTTPS for email and a couple of third-party integrations. Everything
else, the rest of the cluster, my home LAN, the internet at large, is stuff it has no
reason to reach.
The egress policy
podSelector: {} matches every pod in the namespace, so the mere presence of this
NetworkPolicy flips Chatwoot from “allow all egress” to “deny anything not explicitly
listed below”:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: chatwoot-egress
spec:
podSelector: {}
policyTypes:
- Egress
egress:
# DNS
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Shared CNPG postgres cluster
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: postgres
ports:
- protocol: TCP
port: 5432
# Own redis (Bull/Sidekiq queue + cache), same namespace
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: chatwoot-redis
app.kubernetes.io/component: master
ports:
- protocol: TCP
port: 6379
# SMTP relay + HTTPS (S3 storage, Slack/Google OAuth). No stable
# IP/CIDR published for either, so scoped by port only.
# Still excludes the LAN, just not a specific host.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 192.168.1.0/24
ports:
- protocol: TCP
port: 587
- protocol: TCP
port: 443No rule for the Kubernetes API, on purpose. Chatwoot doesn’t call it, so there’s nothing to allow there.
The DNS rule has to match the pod, not the ClusterIP
That first rule looks like it should just be an ipBlock for CoreDNS’s ClusterIP. I
wrote it that way the first time, on a different app on this same cluster, and it broke
DNS completely. Flannel’s policy engine evaluates after kube-proxy has already rewritten
the destination to CoreDNS’s real pod IP, so a rule matching the virtual ClusterIP never
actually matches anything. Matching CoreDNS by namespaceSelector + podSelector
instead, by pod identity rather than by the Service address, is what actually works. I pulled
this into a small shared Helm chart (network-policy-snippets) so every app’s egress
policy includes the same correct DNS rule instead of each one risking that mistake on its
own.
Testing it
To check the policy is actually doing what I think it’s doing, I dropped a
netshoot
pod into the chatwoot namespace and
tried reaching both an allowed destination and a denied one:
❯ kubectl run chatwoot-netpol-demo -n chatwoot --image=nicolaka/netshoot:latest \
--restart=Never --command -- sleep 3600
pod/chatwoot-netpol-demo createdPostgres and Redis, both explicitly allowed, connect straight away:
❯ kubectl exec -n chatwoot chatwoot-netpol-demo -- \
nc -zv -w3 postgres.postgres.svc.cluster.local 5432
Connection to postgres.postgres.svc.cluster.local (10.107.254.52) 5432 port [tcp/postgresql] succeeded!
❯ kubectl exec -n chatwoot chatwoot-netpol-demo -- \
nc -zv -w3 chatwoot-chatwoot-redis-master 6379
Connection to chatwoot-chatwoot-redis-master (10.104.168.82) 6379 port [tcp/redis] succeeded!The kube-apiserver, which isn’t in the policy at all, just hangs until the connection times out:
❯ kubectl exec -n chatwoot chatwoot-netpol-demo -- nc -zv -w5 192.168.1.10 6443
nc: connect to 192.168.1.10 port 6443 (tcp) timed out: Operation in progress
command terminated with exit code 1That’s the difference between a policy that exists and a policy that’s actually enforced. I deleted the pod once I’d seen both results.
Conclusion
It’s still not real network isolation, though. Enforcement happens in Flannel, on the
same node the pod runs on, not on a VLAN or a separate firewall device sitting between
the cluster and the rest of the LAN. A CNI bug or a misconfigured node bypasses it
entirely.
What it does catch is the boring failure, an app reaching somewhere it has no business
reaching, and that’s the case that actually happens on a homelab. Anywhere the stakes
were higher, physical or VLAN segmentation would still be the real fix, not a
NetworkPolicy.