<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Amal Zacharia </title>
    <description>The latest articles on DEV Community by Amal Zacharia  (@crza5).</description>
    <link>https://dev.to/crza5</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3947061%2F1d210c22-fc17-412b-ad67-15548e493258.gif</url>
      <title>DEV Community: Amal Zacharia </title>
      <link>https://dev.to/crza5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/crza5"/>
    <language>en</language>
    <item>
      <title>We Dropped Harbor for Large Images. A Traefik Default Was the Cause.</title>
      <dc:creator>Amal Zacharia </dc:creator>
      <pubDate>Wed, 04 Nov 2026 01:53:18 +0000</pubDate>
      <link>https://dev.to/crza5/we-dropped-harbor-for-large-images-a-traefik-default-was-the-cause-1nn</link>
      <guid>https://dev.to/crza5/we-dropped-harbor-for-large-images-a-traefik-default-was-the-cause-1nn</guid>
      <description>&lt;p&gt;I stopped pushing large images to Harbor because the pushes kept failing. The cause was a default in Traefik, which I had adopted only because ingress-nginx was retired.&lt;/p&gt;

&lt;p&gt;It took me a while to see that, and the reason is the useful part. Every metric I had said the system was healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The cluster is new and runs on bare metal. Only HTTP and HTTPS need to be reachable from outside. Everything else is consumed inside the private network or the cluster, where eBGP handles routing fine.&lt;/p&gt;

&lt;p&gt;For the public side I used Cloudflare Tunnel. A tunnel needs no public IP on the cluster. Cilium's L2 announcements were still beta and send all inbound traffic for an address to a single node, so I didn't want to build the edge on them. The tunnel also removed two chores. I didn't have to set up authenticated origin pulls, and I didn't have to maintain a firewall that blocks every IP outside Cloudflare's ranges, because the origin only makes outbound connections.&lt;/p&gt;

&lt;p&gt;The request path is Cloudflare's edge, the tunnel, Traefik, then the service. Traefik was my ingress because ingress-nginx, the previous default, was retired in March 2026. I knew nginx's timeouts well. I knew almost nothing about Traefik's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first failure: Harbor
&lt;/h2&gt;

&lt;p&gt;My images lived on GHCR. GitHub's docs say fine-grained tokens don't cover Packages, and I found no native way to issue a pull credential limited to part of an account's images. Harbor has project-level RBAC and robot accounts that stay inside one project, so I ran Harbor in the cluster.&lt;/p&gt;

&lt;p&gt;CI pushed built images to it. Small images went through. Large ones timed out, and not every time. I tried to work around it from the client side, and the workarounds were flaky. In the end I stopped sending anything over 100 MB through Harbor.&lt;/p&gt;

&lt;p&gt;I put the failures down to something in front of Harbor, most likely Cloudflare. Prometheus had nothing to contradict that. It had nothing to confirm it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same failure in a second place
&lt;/h2&gt;

&lt;p&gt;A separate project needed object storage. It builds kernel and root filesystem images for microVMs, each a few gigabytes. They lived in a hosting provider's object store, and the provider had no region near the nodes that run the microVMs. Whenever the artifact cache expired or a new artifact shipped, every node pulled it from another region over a congested public route.&lt;/p&gt;

&lt;p&gt;So I moved storage into the cluster: an S3-compatible store behind the same tunnel and the same Traefik. The first publish was a huge multipart upload, and I split it into 100 MB parts.&lt;/p&gt;

&lt;p&gt;Some parts failed. Most went through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling things out
&lt;/h2&gt;

&lt;p&gt;Splitting the upload into 100 MB parts didn't stop the failures. The &lt;code&gt;cloudflared&lt;/code&gt; pod's CPU and memory sat flat. Nothing was overloaded, so something configured was cutting the connections.&lt;/p&gt;

&lt;p&gt;The public internet doesn't guarantee throughput, which matches the pattern. A part that hit a slow stretch took longer than the others, and only those parts failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking at each hop
&lt;/h2&gt;

&lt;p&gt;The cluster was new, so observability was already in place. Prometheus and Loki had told me nothing useful. Tempo was the missing piece. I pointed Traefik's tracing at it, turned on Cloudflare's trace export to the same place, and retried the upload.&lt;/p&gt;

