Omegion

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

  1. 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 .
  2. An app already running that you want to lock down (Chatwoot, in this example).
  3. kubectl pointed 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”:

yaml
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: 443

No 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:

shell
❯ kubectl run chatwoot-netpol-demo -n chatwoot --image=nicolaka/netshoot:latest \
  --restart=Never --command -- sleep 3600
pod/chatwoot-netpol-demo created

Postgres and Redis, both explicitly allowed, connect straight away:

shell
❯ 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:

shell
❯ 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 1

That’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.