<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>NetworkPolicy on Omegion</title><link>https://omegion.dev/tags/networkpolicy/</link><description>Recent content in NetworkPolicy on Omegion</description><generator>Hugo</generator><language>en-us</language><copyright>Omegion</copyright><lastBuildDate>Mon, 24 Aug 2026 18:45:41 +0200</lastBuildDate><atom:link href="https://omegion.dev/tags/networkpolicy/index.xml" rel="self" type="application/rss+xml"/><item><title>Restricting Kubernetes Pod Egress with NetworkPolicy</title><link>https://omegion.dev/2025/07/restricting-kubernetes-pod-egress-with-networkpolicy/</link><pubDate>Thu, 24 Jul 2025 00:00:00 +0000</pubDate><guid>https://omegion.dev/2025/07/restricting-kubernetes-pod-egress-with-networkpolicy/</guid><description>Locking a real app down to only the Postgres and Redis it actually needs, with Flannel enforcing the NetworkPolicy on a Talos homelab cluster.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>When I was building my Talos Kubernetes homelab cluster, I wrote about
<a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">setting it up</a>
 and turning on
Flannel&rsquo;s native <code>NetworkPolicy</code> enforcement along the way, with one generic example:
allow HTTPS out, block the home LAN. That was a decent first pass, but it didn&rsquo;t say
anything about what a specific app actually needs. Without a policy, every pod on that
cluster still gets Kubernetes&rsquo; default &ldquo;allow all egress&rdquo;. A pod can reach anything else
in the cluster, or anything on my LAN, just as easily as the one service it&rsquo;s actually
supposed to talk to. This post is the real version of that lock, for one real app:
<a href="https://www.chatwoot.com/" target="_blank" rel="noopener noreferrer">Chatwoot</a>
, which needs the cluster&rsquo;s shared Postgres and its
own Redis, and nothing else.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ol>
<li>A Talos cluster with Flannel&rsquo;s policy enforcement turned on. I covered that when I
first built this cluster, in
<a href="https://omegion.dev/2023/02/talos-os-setup-a-3-node-homelab-kubernetes-cluster/">the post about setting it up</a>
.</li>
<li>An app already running that you want to lock down (Chatwoot, in this example).</li>
<li><code>kubectl</code> pointed at the cluster.</li>
</ol>
<h2 id="what-chatwoot-actually-talks-to">What Chatwoot actually talks to</h2>
<p>Chatwoot&rsquo;s web and worker pods need three things to work: the
<a href="https://omegion.dev/2023/11/talos-os-running-postgres-with-cloudnativepg-on-ceph/">shared CloudNativePG cluster</a>