&lt;p&gt;A failed part looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Span&lt;/th&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare edge&lt;/td&gt;
&lt;td&gt;whole request&lt;/td&gt;
&lt;td&gt;60.27 s&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;origin fetch&lt;/td&gt;
&lt;td&gt;60.24 s&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traefik&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60.0 s&lt;/td&gt;
&lt;td&gt;504&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cloudflare saw the origin answer 504 after about 60.2 seconds. Traefik's own span ended at exactly 60.0 seconds. A network fault doesn't end at a round number.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp5vqt5tktvd4nl5x5xg0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp5vqt5tktvd4nl5x5xg0.webp" alt="Three trace spans for one failed PUT: Cloudflare edge request 60.27 s, Cloudflare origin fetch 60.24 s and Traefik PUT 60.0 s, all ending at the 60 second mark with HTTP 504" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The default
&lt;/h2&gt;

&lt;p&gt;Traefik's entrypoint &lt;code&gt;readTimeout&lt;/code&gt; defaults to 60 seconds, and the docs say it covers reading the entire request, including the body. A multipart part is one request. Any part whose body took longer than a minute to arrive was cut with a 504, whatever the client was configured to tolerate.&lt;/p&gt;

&lt;p&gt;That explained everything I had seen. The failures were intermittent because the speed of the public route varied, and client-side workarounds did nothing because the limit lived on the server. Prometheus showed nothing because a timeout adds no load.&lt;/p&gt;

&lt;p&gt;The fix is two lines per entrypoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;helm.cattle.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HelmChartConfig&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rke2-traefik&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;valuesContent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
    &lt;span class="s"&gt;ports:&lt;/span&gt;
      &lt;span class="s"&gt;web:&lt;/span&gt;
        &lt;span class="s"&gt;transport:&lt;/span&gt;
          &lt;span class="s"&gt;respondingTimeouts:&lt;/span&gt;
            &lt;span class="s"&gt;readTimeout: 10m&lt;/span&gt;
      &lt;span class="s"&gt;websecure:&lt;/span&gt;
        &lt;span class="s"&gt;transport:&lt;/span&gt;
          &lt;span class="s"&gt;respondingTimeouts:&lt;/span&gt;
            &lt;span class="s"&gt;readTimeout: 10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx behaves differently. Its &lt;code&gt;client_body_timeout&lt;/code&gt; limits the gap between two successive reads of the body, so a slow but steady upload survives it. Traefik's limit covers the whole request. I had no reason to know that difference until I migrated.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgvtrtjyehinogoe6owb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsgvtrtjyehinogoe6owb.webp" alt="nginx restarts its timer after every read of the request body, so a slow upload finishes. Traefik runs one 60 second clock for the whole request and cuts a slow part with a 504" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  After the fix
&lt;/h2&gt;

&lt;p&gt;I retried the huge upload and it went through cleanly. Then I retried Harbor, and the large pushes that used to fail succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd carry forward
&lt;/h2&gt;

&lt;p&gt;An intermittent failure that ends at a round number is a timeout until something proves otherwise. Metrics measure load, and a timeout adds none, so the dashboards can stay green the whole time.&lt;/p&gt;

&lt;p&gt;When a deprecation forces you onto a new component, read its timeout defaults before you send your largest request through it. Put traces in before you need them. The hop I couldn't see was the one at fault.&lt;/p&gt;

&lt;p&gt;I dropped Harbor for large images on a wrong explanation, and I only found the right one because a different project forced me to look.&lt;/p&gt;

</description>
      <category>traefik</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Five Clusters. Five Lessons. One Production System.</title>
      <dc:creator>Amal Zacharia </dc:creator>
      <pubDate>Sat, 23 May 2026 04:53:30 +0000</pubDate>
      <link>https://dev.to/crza5/five-clusters-five-lessons-one-production-system-162o</link>
      <guid>https://dev.to/crza5/five-clusters-five-lessons-one-production-system-162o</guid>
      <description>&lt;p&gt;Most engineers talk about Kubernetes like it's something you set up once and forget. I set it up five times. Each time because the last version taught me something I couldn't have learned any other way.&lt;/p&gt;

&lt;p&gt;This is that story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest version of each stage:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stage 1. &lt;em&gt;I just need HA.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stage 2. &lt;em&gt;I just need cheaper HA.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stage 3. &lt;em&gt;I just need multi-cloud ARM scheduling.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stage 4. &lt;em&gt;I just need BGP mesh ingress.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stage 5. &lt;em&gt;I have achieved zero trust enlightenment.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every stage felt like a small reasonable ask. The cluster had other ideas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1: The Naive But Functional Cluster
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhpkcxstkk64sm3xwaeyp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhpkcxstkk64sm3xwaeyp.png" alt=" " width="760" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first cluster wasn't elegant. It wasn't supposed to be.&lt;/p&gt;

&lt;p&gt;Three to five Hetzner dedicated nodes running k3s. Three DigitalOcean VPS running nginx as a makeshift load balancer. A DigitalOcean cloud load balancer sitting in front of those three VPS.&lt;/p&gt;

&lt;p&gt;I followed Techno Tim's k3s HA install guide as the foundation. Solid starting point for getting a highly available cluster off the ground. Replaced his default Traefik ingress with nginx and added Rancher dashboard on top for visibility. Everything after that was documentation and experimentation.&lt;/p&gt;

&lt;p&gt;Before this everything ran on plain VPS. Individual servers. No orchestration. Moving to Kubernetes meant migrating workloads one by one. Old VPS running stable, new cluster being tested in parallel, services moved gradually. If something broke I reverted. Nothing burned.&lt;/p&gt;

&lt;p&gt;The cluster itself was stable. No memorable incidents with the nodes or networking. The one persistent irritation was MongoDB. Running Bitnami's MongoDB HA chart. One write replica, two read replicas. Randomly and without warning one of the pods would enter a crashloop. Logs pointed vaguely at lock contention but nothing actionable. A restart brought it back clean every time.&lt;/p&gt;

&lt;p&gt;The read heavy workload saved me. A local cache layer absorbed most requests so when a replica went down users barely felt it. I'd notice eventually. Not through alerts, just observation. Restart the StatefulSet and move on.&lt;/p&gt;

&lt;p&gt;It happened often enough to be annoying. Never often enough to be a crisis. The cluster was otherwise stable. This was just a ghost that came with the Bitnami chart.&lt;/p&gt;

&lt;p&gt;It followed me into the next stage. And the one after that.&lt;/p&gt;

&lt;p&gt;Cost pressure eventually killed this setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: The WireGuard Mesh
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1f6gshbxrav2gkv82cfz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1f6gshbxrav2gkv82cfz.png" alt=" " width="760" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three DigitalOcean VPS plus one cloud load balancer to serve less than a thousand requests a month. Those thousand requests were me hitting the Rancher dashboard. Every actual workload was outbound only. Bots, task processors, queue consumers. Nothing needed to receive HTTP from the internet. Kubernetes was not there to serve traffic. The nginx tier existed for one admin panel. The math didn't make sense.&lt;/p&gt;

&lt;p&gt;I wanted to move to Contabo. Not cheaper in absolute terms but more efficient for my actual workload requirements. Hetzner dedicated nodes were sitting with RAM mostly idle. I was paying for resources I wasn't using.&lt;/p&gt;

&lt;p&gt;The problem was Contabo had no private network at the time.&lt;/p&gt;

&lt;p&gt;So I built one.&lt;/p&gt;

&lt;p&gt;WireGuard. I found it while researching private networking options. I looked at Kilo as well, a Kubernetes-native WireGuard mesh operator, but it felt too complex for what I needed. Netclient handled mesh generation automatically. WireGuard configurations for multiple peers without the manual key exchange headache.&lt;/p&gt;

&lt;p&gt;A single DigitalOcean VPS became the entry point. Running the Netclient server and dashboard. All the Contabo nodes connected through it via mesh. First try. Everything came up clean.&lt;/p&gt;

&lt;p&gt;The three nginx VPS and the cloud load balancer disappeared. Previously traffic flowed from Klipper through three nginx VPS to a DigitalOcean cloud load balancer before reaching users. An entire layer of infrastructure serving as a middleman. Replaced now by Klipper speaking directly to users. The elaborate routing chain had been architectural overkill from the start.&lt;/p&gt;

&lt;p&gt;Contabo had weird reliability. Nodes would occasionally behave unexpectedly in ways that didn't make sense on paper. But the workload was tolerant to disruption and the cost saving from eliminating the most expensive components made the occasional weirdness acceptable.&lt;/p&gt;