in the <code>postgres</code> namespace, a Redis instance in Chatwoot&rsquo;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.</p>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart TD\n  Pod[\"Chatwoot pod\"] --\u003e|\"5432, allowed\"| PG[\"Postgres (postgres namespace)\"]\n  Pod --\u003e|\"6379, allowed\"| Redis[\"Redis (chatwoot namespace)\"]\n  Pod -.-\u003e|\"6443, denied\"| API[\"kube-apiserver\"]\n  Pod -.-\u003e|\"denied\"| Router[\"Router 192.168.1.1\"]";</script>

<h2 id="the-egress-policy">The egress policy</h2>
<p><code>podSelector: {}</code> matches every pod in the namespace, so the mere presence of this
<code>NetworkPolicy</code> flips Chatwoot from &ldquo;allow all egress&rdquo; to &ldquo;deny anything not explicitly
listed below&rdquo;:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">yaml</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">networking.k8s.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">NetworkPolicy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">chatwoot-egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">podSelector</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">policyTypes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="l">Egress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">egress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># DNS</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">namespaceSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kubernetes.io/metadata.name</span><span class="p">:</span><span class="w"> </span><span class="l">kube-system</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">podSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">k8s-app</span><span class="p">:</span><span class="w"> </span><span class="l">kube-dns</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">UDP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">53</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">53</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># Shared CNPG postgres cluster</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">namespaceSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">kubernetes.io/metadata.name</span><span class="p">:</span><span class="w"> </span><span class="l">postgres</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">5432</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># Own redis (Bull/Sidekiq queue + cache), same namespace</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">podSelector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">app.kubernetes.io/name</span><span class="p">:</span><span class="w"> </span><span class="l">chatwoot-redis</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">app.kubernetes.io/component</span><span class="p">:</span><span class="w"> </span><span class="l">master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># SMTP relay + HTTPS (S3 storage, Slack/Google OAuth). No stable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># IP/CIDR published for either, so scoped by port only.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># Still excludes the LAN, just not a specific host.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">to</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">ipBlock</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">cidr</span><span class="p">:</span><span class="w"> </span><span class="m">0.0.0.0</span><span class="l">/0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">except</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span>- <span class="m">192.168.1.0</span><span class="l">/24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">587</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">TCP</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">port</span><span class="p">:</span><span class="w"> </span><span class="m">443</span></span></span></code></pre></div>
</div>
<p>No rule for the Kubernetes API, on purpose. Chatwoot doesn&rsquo;t call it, so there&rsquo;s nothing
to allow there.</p>
<h2 id="the-dns-rule-has-to-match-the-pod-not-the-clusterip">The DNS rule has to match the pod, not the ClusterIP</h2>
<p>That first rule looks like it should just be an <code>ipBlock</code> for CoreDNS&rsquo;s ClusterIP. I
wrote it that way the first time, on a different app on this same cluster, and it broke
DNS completely. Flannel&rsquo;s policy engine evaluates <em>after</em> kube-proxy has already rewritten
the destination to CoreDNS&rsquo;s real pod IP, so a rule matching the virtual ClusterIP never
actually matches anything. Matching CoreDNS by <code>namespaceSelector</code> + <code>podSelector</code>
instead, by pod identity rather than by the Service address, is what actually works. I pulled
this into a small shared Helm chart (<code>network-policy-snippets</code>) so every app&rsquo;s egress
policy includes the same correct DNS rule instead of each one risking that mistake on its
own.</p>
<h2 id="testing-it">Testing it</h2>
<p>To check the policy is actually doing what I think it&rsquo;s doing, I dropped a
<a href="https://github.com/nicolaka/netshoot" target="_blank" rel="noopener noreferrer">netshoot</a>
 pod into the <code>chatwoot</code> namespace and
tried reaching both an allowed destination and a denied one:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl run chatwoot-netpol-demo -n chatwoot --image<span class="o">=</span>nicolaka/netshoot:latest <span class="se">\
</span></span></span><span class="line"><span class="cl">  --restart<span class="o">=</span>Never --command -- sleep <span class="m">3600</span>
</span></span><span class="line"><span class="cl">pod/chatwoot-netpol-demo created</span></span></code></pre></div>
</div>
<p>Postgres and Redis, both explicitly allowed, connect straight away:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl <span class="nb">exec</span> -n chatwoot chatwoot-netpol-demo -- <span class="se">\
</span></span></span><span class="line"><span class="cl">  nc -zv -w3 postgres.postgres.svc.cluster.local <span class="m">5432</span>
</span></span><span class="line"><span class="cl">Connection to postgres.postgres.svc.cluster.local <span class="o">(</span>10.107.254.52<span class="o">)</span> <span class="m">5432</span> port <span class="o">[</span>tcp/postgresql<span class="o">]</span> succeeded!
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">❯ kubectl <span class="nb">exec</span> -n chatwoot chatwoot-netpol-demo -- <span class="se">\
</span></span></span><span class="line"><span class="cl">  nc -zv -w3 chatwoot-chatwoot-redis-master <span class="m">6379</span>
</span></span><span class="line"><span class="cl">Connection to chatwoot-chatwoot-redis-master <span class="o">(</span>10.104.168.82<span class="o">)</span> <span class="m">6379</span> port <span class="o">[</span>tcp/redis<span class="o">]</span> succeeded!</span></span></code></pre></div>
</div>
<p>The kube-apiserver, which isn&rsquo;t in the policy at all, just hangs until the connection
times out:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">shell</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">❯ kubectl <span class="nb">exec</span> -n chatwoot chatwoot-netpol-demo -- nc -zv -w5 192.168.1.10 <span class="m">6443</span>
</span></span><span class="line"><span class="cl">nc: connect to 192.168.1.10 port <span class="m">6443</span> <span class="o">(</span>tcp<span class="o">)</span> timed out: Operation in progress
</span></span><span class="line"><span class="cl"><span class="nb">command</span> terminated with <span class="nb">exit</span> code <span class="m">1</span></span></span></code></pre></div>
</div>
<p>That&rsquo;s the difference between a policy that exists and a policy that&rsquo;s actually
enforced. I deleted the pod once I&rsquo;d seen both results.</p>
<h2 id="conclusion">Conclusion</h2>
<p>It&rsquo;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&rsquo;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
<code>NetworkPolicy</code>.</p>
]]></content:encoded></item></channel></rss>