&lt;p&gt;The MongoDB crashloop followed. Still there. Still managed by cache and manual restarts. Not catastrophic. Just persistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: ARM Workers and the First Multi-Cloud Cluster
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyv1go7xxukdwq31thnab.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyv1go7xxukdwq31thnab.png" alt=" " width="760" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ARM compute is cheap. Oracle's free tier ARM instances are extremely cheap. As in free.&lt;/p&gt;

&lt;p&gt;A Network Chuck video appeared in my YouTube recommendations one day. Kubernetes on Raspberry Pi. Watched it casually. The part that stuck was his explanation of why he ran the Rancher dashboard on an amd64 node rather than the ARM64 ones. The reasoning was practical. Certain workloads and management tooling simply behaved better on amd64. That one observation shaped the architecture before I'd even started.&lt;/p&gt;

&lt;p&gt;Master nodes stayed on amd64. Workers went on ARM64. Clean separation from the beginning.&lt;/p&gt;

&lt;p&gt;I extended the Netclient WireGuard mesh to include Oracle ARM nodes in a different region. Multi-cloud before that was a common thing to talk about. Not for ideological reasons. Purely because free ARM compute was sitting there unused. The scale didn't justify multi-cloud architecture. The price did. Free compute is free compute.&lt;/p&gt;

&lt;p&gt;Multi-arch builds handled through GoReleaser. On every GitHub commit, GitHub Actions triggered GoReleaser. Publishing multi-arch Docker images and binaries as releases automatically. Services with amd64-only dependencies got node affinity rules keeping them on the Contabo nodes. Cross-architecture scheduling handled through proper affinity configuration rather than hoping things landed in the right place.&lt;/p&gt;

&lt;p&gt;Oracle's networking was strange. Occasionally a worker node would drift out of WireGuard mesh sync and require a restart. Never more than one at a time. Enough redundancy that it didn't cause visible issues. Whether that was a free tier limitation or something inherent to their networking I never definitively determined.&lt;/p&gt;

&lt;p&gt;The MongoDB crashloop was still there. Quietly persistent across three different infrastructure setups now.&lt;/p&gt;

&lt;p&gt;What ended it was a new client. Twenty Discord bots. Each one 512MB RAM at startup, 2GB at peak load. Discord.js cache is RAM hungry by design. The library caches guild members, messages, and state aggressively. Twenty bots at peak meant up to 40GB of RAM requirement for workloads that could not be disrupted.&lt;/p&gt;

&lt;p&gt;Oracle free tier ARM nodes are not where you run non-disruptable RAM-intensive production workloads.&lt;/p&gt;

&lt;p&gt;Time to go back to dedicated hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: Real High Availability
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhsu2xgpgafa8b54kcwly.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhsu2xgpgafa8b54kcwly.png" alt=" " width="760" height="690"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five Hetzner nodes. Dual network. This is where the cluster started looking like something serious.&lt;/p&gt;

&lt;p&gt;The private VLAN handled all internode communication. k3s configured via args to bind to the private IP. Not 0.0.0.0. Cluster join happened over private IPs not public ones. The public interface existed for egress only at the node level.&lt;/p&gt;

&lt;p&gt;The goal with CNI was specific. Klipper, k3s's default service load balancer, requires port 80 and 443 open on every node to function. I didn't want that. I wanted a single advertised IP for ingress, not ports splayed across every node in the cluster.&lt;/p&gt;

&lt;p&gt;Calico with BGP was the answer. Or so I thought.&lt;/p&gt;

&lt;p&gt;The setup was architecturally sound. Five Hetzner dedicated nodes running a BGP mesh via Calico. MetalLB advertising a private IP range for load balancer services. That private range connected to Hetzner's cloud network via static routes. Each node acting as a router. A Hetzner cloud load balancer sitting in front, pointing to the MetalLB advertised IP. Clean. No ports open on individual nodes. Exactly what I wanted.&lt;/p&gt;

&lt;p&gt;Keepalived handled the cluster join entry point. A floating IP that survived node failures. I tested this manually during bootstrap. Simulated a node failure. Keepalived moved the IP cleanly to a healthy node. That part worked exactly as expected.&lt;/p&gt;

&lt;p&gt;The BGP setup worked too. Technically. I ran it for a month.&lt;/p&gt;

&lt;p&gt;Then I measured HTTP latency.&lt;/p&gt;

&lt;p&gt;The requests going through the Hetzner cloud network into the BGP mesh and back out were noticeably slow. Not broken. Just slow in a way that didn't make sense given the hardware. I only had ingress exposed so I couldn't measure other protocols. Whether it was Hetzner Cloud's routing adding overhead or something in the mesh I never definitively diagnosed.&lt;/p&gt;

&lt;p&gt;I reverted. Wrote it off as a cloud networking quirk. The internode BGP mesh stayed. Calico peers between all five nodes remained stable. But MetalLB and the cloud LB routing experiment went back to Keepalived managing a floating IP with Hetzner API hooks for failover.&lt;/p&gt;

&lt;p&gt;Something else happened at this stage. The MongoDB crashloop stopped. I don't know exactly when. I don't know exactly why. It had followed me through three different infrastructure setups. Different providers, different CNIs, different everything. Then somewhere around moving to Calico it just stopped. Never diagnosed. Never explained. Just gone.&lt;/p&gt;

&lt;p&gt;Around a hundred workloads running at this point. Bots, websites, the WebSocket infrastructure, client services accumulated over years.&lt;/p&gt;

&lt;p&gt;What ended Stage 4 wasn't a failure. It was a conference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interlude: The Homelab Test
&lt;/h2&gt;

&lt;p&gt;Before this I validated experiments on DigitalOcean VPS. Spin one up, test the idea, tear it down. Clean. Disposable. No commitment.&lt;/p&gt;

&lt;p&gt;But before committing rke2 and Cilium to production I wanted to test on real hardware. Not a cloud VM. Actual bare metal.&lt;/p&gt;

&lt;p&gt;I had an old PC. Booted Ventoy, loaded a gparted live image, used dd to write Debian directly to the disk. Configured SSH and networking. Came back to my main machine and SSHed in. Bare metal provisioning the old fashioned way. No cloud console, no managed image, just a disk, Ventoy, and a Debian ISO.&lt;/p&gt;

&lt;p&gt;Installed rke2. Installed Cilium. Configured BGP and set static routes in my home router to expose services publicly. Ran a PostgreSQL instance. One database per project, cleaner than running everything on my main machine. Threw a Minecraft server and an Ark Survival server on it so friends could connect.&lt;/p&gt;

&lt;p&gt;It worked. Everything I planned for Stage 5 behaved the way I expected on real hardware before I committed it to production.&lt;/p&gt;

&lt;p&gt;That's the point of a homelab. Not to show off. Not to run unnecessary infrastructure. To answer the question before production has to.&lt;/p&gt;

&lt;p&gt;Stage 5 wasn't a leap of faith. It was a validated decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 5: Zero Trust and the Hardened Cluster
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnstupkv4p3hjyuayj3u7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnstupkv4p3hjyuayj3u7.png" alt=" " width="760" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The current cluster. OVH this time, not Hetzner.&lt;/p&gt;

&lt;p&gt;OVH has better DDoS protection. Their uptime guarantees are stronger.&lt;/p&gt;

&lt;p&gt;rke2 replaced k3s as the distribution. The operational feel is similar. rke2 is heavier. Larger resource footprint. But for a cluster running serious production workloads that weight is appropriate.&lt;/p&gt;

&lt;p&gt;Cilium replaced Calico as CNI.&lt;/p&gt;

&lt;p&gt;I saw Cilium at KubeCon. But the real reason was simpler. I had never successfully run a firewall alongside Kubernetes. Every attempt broke with CNI changes. iptables rules conflicting with CNI networking. Configurations that worked until a CNI update silently invalidated them. I gave up trying to run a traditional firewall on Kubernetes nodes entirely.&lt;/p&gt;

&lt;p&gt;Cilium's host firewall was different. It runs at the eBPF layer, below where CNI conflicts happen. That was the actual problem I needed solved. KubeCon introduced me to the solution. The L2 load balancer announcement was a bonus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;L2 announcement for load balancer IPs.&lt;/strong&gt; A few YAML manifests and the cluster manages IP assignment itself. The goal from Stage 4 finally solved. Differently than expected but solved. Keepalived still exists for the cluster join endpoint but the complex API hook dance for floating IP management is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Host firewall via CiliumClusterwideNetworkPolicy.&lt;/strong&gt; The public IP of every node is egress only. Nodes can initiate connections to the world. The world cannot initiate connections to nodes. Not even ping. Except from monitoring systems and my static IP for SSH. Enforced at the kernel level on every node via eBPF. Not a firewall rule sitting in front of the infrastructure. The infrastructure itself is the firewall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingress hardened to Cloudflare only.&lt;/strong&gt; Traefik as the ingress controller. Cilium network policy restricts ingress traffic to Cloudflare IP ranges only at the network level. Traefik middleware handles mTLS authenticated pull on top of that. Two layers of verification before any request reaches a service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Namespace isolation by default.&lt;/strong&gt; Network policies deny all ingress by default. Services explicitly whitelist what they need. DNS, shared resources, specific cross-namespace communication. The model is incoming-decides. Each namespace declares what it accepts rather than what it sends. No traffic sniffing between namespaces is possible by policy.&lt;/p&gt;

&lt;p&gt;The cluster currently runs bots, websites, and accumulated client workloads. It is boring in the useful sense. Deployable, diagnosable, recoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Five Clusters Taught Me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cost pressure is a better architect than planning.&lt;/strong&gt; Every significant change in this infrastructure happened because something was too expensive relative to its value. The nginx VPS layer. The idle Hetzner RAM. The Oracle free tier nodes that couldn't handle non-disruptable workloads. Cost pressure forced clarity about what the infrastructure actually needed to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first version is supposed to be wrong.&lt;/strong&gt; One video and documentation got the first cluster running. That was enough. The wrongness of the first version taught me what the second version needed to be.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistent problems have hidden causes.&lt;/strong&gt; MongoDB crashed across three different providers, two different CNIs, and multiple cluster rebuilds. Then stopped. I never found the root cause. Sometimes production systems have ghosts you learn to live with until they quietly leave on their own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security is a journey not a configuration.&lt;/strong&gt; Stage 1 had no meaningful firewall story. Stage 5 has host-level eBPF enforcing zero trust at the kernel. That's not a single decision. That's five iterations of failing to run a firewall properly until the right tool existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simplicity compounds.&lt;/strong&gt; Every stage removed something. The nginx VPS layer. The cloud load balancer. The WireGuard entry point VPS. The Keepalived API hooks. Each removal made the system more reliable not less. The most complex part of Stage 1 was the load balancing layer doing the least important work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context beats technology.&lt;/strong&gt; Multi-cloud sounds impressive. The reality was free Oracle ARM compute that cost nothing to add. Cilium sounds cutting edge. The reality was a persistent firewall problem that nothing else solved cleanly. The technology follows the problem. Not the other way around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate before you migrate.&lt;/strong&gt; I didn't move production to rke2 and Cilium based on a KubeCon talk. I went home, provisioned a bare metal server from scratch, and ran the stack on real hardware first. The homelab wasn't a hobby. It was due diligence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security - Five Stage Evolution
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Security Posture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stage 1&lt;/td&gt;
&lt;td&gt;No meaningful firewall · MongoDB on crashloop · Manual restart as incident response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 2&lt;/td&gt;
&lt;td&gt;WireGuard mesh · First network isolation layer · Removed public-facing VPS middlemen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 3&lt;/td&gt;
&lt;td&gt;Node affinity arch-based isolation · Multi-cloud scheduling boundaries · GoReleaser controlled build pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 4&lt;/td&gt;
&lt;td&gt;Private VLAN for internode comms · Public interface egress only · Keepalived tested at bootstrap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage 5&lt;/td&gt;
&lt;td&gt;eBPF host firewall nodes egress only · Cloudflare-only ingress + mTLS · Namespace isolation by default · No traffic sniffing between namespaces&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Stage 1&lt;/strong&gt; ░░░░░░░░░░░░░░░░░░░░&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Stage 2&lt;/strong&gt; ████░░░░░░░░░░░░░░░░&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Stage 3&lt;/strong&gt; ████████░░░░░░░░░░░░&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Stage 4&lt;/strong&gt; ████████████░░░░░░░░&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Stage 5&lt;/strong&gt; ████████████████████  &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Security is a journey not a configuration. Five iterations of failing to run a firewall properly until the right tool existed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Available&lt;/strong&gt; — Senior platform / SRE roles. Remote.&lt;br&gt;&lt;br&gt;
&lt;a href="mailto:contact@crza.dev"&gt;contact@crza.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>infrastructure</category>
      <category>cloudnative</category>
    </item>
  </channel>
</rss>
