<?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: yep</title>
    <description>The latest articles on DEV Community by yep (@yepchaos).</description>
    <link>https://dev.to/yepchaos</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2089712%2Ffa6eed7d-19b8-48b9-8c23-dd66c11a895e.jpg</url>
      <title>DEV Community: yep</title>
      <link>https://dev.to/yepchaos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yepchaos"/>
    <language>en</language>
    <item>
      <title>Active/Active Multi-region - Chat application Architecture</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Tue, 21 Apr 2026 13:06:41 +0000</pubDate>
      <link>https://dev.to/yepchaos/activeactive-multi-region-chat-application-architecture-395b</link>
      <guid>https://dev.to/yepchaos/activeactive-multi-region-chat-application-architecture-395b</guid>
      <description>&lt;p&gt;In the previous post I covered how I connected two Kubernetes clusters across Mongolia and Germany using Netbird. That was the networking layer — pods can reach each other, DNS works across clusters. Now the interesting part: making the actual application work active/active across both regions.&lt;/p&gt;

&lt;p&gt;Active/active means both clusters run independently and serve users, but a user on cluster A can chat with a user on cluster B in real time. No single point of failure, no "primary" region. Either cluster can go down and the other keeps running.&lt;/p&gt;

&lt;p&gt;This breaks down into three problems: real-time events, chat history, and application state. Each one needs a different solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: Real-Time Events (NATS Super-Cluster)
&lt;/h2&gt;

&lt;p&gt;For single-cluster WebSocket scaling I already use NATS — covered in an earlier post. The short version: all WebSocket servers publish and subscribe through NATS, so a message from a user on server A reaches a user on server B without those servers knowing about each other.&lt;/p&gt;

&lt;p&gt;For multi-region, NATS has a concept called a &lt;strong&gt;super-cluster&lt;/strong&gt;. You deploy independent NATS clusters in each region and connect them together. Messages published in one cluster eventually replicate to the other. "Eventually" here means milliseconds of extra latency — there are more network hops, but I accept that.&lt;/p&gt;

&lt;p&gt;Setup is straightforward. Deploy NATS in each cluster using the operator (Helm chart), then configure the super-cluster by pointing each cluster at the other's gateway endpoints. After that, the application doesn't change at all. A backend in Germany subscribes to the same subjects as a backend in Mongolia. A message published in one region fans out to both. The application has no idea it's talking to a distributed system — it just publishes and subscribes like before.&lt;/p&gt;

&lt;p&gt;This is the cleanest part of the whole setup. NATS was designed for this, and it shows. Example &lt;code&gt;values.yaml&lt;/code&gt;&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;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cluster&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;merge&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;astring-fsn1&lt;/span&gt;

  &lt;span class="na"&gt;gateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7522&lt;/span&gt;
    &lt;span class="na"&gt;merge&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;astring-fsn1&lt;/span&gt;
      &lt;span class="na"&gt;gateways&lt;/span&gt;&lt;span class="pi"&gt;:&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;astring-mn&lt;/span&gt;
          &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;nats://nats-mn-headless.nats.astring-mn.internal:7522&lt;/span&gt;

  &lt;span class="na"&gt;monitor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8222&lt;/span&gt;
  &lt;span class="na"&gt;merge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt; $NATS_USER &amp;gt;&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;&amp;lt; $NATS_PASSWORD &amp;gt;&amp;gt;&lt;/span&gt;

&lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;NATS_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;secretKeyRef&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;nats-auth-secret&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;username&lt;/span&gt;
    &lt;span class="na"&gt;NATS_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;secretKeyRef&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;nats-auth-secret&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nats&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;gateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;monitor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;promExporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;NATS_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;secretKeyRef&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;nats-auth-secret&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;username&lt;/span&gt;
    &lt;span class="na"&gt;NATS_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;secretKeyRef&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;nats-auth-secret&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;

&lt;span class="na"&gt;reloader&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;natsBox&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 2: Chat History (Cassandra, Not ScyllaDB)
&lt;/h2&gt;

&lt;p&gt;I was using ScyllaDB. In December 2024 ScyllaDB moved from AGPL to a source-available license — the code is still public on GitHub, but running a cluster beyond a certain size requires a commercial license. ScyllaDB Manager (the tool for automation, repairs, and backups) is limited to 5 nodes on the free version. It's technically "open source" but not really anymore. I switched to Cassandra, which is fully open source under Apache 2.0 and has the same architecture.&lt;/p&gt;

&lt;p&gt;For multi-region, Cassandra is actually the best-fit database I've worked with. Cassandra natively understands the concept of &lt;strong&gt;datacenters&lt;/strong&gt; — your two sites aren't two separate clusters, they're two DCs in one logical Cassandra cluster. Replication is configured per-DC. Consistency levels let you decide per-query whether you need a local quorum (fast, single-region) or global quorum (slower but cross-region consistent).&lt;/p&gt;

&lt;p&gt;For chat history, I use local quorum for reads and writes. Messages replicate to the other DC asynchronously. A user reading chat history gets it from their local DC — fast. Eventually the other DC catches up. For chat history this is fine — nobody needs sub-millisecond cross-region consistency for reading old messages.&lt;/p&gt;

&lt;p&gt;For Kubernetes I use the &lt;strong&gt;k8ssandra-operator&lt;/strong&gt;, which manages Cassandra clusters across multiple Kubernetes clusters. This is where it gets interesting: the operator needs to manage pods in both &lt;code&gt;cluster-mn&lt;/code&gt; and &lt;code&gt;cluster-de&lt;/code&gt;, which means it needs to reach both clusters. I deploy the k8ssandra-operator on a separate management cluster — a small single-node k3s cluster that reaches both application clusters through Netbird. The operator registers both clusters and treats them as two DCs in one Cassandra deployment.&lt;/p&gt;

&lt;p&gt;If the management cluster goes down, the Cassandra cluster keeps running — the operator just can't make configuration changes until it comes back. Acceptable tradeoff. After the register the 2 clusters (use official doc, they have explained better), my &lt;code&gt;cluster.yaml&lt;/code&gt; is&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;k8ssandra.io/v1alpha1&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;K8ssandraCluster&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;astring&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;k8ssandra-operator&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;cassandra&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;serverVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.0.10"&lt;/span&gt;
    &lt;span class="na"&gt;telemetry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mcac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;storageConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cassandraDataVolumeClaimSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openebs-hostpath&lt;/span&gt;
        &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cassandraYaml&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;listen_address&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0"&lt;/span&gt;
      &lt;span class="na"&gt;jvmOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;heapSize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512M&lt;/span&gt;
    &lt;span class="na"&gt;datacenters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;dc1&lt;/span&gt;
        &lt;span class="na"&gt;k8sContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astring-mn&lt;/span&gt;
        &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;dc2&lt;/span&gt;
        &lt;span class="na"&gt;k8sContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astring-fsn1&lt;/span&gt;
        &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;stargate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;heapSize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512M&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 3: Application Database (The Hard Part)
&lt;/h2&gt;

&lt;p&gt;NATS and Cassandra were relatively clean. Postgres is where I spent most of my time.&lt;/p&gt;

&lt;p&gt;Postgres stores users, rooms, OTPs, metadatas — all the relational data. The problem: Postgres has one primary at a time. All writes go to the primary, replicas are read-only. In a multi-region setup, if the primary is in Mongolia and a user in Germany does a login, that request either needs to cross the ocean to write (200ms penalty) or I need two primaries that stay in sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Looked At
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;TiDB / SurrealDB (TiKV-based)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These are impressive databases but built for low-latency interconnects — single region or multi-AZ with &amp;lt;10ms between nodes. Stretch them across continents and the distributed SQL magic collapses for three specific reasons:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;TSO coordination latency.&lt;/em&gt; TiDB relies on a Placement Driver (PD) acting as a Timestamp Oracle (TSO) to assign globally ordered timestamps. While timestamp allocation is optimized (batched/pipelined), it still requires coordination with a leader. In a Mongolia–Germany setup, this introduces non-trivial latency before transaction execution, especially under high concurrency.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Raft + 2PC write latency.&lt;/em&gt; TiKV uses Raft consensus for replication and Percolator-style two-phase commit for distributed transactions. Writes require quorum acknowledgment, which in cross-region setups means at least one intercontinental round trip. Combined with 2PC coordination, end-to-end write latency can reach hundreds of milliseconds.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Scaling across regions.&lt;/em&gt; Adding more regions increases coordination overhead (more replicas, more quorum distance). These systems scale well within a region, but cross-region deployments require careful topology design and acceptance of higher write latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CockroachDB&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CockroachDB&lt;/strong&gt; has similar characteristics: &lt;em&gt;Consensus-driven latency.&lt;/em&gt; CockroachDB also uses Raft for replication. Cross-region writes require quorum, so latency is bounded by inter-region round trips, similar to TiDB/TiKV.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Operational and licensing considerations.&lt;/em&gt; Recent versions have shifted licensing and feature availability. Advanced capabilities like geo-partitioning (which help localize data and reduce cross-region latency) are part of paid tiers. This introduces constraints for setups that require fine-grained data locality control without additional licensing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YugabyteDB&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This one I actually deployed and tested. YugabyteDB is Kubernetes-native, supports active/active replication through their xCluster feature, and the management UI is genuinely good — modern, clear, well-designed. I ran it on both clusters using their operator.&lt;/p&gt;

&lt;p&gt;The xCluster setup works: deploy two independent YugabyteDB clusters, configure bidirectional xCluster replication between them. In theory, writes in Mongolia replicate to Germany and vice versa.&lt;/p&gt;

&lt;p&gt;The dealbreaker: &lt;strong&gt;no DDL replication&lt;/strong&gt;. Every time I add a table or alter a schema, I have to manually register each new table in the xCluster configuration. There's no automation for this in the open source version — I'd have to go into the dashboard, find the table ID, and add it manually every time. The UI for xCluster management is also rough. YugabyteDB Anywhere (their managed product) handles this properly, but that requires a license.&lt;/p&gt;

&lt;p&gt;Here's the values I used — it works fine for single-cluster if you're interested:&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;Image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2025.2.1.0-b141&lt;/span&gt;

&lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openebs-hostpath&lt;/span&gt;
  &lt;span class="na"&gt;tserver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2Gi&lt;/span&gt;
    &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openebs-hostpath&lt;/span&gt;

&lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.5Gi&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
  &lt;span class="na"&gt;tserver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.5Gi&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;

&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;tserver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

&lt;span class="na"&gt;partition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;master&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;tserver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;

&lt;span class="na"&gt;domainName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;zone&amp;gt;.internal"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And creating xCluster replication (run from inside a pod):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yb-admin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--master_addresses&lt;/span&gt; yb-master-0.yb-masters.yb.svc.astring-fsn1.internal:7100,... &lt;span class="se"&gt;\&lt;/span&gt;
  setup_universe_replication &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;replication_id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  yb-master-0.yb-masters.yb.svc.astring-mn.internal:7100,... &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;table_id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the table ID from the dashboard manually. As I said — not practical.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Actually Use: PgEdge + Spock
&lt;/h3&gt;

&lt;p&gt;After going through all of that, I ended up with two independent Postgres clusters synchronized using logical replication via &lt;strong&gt;Spock&lt;/strong&gt; — a Postgres extension that enables multi-master replication. &lt;strong&gt;PgEdge&lt;/strong&gt; is a Helm chart built on top of CloudNativePG that packages Spock with a proper Kubernetes operator.&lt;/p&gt;

&lt;p&gt;CloudNativePG is excellent — backup, restore, WAL archiving, high availability all work seamlessly. PgEdge adds Spock on top for the cross-cluster sync.&lt;/p&gt;

&lt;p&gt;The architecture: two independent Postgres clusters (one per region), each a primary with replicas. Spock creates a logical replication subscription in each direction — cluster-mn subscribes to cluster-de, cluster-de subscribes to cluster-mn. Writes in either region replicate to the other asynchronously.&lt;/p&gt;

&lt;p&gt;Helm values for each cluster:&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;pgEdge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;appName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astring-cluster&lt;/span&gt;
  &lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&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;n1&lt;/span&gt;
      &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;astring-cluster-n1-rw&lt;/span&gt;
      &lt;span class="na"&gt;clusterSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;instances&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
        &lt;span class="na"&gt;enableSuperuserAccess&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="na"&gt;postgresql&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;track_commit_timestamp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on"&lt;/span&gt;
            &lt;span class="na"&gt;wal_level&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;logical"&lt;/span&gt;
        &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="pi"&gt;:&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;barman-cloud.cloudnative-pg.io&lt;/span&gt;
            &lt;span class="na"&gt;isWALArchiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
            &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;barmanObjectName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;r2-storage&lt;/span&gt;
  &lt;span class="na"&gt;clusterSpec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
      &lt;span class="na"&gt;storageClass&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openebs-hostpath&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After deploying both clusters, I run an initialization script that sets up the database, roles, and Spock nodes on each cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="nv"&gt;CONTEXTS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"astring-mn"&lt;/span&gt; &lt;span class="s2"&gt;"astring-fsn1"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"astring_prod"&lt;/span&gt;
&lt;span class="nv"&gt;NAMESPACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pgedge"&lt;/span&gt;
&lt;span class="nv"&gt;POD_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"astring-cluster-n1-1"&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;CTX &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONTEXTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--- Initializing: &lt;/span&gt;&lt;span class="nv"&gt;$CTX&lt;/span&gt;&lt;span class="s2"&gt; ---"&lt;/span&gt;

    &lt;span class="nv"&gt;POD_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get pod &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POD_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAMESPACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.podIP}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;SUPER_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get secret &lt;span class="s2"&gt;"astring-cluster-n1-superuser"&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAMESPACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;--decode&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;APP_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get secret &lt;span class="s2"&gt;"astring-cluster-n1-app"&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NAMESPACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;--decode&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

    &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="s2"&gt;"mn"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;NODE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region_mn"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;NODE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region_fsn1"&lt;/span&gt;
    &lt;span class="nv"&gt;DSN_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"astring-cluster-n1-rw.pgedge.svc.cluster.local"&lt;/span&gt;

    &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$SUPER_PASS&lt;/span&gt;

    psql &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POD_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"CREATE DATABASE &lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt;;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
    &lt;/span&gt;psql &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POD_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
        ALTER ROLE app WITH REPLICATION;
        ALTER DATABASE &lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt; OWNER TO app;
        CREATE EXTENSION IF NOT EXISTS spock;
        GRANT USAGE ON SCHEMA spock TO app;
        GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA spock TO app;
        GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA spock TO app;
    "&lt;/span&gt;

    psql &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$POD_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
        SELECT spock.node_create(
            node_name := '&lt;/span&gt;&lt;span class="nv"&gt;$NODE_NAME&lt;/span&gt;&lt;span class="s2"&gt;',
            dsn := 'host=&lt;/span&gt;&lt;span class="nv"&gt;$DSN_HOST&lt;/span&gt;&lt;span class="s2"&gt; port=5432 dbname=&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt; user=app password=&lt;/span&gt;&lt;span class="nv"&gt;$APP_PASS&lt;/span&gt;&lt;span class="s2"&gt;'
        );
    "&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;unset &lt;/span&gt;PGPASSWORD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then sync an initial data dump to make both clusters start from the same state, and set up bidirectional replication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

&lt;span class="nv"&gt;C1_CTX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"astring-mn"&lt;/span&gt;
&lt;span class="nv"&gt;C2_CTX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"astring-fsn1"&lt;/span&gt;
&lt;span class="nv"&gt;NS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pgedge"&lt;/span&gt;
&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"production_db"&lt;/span&gt;

&lt;span class="nv"&gt;C1_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgres-primary.pgedge.astring-mn.internal"&lt;/span&gt;
&lt;span class="nv"&gt;C2_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgres-primary.pgedge.astring-fsn1.internal"&lt;/span&gt;

get_ip&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; kubectl get pod &lt;span class="s2"&gt;"astring-cluster-n1-1"&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.podIP}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
get_pass&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; kubectl get secret &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;--decode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;C1_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_ip &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C1_CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;C2_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_ip &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C2_CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;C1_SUP_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_pass &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C1_CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"astring-cluster-n1-superuser"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;C2_SUP_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_pass &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C2_CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"astring-cluster-n1-superuser"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;C1_APP_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_pass &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C1_CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"astring-cluster-n1-app"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;C2_APP_PASS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;get_pass &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C2_CTX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"astring-cluster-n1-app"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# FSN1 subscribes to MN&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$C2_SUP_PASS&lt;/span&gt;
psql &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C2_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
SELECT spock.sub_create(
    subscription_name := 'sub_to_region_mn',
    provider_dsn := 'host=&lt;/span&gt;&lt;span class="nv"&gt;$C1_HOST&lt;/span&gt;&lt;span class="s2"&gt; port=5432 dbname=&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt; user=app password=&lt;/span&gt;&lt;span class="nv"&gt;$C1_APP_PASS&lt;/span&gt;&lt;span class="s2"&gt;'
);"&lt;/span&gt;

&lt;span class="c"&gt;# MN subscribes to FSN1&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$C1_SUP_PASS&lt;/span&gt;
psql &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C1_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
SELECT spock.sub_create(
    subscription_name := 'sub_to_region_fsn1',
    provider_dsn := 'host=&lt;/span&gt;&lt;span class="nv"&gt;$C2_HOST&lt;/span&gt;&lt;span class="s2"&gt; port=5432 dbname=&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt; user=app password=&lt;/span&gt;&lt;span class="nv"&gt;$C2_APP_PASS&lt;/span&gt;&lt;span class="s2"&gt;'
);"&lt;/span&gt;

&lt;span class="nb"&gt;unset &lt;/span&gt;PGPASSWORD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DDL syncs automatically — add a table in one cluster, it appears in the other. No manual table registration like YugabyteDB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One important detail:&lt;/strong&gt; with two independent primaries both generating IDs, you need to make sure sequences don't conflict. If both clusters auto-increment from 1, you get duplicate primary keys. Update the sequence ID &amp;amp; increment on both database or use uuid v7, snowflake ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Stands
&lt;/h2&gt;

&lt;p&gt;Both clusters run independently behind GSLB. Users are routed to the nearest region, so normal operations stay local — no cross-ocean round trips on the critical path. Within each cluster, data remains strongly consistent. Across regions, I accept eventual consistency and the small window where state may diverge (e.g., a newly created user that hasn’t replicated yet during a failover). &lt;em&gt;(I’ll cover the GSLB setup and routing details separately.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Real-time messaging flows through a &lt;strong&gt;NATS&lt;/strong&gt; supercluster, chat history is replicated using &lt;strong&gt;Apache Cassandra&lt;/strong&gt;’s multi–data center replication, and application-level state syncs through Spock. &lt;/p&gt;

&lt;p&gt;Is this over-engineered for a chat app with 10 users? Yes. &lt;/p&gt;

&lt;p&gt;The architecture scales in a straightforward way — adding a new region means deploying another cluster and integrating it into the existing messaging and replication topology, with the usual tradeoffs around replication lag and consistency.&lt;/p&gt;

&lt;p&gt;ArgoCD manages all of this across all three clusters — application clusters and management cluster — through ApplicationSets. Maybe I’ll write about this later.&lt;/p&gt;

</description>
      <category>multiregion</category>
      <category>activeactive</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Multi region kubernetes cluster (Onprem/Public Cloud)</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Mon, 20 Apr 2026 06:11:33 +0000</pubDate>
      <link>https://dev.to/yepchaos/multi-region-kubernetes-cluster-onprempublic-cloud-4ifl</link>
      <guid>https://dev.to/yepchaos/multi-region-kubernetes-cluster-onprempublic-cloud-4ifl</guid>
      <description>&lt;p&gt;A single Kubernetes cluster is manageable. You deploy things, they run, life is good. Things get complicated when you have clusters in multiple regions that need to talk to each other. This post covers why I needed multi-region, what I tried, what didn’t work, and how I got pod-to-pod connectivity between Mongolia and Germany using Netbird.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Multi-Region
&lt;/h2&gt;

&lt;p&gt;The answer has two parts.&lt;/p&gt;

&lt;p&gt;First: the Mongolia cluster is on-premise, and this infrastructure is unstable. Network switches fail, the DC has maintenance windows, things go down. With only one cluster, when it goes down, the app goes down. Even with 10 users, they deserve better.&lt;/p&gt;

&lt;p&gt;Second: I wanted to build something complicated and solve problems I didn't actually have yet. Active/active multi-region means clients in different geographies connect to their nearest cluster — a user in Germany shouldn't be routing through Mongolia just to send a chat message. That's ~200ms round trip just to reach the server. For a chat app that latency is noticeable. I don't have German users yet. I don't have many users at all. But the architecture is ready for when the app explodes. Hope it’s gonna explode.&lt;/p&gt;

&lt;p&gt;For the second cluster I chose Hetzner — cheap, stable, good network. Germany datacenter. I provision it with Terraform and run k3s with Cilium, same as Mongolia.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Looked At and Ruled Out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stretched k3s Cluster (Single etcd)
&lt;/h3&gt;

&lt;p&gt;k3s supports Tailscale natively, which means you could in theory run a single stretched cluster across two DCs — nodes in both Mongolia and Germany joining the same k3s cluster with a shared etcd.&lt;/p&gt;

&lt;p&gt;The problem is etcd. It's quorum-based — a majority of nodes must agree on every write. With nodes split across two DCs and ~200ms latency between them, every etcd write waits for that round trip. That's not acceptable for a control plane. You'd also need an odd number of etcd nodes for quorum, which means either an unbalanced split between DCs or a third observer node somewhere. The complexity and latency made this a non-starter before even trying it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cilium Cluster Mesh
&lt;/h3&gt;

&lt;p&gt;Cilium has a built-in multi-cluster feature called cluster mesh. It connects multiple clusters at the network level — services become reachable across clusters natively, policies apply across clusters, load balancing works across clusters. It's exactly what I wanted.&lt;/p&gt;

&lt;p&gt;The requirement: cluster nodes must be directly reachable from each other. The Mongolia cluster is behind NAT — its nodes have private IPs, not public ones. Without a way to make those nodes reachable from Germany, cluster mesh won't work. I ruled this out before attempting the setup.&lt;/p&gt;

&lt;p&gt;This is important to understand about the architecture: each cluster internally uses native Cilium networking, no VPN overhead. Pod-to-pod traffic within a cluster is fully native. Netbird only sits between clusters — it's the bridge layer, not the base layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Netbird for Cross-Cluster Connectivity
&lt;/h2&gt;

&lt;p&gt;Netbird is a WireGuard-based overlay network with a routing architecture that handles NAT traversal automatically. The key feature: you can configure routing peers that expose a private network to the Netbird network. Other Netbird peers can then reach that private network through the router, even if the network itself is behind NAT.&lt;/p&gt;

&lt;p&gt;For my setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cluster-mn&lt;/code&gt; (Mongolia, behind NAT) — pod CIDR &lt;code&gt;&amp;lt;MN_POD_CIDR&amp;gt;&lt;/code&gt;, service CIDR &lt;code&gt;&amp;lt;MN_SVC_CIDR&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster-de&lt;/code&gt; (Germany, Hetzner) — pod CIDR &lt;code&gt;&amp;lt;DE_POD_CIDR&amp;gt;&lt;/code&gt;, service CIDR &lt;code&gt;&amp;lt;DE_SVC_CIDR&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pod and service CIDRs must be different between clusters — if both use the same ranges, routing breaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: Netbird Agent on VMs (Rejected)
&lt;/h3&gt;

&lt;p&gt;Install Netbird agent directly on each cluster's nodes, use those nodes as routing peers. Pods wanting to reach the other cluster go through a Cilium Egress Gateway to the VM running the agent.&lt;/p&gt;

&lt;p&gt;This works. I tested it. But it means managing agents on VMs manually, making sure they stay running, handling updates. I didn't want that operational overhead. I'm already over-engineering enough things.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: Netbird Kubernetes Operator (What I Use)
&lt;/h3&gt;

&lt;p&gt;Netbird has a Kubernetes operator that installs agents as pods inside the cluster. No VM management needed.&lt;/p&gt;

&lt;p&gt;The problem: the operator's router pod doesn't use &lt;code&gt;hostNetwork&lt;/code&gt;, so pods inside the cluster can't reach the Netbird network through it. The pod is isolated from the node's network namespace. Possible to use Sidecar, but for the cassandra and others it becomes more complicated.&lt;/p&gt;

&lt;p&gt;The fix: patch the router deployment to enable &lt;code&gt;hostNetwork: true&lt;/code&gt; after it's deployed. The Netbird Helm chart doesn't expose this as a configurable value, so I use a Kubernetes Job that runs post-deploy and patches the deployment:&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;batch/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;Job&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;patch-router-hostnetwork&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;netbird&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/hook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PostSync&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/hook-delete-policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BeforeHookCreation&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;backoffLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&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;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;netbird-patcher&lt;/span&gt;
      &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&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;patch&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitnami/kubectl:latest&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sh&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;echo "Waiting for router deployment..."&lt;/span&gt;
              &lt;span class="s"&gt;kubectl rollout status deployment/router -n netbird --timeout=120s&lt;/span&gt;
              &lt;span class="s"&gt;echo "Patching hostNetwork..."&lt;/span&gt;
              &lt;span class="s"&gt;kubectl patch deployment router -n netbird --type=json -p='[&lt;/span&gt;
                &lt;span class="s"&gt;{"op":"add","path":"/spec/template/spec/hostNetwork","value":true},&lt;/span&gt;
                &lt;span class="s"&gt;{"op":"add","path":"/spec/template/spec/dnsPolicy","value":"ClusterFirstWithHostNet"}&lt;/span&gt;
              &lt;span class="s"&gt;]'&lt;/span&gt;
              &lt;span class="s"&gt;echo "Done."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Job is triggered by ArgoCD as a PostSync hook — it runs automatically after every sync, so the patch is always applied even after redeployments. I'll write a dedicated post on ArgoCD, but this is a good example of why it's useful — this whole setup is just config, no manual steps. &lt;/p&gt;

&lt;p&gt;The router pod also needs to run on a specific node (the one that will act as the routing peer), so I label that node and add node affinity:&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;router&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;netbird-router&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cilium Egress Gateway
&lt;/h3&gt;

&lt;p&gt;With the router pod running with &lt;code&gt;hostNetwork: true&lt;/code&gt; on the designated node, I configure a CiliumEgressGatewayPolicy. Any pod in &lt;code&gt;cluster-de&lt;/code&gt; wanting to reach &lt;code&gt;&amp;lt;MN_POD_CIDR&amp;gt;&lt;/code&gt; (Mongolia's pod network) exits through the router node's &lt;code&gt;wt0&lt;/code&gt; interface (the WireGuard/Netbird interface):&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;cilium.io/v2&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;CiliumEgressGatewayPolicy&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;netbird-egress&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;selectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;destinationCIDRs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;MN_POD_CIDR&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;egressGateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;netbird-router&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;interface&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wt0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; the egress policy only covers the other cluster's pod CIDR, not Netbird IPs (&lt;code&gt;100.115.x.x&lt;/code&gt; range). This is intentional. If you add an egress policy for the Netbird IP range, you create a routing loop — traffic arrives from a Netbird IP, the egress policy kicks in and tries to redirect it back through the router, and it never reaches the pod. By leaving Netbird IPs out of the egress policy, pods can't directly reach the Netbird network (minor downside), but the loop is avoided and cross-cluster traffic works correctly.&lt;/p&gt;

&lt;p&gt;Enable egress gateway in Cilium's Helm values:&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;egressGateway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same setup runs mirror-image on &lt;code&gt;cluster-mn&lt;/code&gt;, with egress policy pointing at &lt;code&gt;&amp;lt;DE_POD_CIDR&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Cluster Service Discovery
&lt;/h2&gt;

&lt;p&gt;Pod-to-pod connectivity works now, but pod IPs change. You can't exactly hardcode them. Kubernetes service IPs don't work across clusters either — Cilium uses virtual routing for service IPs, and a pod in &lt;code&gt;cluster-de&lt;/code&gt; trying to reach a service IP from &lt;code&gt;cluster-mn&lt;/code&gt; doesn't know how to resolve it. The solution is a bit of a DNS relay. We need to trick the pods into asking the &lt;em&gt;other&lt;/em&gt; cluster for the right IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Relay Logic
&lt;/h3&gt;

&lt;p&gt;When a pod in Germany wants to find &lt;code&gt;database.namespace.svc.astring-mn.internal&lt;/code&gt;, the request follows this path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local CoreDNS:&lt;/strong&gt; Realizes it doesn't own &lt;code&gt;.astring-mn.internal&lt;/code&gt; and forwards it to our "DNS Bridge."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS Bridge:&lt;/strong&gt; A CoreDNS pod running on the &lt;code&gt;hostNetwork&lt;/code&gt; of the router node. It listens on port &lt;code&gt;1053&lt;/code&gt; and forwards the request over the Netbird tunnel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netbird Peer:&lt;/strong&gt; The request travels through the WireGuard tunnel to a Netbird peer that can see the Mongolia cluster's internal API/DNS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution:&lt;/strong&gt; The IP comes back, and the egress policy we set up earlier handles the actual data routing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  CoreDNS Custom Config
&lt;/h3&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;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;ConfigMap&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;coredns-custom&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;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;astring-fsn1.server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;astring-fsn1.internal:53 {&lt;/span&gt;
        &lt;span class="s"&gt;rewrite name suffix .svc.astring-fsn1.internal .svc.cluster.local&lt;/span&gt;
        &lt;span class="s"&gt;rewrite name suffix .astring-fsn1.internal .svc.cluster.local&lt;/span&gt;
        &lt;span class="s"&gt;kubernetes cluster.local&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;astring-mn.server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;astring-mn.internal:53 {&lt;/span&gt;
      &lt;span class="s"&gt;errors&lt;/span&gt;
      &lt;span class="s"&gt;cache 30&lt;/span&gt;
      &lt;span class="s"&gt;forward . &amp;lt;ROUTER_NODE_IP&amp;gt;:1053&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Queries for &lt;code&gt;*.astring-mn.internal&lt;/code&gt; get forwarded to port 1053 on the router node. The router node runs a DNS bridge that forwards those queries into the other cluster's network via Netbird.&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS Bridge: The middleman
&lt;/h3&gt;

&lt;p&gt;The DNS bridge is a CoreDNS instance running on the router node with &lt;code&gt;hostNetwork: true&lt;/code&gt;, forwarding queries to a Netbird peer IP:&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;apps/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;Deployment&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;dns-bridge-mn&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;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dns-bridge-mn&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&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;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dns-bridge-mn&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;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;netbird-router&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;hostNetwork&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&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;coredns&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coredns/coredns:1.10.1&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-conf"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/etc/coredns/Corefile"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&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;config-volume&lt;/span&gt;
              &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/coredns&lt;/span&gt;
              &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&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;config-volume&lt;/span&gt;
          &lt;span class="na"&gt;configMap&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;dns-bridge-conf&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;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;ConfigMap&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;dns-bridge-conf&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;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Corefile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;astring-mn.internal:1053 {&lt;/span&gt;
      &lt;span class="s"&gt;errors&lt;/span&gt;
      &lt;span class="s"&gt;cache 30&lt;/span&gt;
      &lt;span class="s"&gt;forward . &amp;lt;NETBIRD_PEER_IP&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;NETBIRD_PEER_IP&amp;gt;&lt;/code&gt; is a fixed Netbird IP — in my case, the management VM's Netbird agent. I chose this because I wanted a stable IP that doesn't change. Using another cluster's routing peer IP would also work. This is not ideal — ideally this would be dynamically resolved — but it works and I'll improve it later. Same applies to &lt;code&gt;&amp;lt;ROUTER_NODE_IP&amp;gt;&lt;/code&gt; in the CoreDNS config above. Future me has a lot of work to do.&lt;/p&gt;

&lt;p&gt;With this in place, a pod in &lt;code&gt;cluster-de&lt;/code&gt; can reach a service in &lt;code&gt;cluster-mn&lt;/code&gt; using &lt;code&gt;service-name.namespace.svc.astring-mn.internal&lt;/code&gt;. CoreDNS forwards the query to the DNS bridge, the bridge forwards it through Netbird to the other cluster's DNS, gets the pod IP back, and the egress policy routes the traffic through the router node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Managing All of This with ArgoCD
&lt;/h2&gt;

&lt;p&gt;If you're doing this manually — applying patches, keeping configs in sync across two clusters, making sure the post-deploy job runs — it becomes a nightmare quickly. ArgoCD manages all of it declaratively. The patch job, the egress policies, the CoreDNS configs, the Netbird operator — all defined as ApplicationSets, applied automatically on sync. I'll cover ArgoCD properly in a separate post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current State
&lt;/h2&gt;

&lt;p&gt;Pod-to-pod connectivity between &lt;code&gt;cluster-mn&lt;/code&gt; and &lt;code&gt;cluster-de&lt;/code&gt; is working. Services are resolvable across clusters using the custom CoreDNS setup. NATS supercluster and cross-DC ScyllaDB replication both run on top of this — that's a separate post covering the active/active chat architecture.&lt;/p&gt;

&lt;p&gt;Known limitations I'll fix later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;ROUTER_NODE_IP&amp;gt;&lt;/code&gt; in CoreDNS is hardcoded — should be dynamic&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;NETBIRD_PEER_IP&amp;gt;&lt;/code&gt; using management VM is not ideal — should use a cluster routing peer directly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Is this over-engineered for a chat app with small number of users? Absolutely.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>multiregion</category>
      <category>networking</category>
    </item>
    <item>
      <title>Securing Kubernetes with Wireguard &amp; others</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Mon, 20 Apr 2026 05:51:15 +0000</pubDate>
      <link>https://dev.to/yepchaos/securing-kubernetes-with-wireguard-others-2ndp</link>
      <guid>https://dev.to/yepchaos/securing-kubernetes-with-wireguard-others-2ndp</guid>
      <description>&lt;p&gt;As the cluster grew, I needed secure access to internal services — PostgreSQL, NATS, Redis, ScyllaDB — without exposing them to the public internet. I went through three solutions: WireGuard, Tailscale, and eventually Netbird. Each one taught me something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Early on I was forwarding TCP ports directly through the ingress controller:&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;tcp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4222"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nats/nats-cluster:4222&lt;/span&gt;
  &lt;span class="s"&gt;"5432"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pgo/astring-ha:5432&lt;/span&gt;
  &lt;span class="s"&gt;"6379"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis/redis:6379&lt;/span&gt;
  &lt;span class="s"&gt;"9042"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scylla/scylla-client:9042&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service has authentication, so it's not completely open. But exposing databases directly to the public internet is bad practice regardless. I wanted internal services reachable only through a private network, with only HTTP endpoints publicly accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  WireGuard
&lt;/h2&gt;

&lt;p&gt;WireGuard is a modern VPN — lightweight, fast, minimal codebase compared to OpenVPN. The core idea: create an encrypted tunnel between your machine and the cluster. Once connected, internal services look like they're on your local network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup on Kubernetes
&lt;/h3&gt;

&lt;p&gt;I deployed WireGuard using Helm:&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="s"&gt;helm repo add wireguard https://bryopsida.github.io/wireguard-chart&lt;/span&gt;
&lt;span class="s"&gt;helm repo update&lt;/span&gt;

&lt;span class="s"&gt;helm install wireguard wireguard/wireguard \&lt;/span&gt;
  &lt;span class="s"&gt;--namespace wireguard \&lt;/span&gt;
  &lt;span class="s"&gt;--create-namespace \&lt;/span&gt;
  &lt;span class="s"&gt;-f values.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;values.yaml&lt;/code&gt;:&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;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIP&lt;/span&gt;

&lt;span class="na"&gt;wireguard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clients&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;AllowedIPs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.34.0.2/32&lt;/span&gt;
      &lt;span class="na"&gt;PublicKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;client_public_key&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expose UDP port 51820 through the ingress:&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;udp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;51820"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wireguard/wireguard-wireguard:51820&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Client Setup
&lt;/h3&gt;

&lt;p&gt;Generate keys on your local machine:&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="s"&gt;wg genkey | tee privatekey | wg pubkey &amp;gt; publickey&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get the server's public key from the Kubernetes secret:&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="s"&gt;kubectl get secret wireguard-wireguard -n wireguard -o yaml | grep publicKey&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client config (&lt;code&gt;wg0.conf&lt;/code&gt;):&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="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Interface&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="s"&gt;PrivateKey = &amp;lt;your_private_key&amp;gt;&lt;/span&gt;
&lt;span class="s"&gt;Address = 172.32.32.2/32&lt;/span&gt;
&lt;span class="s"&gt;DNS = 10.43.0.10, 8.8.8.8&lt;/span&gt;

&lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Peer&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="s"&gt;PublicKey = &amp;lt;server_public_key&amp;gt;&lt;/span&gt;
&lt;span class="s"&gt;AllowedIPs = 10.0.0.0/16, 10.43.0.0/16, 172.32.32.0/24&lt;/span&gt;
&lt;span class="s"&gt;Endpoint = &amp;lt;cluster_public_ip&amp;gt;:51820&lt;/span&gt;
&lt;span class="s"&gt;PersistentKeepalive = &lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring the tunnel up: &lt;code&gt;wg-quick up wg0&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Done — internal services are now accessible through the tunnel, not exposed publicly.&lt;/p&gt;

&lt;p&gt;WireGuard worked. But managing keys manually, updating the Helm values for each new client, and handling the configuration yourself gets old quickly. Also using PodIP was bad, it changes frequently. Then I found Tailscale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tailscale
&lt;/h2&gt;

&lt;p&gt;Tailscale is WireGuard underneath, but with everything you'd otherwise build yourself already done — key management, device registration, DNS, access policies, a UI. You don't touch keys manually. You don't write config files. You just install it and devices appear in your network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes Integration
&lt;/h3&gt;

&lt;p&gt;Tailscale has a Kubernetes operator. Install it:&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="s"&gt;helm repo add tailscale https://pkgs.tailscale.com/helmcharts&lt;/span&gt;
&lt;span class="s"&gt;helm repo update&lt;/span&gt;

&lt;span class="s"&gt;helm upgrade \&lt;/span&gt;
  &lt;span class="s"&gt;--install \&lt;/span&gt;
  &lt;span class="s"&gt;tailscale-operator \&lt;/span&gt;
  &lt;span class="s"&gt;tailscale/tailscale-operator \&lt;/span&gt;
  &lt;span class="s"&gt;--namespace=tailscale \&lt;/span&gt;
  &lt;span class="s"&gt;--create-namespace \&lt;/span&gt;
  &lt;span class="s"&gt;--set-string oauth.clientId=&amp;lt;client_id&amp;gt; \&lt;/span&gt;
  &lt;span class="s"&gt;--set-string oauth.clientSecret=&amp;lt;client_secret&amp;gt; \&lt;/span&gt;
  &lt;span class="s"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then expose a service to your Tailscale network by adding one annotation:&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;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tailscale.com/expose&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The service gets a Tailscale IP and a DNS name automatically. No UDP port forwarding, no ingress config, no manual key exchange.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Made It Better
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;MagicDNS&lt;/strong&gt; — every device and service on your Tailscale network gets a DNS name. Instead of remembering &lt;code&gt;10.43.x.x&lt;/code&gt;, you connect to &lt;code&gt;postgres.tailnet-name.ts.net&lt;/code&gt;. Works automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access controls from the UI&lt;/strong&gt; — you define which devices can reach which services through a policy file in the Tailscale dashboard. No YAML in the cluster, no firewall rules to manage manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sharing&lt;/strong&gt; — you can share specific services with other Tailscale accounts from the UI. Useful for giving someone temporary access without adding them to your cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-platform&lt;/strong&gt; — Tailscale client runs on Linux, macOS, iOS, Android. Once installed, all your devices are on the same private network automatically.&lt;/p&gt;

&lt;p&gt;If you're looking for a simple private networking solution for a Kubernetes cluster — accessing databases locally, connecting team members, securing internal services — Tailscale is the easiest path. I'd recommend it for most use cases.&lt;/p&gt;

&lt;p&gt;I used Tailscale as my primary private network for a while and fully replaced WireGuard with it. But then my requirements changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Netbird: Why I Moved
&lt;/h2&gt;

&lt;p&gt;Two things pushed me toward Netbird:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosting potential.&lt;/strong&gt; Tailscale is a managed service. Your network topology goes through their coordination server. For now that's fine, but I want the option to fully self-host the control plane in the future. Netbird is fully open source — the management server, signal server, everything. I'm currently using Netbird cloud, but the option to move is there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-DC connectivity.&lt;/strong&gt; The bigger reason. I have two clusters — Mongolia (on-premise) and Germany (cloud). I need them to communicate directly: NATS supercluster, Cassandra/ScyllaDB cross-DC replication, other services that need pod-to-pod reachability.&lt;/p&gt;

&lt;p&gt;The k8ssandra-operator is a good example of why this is hard. It needs pods in one DC to directly reach pods in the other DC by their pod IPs. You can't just expose a LoadBalancer service and call it done — the operator needs the actual pod network to be routable between clusters.&lt;/p&gt;

&lt;p&gt;Netbird handles this with its routing architecture. You configure network routes that make each cluster's pod CIDR reachable through Netbird peers. A Netbird peer in each cluster acts as a router for that cluster's network. Traffic between clusters flows through Netbird's encrypted tunnels, but pod IPs on both sides remain directly addressable.&lt;/p&gt;

&lt;p&gt;This also works through NAT — the Mongolia cluster is behind NAT, the Germany cluster is on a public IP. Netbird's hole-punching handles the NAT traversal automatically. No static IPs required on the Mongolia side.&lt;/p&gt;

&lt;p&gt;I tested this setup — pod-to-pod connectivity between Mongolia and Germany works. The full multi-DC architecture (NATS supercluster, cross-DC ScyllaDB replication) is a separate post.&lt;/p&gt;

&lt;h3&gt;
  
  
  Netbird on Kubernetes
&lt;/h3&gt;

&lt;p&gt;Install the Netbird client as a DaemonSet or deployment in each cluster, register with your Netbird account, configure routes for the pod and service CIDRs. Each cluster becomes a peer on the Netbird network with routing configured for its internal networks.&lt;/p&gt;

&lt;p&gt;The management UI lets you define access policies — which peers can reach which networks, which ports are allowed. Similar to Tailscale's access controls but fully open source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WireGuard&lt;/strong&gt; — solid foundation, full control, manual everything. Good if you want to understand what's happening underneath or have specific requirements that managed solutions don't cover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale&lt;/strong&gt; — WireGuard with all the operational work done for you. Best choice for most use cases: local development access, team access, securing internal services. I'd recommend this as the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netbird&lt;/strong&gt; — open source Tailscale alternative with better routing architecture for multi-cluster setups. Right choice when you need pod-to-pod reachability across clusters or want self-hosting as an option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Current state: Netbird for everything. WireGuard and Tailscale are gone.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>wireguard</category>
      <category>tailscale</category>
      <category>netbird</category>
    </item>
    <item>
      <title>Persistent Storage in Kubernetes: From Longhorn to OpenEBS</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Mon, 20 Apr 2026 05:43:37 +0000</pubDate>
      <link>https://dev.to/yepchaos/persistent-storage-in-kubernetes-from-longhorn-to-openebs-5f7e</link>
      <guid>https://dev.to/yepchaos/persistent-storage-in-kubernetes-from-longhorn-to-openebs-5f7e</guid>
      <description>&lt;p&gt;Stateful workloads need storage that outlives pods. In Kubernetes, that means Persistent Volumes (PV) and Persistent Volume Claims (PVC) — a PV is the actual storage, a PVC is a pod's request for it. Kubernetes matches them and handles the binding. The interesting question is what backs those PVs.&lt;/p&gt;

&lt;p&gt;I started with Longhorn, realized it was too heavy for my cluster, benchmarked alternatives, and switched to OpenEBS. Here's the full story with numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Longhorn: Good, But Overkill
&lt;/h2&gt;

&lt;p&gt;Longhorn is easy to install and comes with a solid UI, snapshots, backups, and synchronous replication across nodes. I installed it with Helm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add longhorn https://charts.longhorn.io
helm repo update

helm &lt;span class="nb"&gt;install &lt;/span&gt;longhorn longhorn/longhorn &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; longhorn-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; 1.7.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. But on a 3-node cluster with limited resources, Longhorn consumes around &lt;strong&gt;1.5GB of memory&lt;/strong&gt; just for its own components — Instance Manager, CSI plugins, Longhorn Manager, and the UI.&lt;/p&gt;

&lt;p&gt;The bigger issue: my stateful apps (PostgreSQL, ScyllaDB) already handle their own replication. ScyllaDB replicates across nodes at the application level. PostgreSQL does the same. Adding storage-level replication on top is redundant — double the replication overhead, double the latency, for no benefit.&lt;/p&gt;

&lt;p&gt;I set replicas to 1 to avoid redundant replication:&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;storage.k8s.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;StorageClass&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;longhorn-single-replica&lt;/span&gt;
&lt;span class="na"&gt;provisioner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;driver.longhorn.io&lt;/span&gt;
&lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;numberOfReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;dataLocality&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;best-effort"&lt;/span&gt;
&lt;span class="na"&gt;reclaimPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Delete&lt;/span&gt;
&lt;span class="na"&gt;volumeBindingMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WaitForFirstConsumer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with single replica, the 1.5GB memory overhead remained. For a small cluster where every GB matters, that's hard to justify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarking the Options
&lt;/h2&gt;

&lt;p&gt;Before switching, I ran proper benchmarks using FIO on my actual cluster — 3-node CentOS VMs, the same hardware running everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  FIO Pod
&lt;/h3&gt;

&lt;p&gt;Same pod spec used across all three storage options, just swapping the PVC:&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;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;Pod&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;fio-test&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;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&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;fio&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ljishen/fio&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fio"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--name=pg-test&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--filename=/data/testfile&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--size=200M&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--bs=8k&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--rw=randrw&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--rwmixread=70&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--ioengine=libaio&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--iodepth=16&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--runtime=60&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--numjobs=1&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--time_based&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--group_reporting&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256Mi"&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;512Mi"&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data&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;testvol&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&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;testvol&lt;/span&gt;
      &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;longhorn-pvc&lt;/span&gt;  &lt;span class="c1"&gt;# swap for local-pvc or openebs-pvc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The FIO config simulates a database-like workload — 8k block size, 70/30 read/write mix, random I/O, 16 queue depth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Longhorn Setup
&lt;/h3&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;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;PersistentVolumeClaim&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;longhorn-pvc&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;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;longhorn-single-replica&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Local PV Setup
&lt;/h3&gt;

&lt;p&gt;More manual — create the directory on the node first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/disks/localdisk1
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;777 /mnt/disks/localdisk1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create the PV and PVC manually:&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;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;PersistentVolume&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;local-pv&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;capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local-storage&lt;/span&gt;
  &lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/mnt/disks/localdisk1&lt;/span&gt;
  &lt;span class="na"&gt;nodeAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nodeSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/hostname&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
              &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;k8s3&lt;/span&gt;
  &lt;span class="na"&gt;persistentVolumeReclaimPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Delete&lt;/span&gt;
  &lt;span class="na"&gt;volumeMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Filesystem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When using local PVs, the pod also needs node affinity to land on the right node:&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;affinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nodeAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requiredDuringSchedulingIgnoredDuringExecution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nodeSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubernetes.io/hostname&lt;/span&gt;
              &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;In&lt;/span&gt;
              &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;k8s3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the problem with local PVs at scale — every new volume needs manual directory creation, a manually written PV manifest, and node affinity on every pod that uses it. No dynamic provisioning. Painful to manage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Longhorn&lt;/th&gt;
&lt;th&gt;Local PV&lt;/th&gt;
&lt;th&gt;OpenEBS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read IOPS&lt;/td&gt;
&lt;td&gt;811&lt;/td&gt;
&lt;td&gt;7757&lt;/td&gt;
&lt;td&gt;7401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read Bandwidth&lt;/td&gt;
&lt;td&gt;6.3 MiB/s&lt;/td&gt;
&lt;td&gt;60.6 MiB/s&lt;/td&gt;
&lt;td&gt;57.8 MiB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read Latency (avg)&lt;/td&gt;
&lt;td&gt;14,189 µs&lt;/td&gt;
&lt;td&gt;1,467 µs&lt;/td&gt;
&lt;td&gt;1,539 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write IOPS&lt;/td&gt;
&lt;td&gt;346&lt;/td&gt;
&lt;td&gt;3328&lt;/td&gt;
&lt;td&gt;3177&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write Bandwidth&lt;/td&gt;
&lt;td&gt;2.7 MiB/s&lt;/td&gt;
&lt;td&gt;26.0 MiB/s&lt;/td&gt;
&lt;td&gt;24.8 MiB/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write Latency (avg)&lt;/td&gt;
&lt;td&gt;12,913 µs&lt;/td&gt;
&lt;td&gt;1,377 µs&lt;/td&gt;
&lt;td&gt;1,440 µs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU Usage (sys)&lt;/td&gt;
&lt;td&gt;4.71%&lt;/td&gt;
&lt;td&gt;26.25%&lt;/td&gt;
&lt;td&gt;26.05%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory Overhead&lt;/td&gt;
&lt;td&gt;~1.5 GB&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;~180 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;User-space&lt;/td&gt;
&lt;td&gt;Kernel block device&lt;/td&gt;
&lt;td&gt;Kernel block device&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Longhorn's numbers are significantly worse — 10x higher latency, ~10x lower IOPS. That's the cost of going through a user-space storage layer for every I/O operation. Local PV and OpenEBS both go through the kernel block device directly, which is why they're close to each other.&lt;/p&gt;

&lt;p&gt;Local PV wins on raw performance but loses on everything else — no dynamic provisioning, manual node affinity management, manual directory creation on each node. It doesn't scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenEBS: The Sweet Spot
&lt;/h2&gt;

&lt;p&gt;OpenEBS with hostpath provisioner gives us performance close to local PV with actual automation. It handles provisioning, metrics, and lifecycle. Memory overhead is ~180MB for the whole stack — 8x less than Longhorn.&lt;/p&gt;

&lt;p&gt;k3s has a built-in &lt;code&gt;local-path&lt;/code&gt; provisioner that's similar, but it also requires manually creating directories on each node and gives less control over the storage lifecycle. OpenEBS handles that automatically.&lt;/p&gt;

&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add openebs https://openebs.github.io/openebs
helm repo update

helm &lt;span class="nb"&gt;install &lt;/span&gt;openebs &lt;span class="nt"&gt;--namespace&lt;/span&gt; openebs openebs/openebs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; engines.replicated.mayastor.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-set engines.replicated.mayastor.enabled=false&lt;/code&gt; disables Mayastor, OpenEBS's replicated storage engine. I don't need it — my apps handle their own replication. Disabling it keeps the footprint small.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Create the base directory once on each node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/openebs/local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then PVCs just reference the &lt;code&gt;openebs-hostpath&lt;/code&gt; storage class:&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;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;PersistentVolumeClaim&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;openebs-pvc&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;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openebs-hostpath&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No manual PV creation, no node affinity on pods, no directory management per volume. OpenEBS handles it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current State
&lt;/h2&gt;

&lt;p&gt;Everything stateful on the cluster — PostgreSQL, ScyllaDB, Redis, NATS — uses OpenEBS with &lt;code&gt;openebs-hostpath&lt;/code&gt;. Longhorn is gone.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>longhorn</category>
      <category>openebs</category>
    </item>
    <item>
      <title>Monitoring &amp; Observability</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Mon, 20 Apr 2026 05:38:52 +0000</pubDate>
      <link>https://dev.to/yepchaos/monitoring-observability-3mdl</link>
      <guid>https://dev.to/yepchaos/monitoring-observability-3mdl</guid>
      <description>&lt;p&gt;Metrics tell us something is wrong. Logs tell us why. We need both. This post covers how I set up the full observability stack for ASTRING — Prometheus and Grafana for metrics, Fluent Bit and Loki for logs, and Alertmanager.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics: Prometheus and Grafana
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Prometheus
&lt;/h3&gt;

&lt;p&gt;Prometheus is the standard for Kubernetes monitoring. It scrapes &lt;code&gt;/metrics&lt;/code&gt; endpoints from our services and stores everything as time series data. PromQL lets us query and aggregate across that data. It also handles alerting rules, which I'll get to later.&lt;/p&gt;

&lt;p&gt;The easiest way to get the full stack running on Kubernetes is &lt;code&gt;kube-prometheus-stack&lt;/code&gt; — it bundles Prometheus, Grafana, Alertmanager, and a set of pre-built dashboards and alerting rules for Kubernetes components.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing kube-prometheus-stack
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm &lt;span class="nb"&gt;install &lt;/span&gt;prometheus prometheus-community/kube-prometheus-stack &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; prometheus &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--values&lt;/span&gt; values.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A minimal &lt;code&gt;values.yaml&lt;/code&gt; to get started:&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;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adminUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin&lt;/span&gt;
  &lt;span class="na"&gt;adminPassword&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your_password&lt;/span&gt;
  &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePort&lt;/span&gt;
    &lt;span class="na"&gt;nodePort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once it's running, Grafana comes with pre-built dashboards for cluster health, node resource usage, pod performance, and Kubernetes component metrics. I actively use these — mostly for checking memory and CPU trends across the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs: Why Not ELK
&lt;/h2&gt;

&lt;p&gt;The standard alternative to what I'm using is the ELK stack — Elasticsearch, Logstash, Kibana. It's powerful but heavy. Elasticsearch automatically creates indexes for everything it ingests, which means significant memory and CPU overhead even at low log volumes. On a 3-node cluster with limited resources, running Elasticsearch alongside everything else didn't make sense. It also adds Kibana as a separate UI, which means maintaining two dashboards.&lt;/p&gt;

&lt;p&gt;Loki takes a different approach — it indexes only metadata (labels like pod name, namespace, container), not the full log content. The logs themselves are stored compressed in object storage. This makes it much lighter to run and cheaper to store. Since it's built by Grafana Labs, it integrates directly into Grafana as a data source — same dashboard for metrics and logs.&lt;/p&gt;

&lt;p&gt;Fluent Bit runs as a DaemonSet on every node, tails container log files, and ships them to Loki. It's lightweight by design, built for high-throughput log forwarding without consuming much memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Loki
&lt;/h2&gt;

&lt;p&gt;Loki stores logs in S3-compatible object storage — I use Cloudflare R2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

helm &lt;span class="nb"&gt;install &lt;/span&gt;loki grafana/loki &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; logging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; values.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important parts of &lt;code&gt;values.yaml&lt;/code&gt;:&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;deploymentMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SingleBinary&lt;/span&gt;

&lt;span class="na"&gt;loki&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;commonConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;replication_factor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;ingester&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;chunk_encoding&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;snappy&lt;/span&gt;
  &lt;span class="na"&gt;querier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;max_concurrent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;schemaConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2024-06-01"&lt;/span&gt;
        &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24h&lt;/span&gt;
          &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;loki_index_&lt;/span&gt;
        &lt;span class="na"&gt;object_store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3&lt;/span&gt;
        &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v13&lt;/span&gt;
        &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tsdb&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;bucketNames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;bucket_name&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;chunks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;bucket_name&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;ruler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;bucket_name&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;s3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;access_key&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;secret_key&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;s3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3://&amp;lt;access_key&amp;gt;:&amp;lt;secret_key&amp;gt;@&amp;lt;r2_endpoint&amp;gt;/&amp;lt;bucket_name&amp;gt;&lt;/span&gt;
      &lt;span class="na"&gt;s3ForcePathStyle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
      &lt;span class="na"&gt;insecure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;s3&lt;/span&gt;

&lt;span class="na"&gt;singleBinary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3Gi&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
  &lt;span class="na"&gt;extraEnv&lt;/span&gt;&lt;span class="pi"&gt;:&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;GOMEMLIMIT&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2750MiB&lt;/span&gt;

&lt;span class="na"&gt;minio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SingleBinary&lt;/code&gt; mode runs everything in one pod — suitable for a small cluster. &lt;code&gt;chunk_encoding: snappy&lt;/code&gt; compresses logs before storing them in R2, which reduces storage costs. &lt;code&gt;GOMEMLIMIT&lt;/code&gt; caps Go's memory usage to stay within the pod's memory limit — same issue as GOMAXPROCS but for memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Fluent Bit
&lt;/h2&gt;

&lt;p&gt;Fluent Bit runs as a DaemonSet — one pod per node, tailing all container logs at &lt;code&gt;/var/log/containers/*.log&lt;/code&gt; and forwarding to Loki.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add fluent https://fluent.github.io/helm-charts
helm repo update

helm &lt;span class="nb"&gt;install &lt;/span&gt;fluent-bit fluent/fluent-bit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; logging &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; values.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important parts of &lt;code&gt;values.yaml&lt;/code&gt;:&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;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-e&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/fluent-bit/bin/out_grafana_loki.so&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--workdir=/fluent-bit/etc&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--config=/fluent-bit/etc/conf/fluent-bit.conf&lt;/span&gt;

&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;[INPUT]&lt;/span&gt;
        &lt;span class="s"&gt;Name tail&lt;/span&gt;
        &lt;span class="s"&gt;Tag kube.*&lt;/span&gt;
        &lt;span class="s"&gt;Path /var/log/containers/*.log&lt;/span&gt;
        &lt;span class="s"&gt;multiline.parser docker, cri&lt;/span&gt;
        &lt;span class="s"&gt;Mem_Buf_Limit 5MB&lt;/span&gt;
        &lt;span class="s"&gt;Skip_Long_Lines On&lt;/span&gt;

  &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;[Output]&lt;/span&gt;
        &lt;span class="s"&gt;Name grafana-loki&lt;/span&gt;
        &lt;span class="s"&gt;Match kube.*&lt;/span&gt;
        &lt;span class="s"&gt;Url ${FLUENT_LOKI_URL}&lt;/span&gt;
        &lt;span class="s"&gt;TenantID foo&lt;/span&gt;
        &lt;span class="s"&gt;Labels {job="fluent-bit"}&lt;/span&gt;
        &lt;span class="s"&gt;LabelKeys level,app&lt;/span&gt;
        &lt;span class="s"&gt;BatchWait 1&lt;/span&gt;
        &lt;span class="s"&gt;BatchSize 1001024&lt;/span&gt;
        &lt;span class="s"&gt;LineFormat json&lt;/span&gt;
        &lt;span class="s"&gt;LogLevel info&lt;/span&gt;
        &lt;span class="s"&gt;AutoKubernetesLabels true&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&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;FLUENT_LOKI_URL&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://loki-gateway.logging.svc.cluster.local/loki/api/v1/push&lt;/span&gt;

&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;repository&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/fluent-bit-plugin-loki&lt;/span&gt;
  &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main-e2ed1c0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AutoKubernetesLabels true&lt;/code&gt; automatically attaches Kubernetes metadata (pod name, namespace, container name) as Loki labels — this makes filtering logs in Grafana much more useful. &lt;code&gt;LabelKeys level,app&lt;/code&gt; promotes those specific fields into Loki stream labels, everything else becomes structured metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Loki to Grafana
&lt;/h2&gt;

&lt;p&gt;In Grafana, add Loki as a data source:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Configuration → Data Sources → Add data source&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Loki&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Set URL to &lt;code&gt;http://loki-gateway.logging.svc.cluster.local&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save &amp;amp; Test&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now logs are queryable in the Explore tab using LogQL, and we can build dashboards that combine metrics from Prometheus and logs from Loki in the same view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current State
&lt;/h2&gt;

&lt;p&gt;The full observability stack running on the cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus&lt;/strong&gt; — scraping metrics from all services and Kubernetes components&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grafana&lt;/strong&gt; — dashboards for cluster health, pod performance, and logs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alertmanager&lt;/strong&gt; — firing alerts to Telegram on pod crashes, high memory, and disk usage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loki&lt;/strong&gt; — storing logs in Cloudflare R2&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fluent Bit&lt;/strong&gt; — collecting and forwarding logs from every node&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I actively use Grafana for both metrics and logs. When something goes wrong, Telegram fires first, then I open Grafana to dig into what happened.&lt;/p&gt;

</description>
      <category>monitoring</category>
      <category>observability</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>K3s, MetalLB &amp; Cilium</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Thu, 16 Apr 2026 09:07:08 +0000</pubDate>
      <link>https://dev.to/yepchaos/k3s-metallb-cilium-e9i</link>
      <guid>https://dev.to/yepchaos/k3s-metallb-cilium-e9i</guid>
      <description>&lt;p&gt;This post covers how I set up the on-premise Kubernetes cluster — picking a distribution, getting k3s running on CentOS, solving load balancing with MetalLB, and eventually replacing both MetalLB and the default CNI with Cilium.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking a Distribution
&lt;/h2&gt;

&lt;p&gt;There are several lightweight Kubernetes distributions for on-prem setups like RKE, k0s, MicroK8s, and k3s. For a small cluster, I care mostly about simplicity and footprint. k3s fits that well — it’s a single binary under 100MB, easy to install, and doesn’t bring much overhead. It’s still stable enough for production use and handles multi-node setups without much complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up k3s
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Firewall First
&lt;/h3&gt;

&lt;p&gt;Before installing anything, open the necessary ports on all nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Allow essential services&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ssh
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https

&lt;span class="c"&gt;# Trust pod and service networks&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;trusted &lt;span class="nt"&gt;--add-source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.42.0.0/16  &lt;span class="c"&gt;# Pods CIDR&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;trusted &lt;span class="nt"&gt;--add-source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.43.0.0/16  &lt;span class="c"&gt;# Services CIDR&lt;/span&gt;

&lt;span class="c"&gt;# k3s-specific ports&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--new-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s &lt;span class="nt"&gt;--set-description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"K3s Firewall Rules"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2379-2380/tcp  &lt;span class="c"&gt;# etcd&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6443/tcp       &lt;span class="c"&gt;# API server&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8472/udp       &lt;span class="c"&gt;# Flannel VXLAN&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10250-10252/tcp &lt;span class="c"&gt;# Kubelet&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30000-32767/tcp &lt;span class="c"&gt;# NodePort&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Master Node
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;INSTALL_K3S_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"v1.31.1+k3s1"&lt;/span&gt; sh &lt;span class="nt"&gt;-s&lt;/span&gt; - server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-init&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;traefik
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--cluster-init&lt;/code&gt; initializes a new etcd-backed cluster. I disabled Traefik here because I use NGINX for ingress instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After installation, grab the node token for the worker nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /var/lib/rancher/k3s/server/node-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Worker Nodes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;INSTALL_K3S_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"v1.31.1+k3s1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;K3S_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;cluster_token&amp;gt; sh &lt;span class="nt"&gt;-s&lt;/span&gt; - server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server&lt;/span&gt; https://&amp;lt;master_ip&amp;gt;:6443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;traefik
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note I'm running workers as &lt;code&gt;server&lt;/code&gt; nodes, not &lt;code&gt;agent&lt;/code&gt; nodes. This means all three nodes run the control plane — full HA with etcd across all nodes. If any one node goes down, the cluster keeps running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All nodes should show as Ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  NGINX Ingress
&lt;/h3&gt;

&lt;p&gt;With Traefik disabled, install NGINX for ingress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.1/deploy/static/provider/cloud/deploy.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Load Balancing: MetalLB
&lt;/h2&gt;

&lt;p&gt;On cloud Kubernetes, creating a &lt;code&gt;Service&lt;/code&gt; of type &lt;code&gt;LoadBalancer&lt;/code&gt; automatically provisions a cloud load balancer. On-premise, Kubernetes has no implementation for this by default — services just sit in &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; forever waiting for an external IP that never comes.&lt;/p&gt;

&lt;p&gt;MetalLB solves this. It implements the &lt;code&gt;LoadBalancer&lt;/code&gt; service type for bare-metal clusters using ARP at Layer 2 — when a service gets an IP from the pool, MetalLB announces it on the local network so traffic routes to the right node.&lt;/p&gt;

&lt;h3&gt;
  
  
  Install
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/metallb/metallb/v0.13.10/config/manifests/metallb-native.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configure
&lt;/h3&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;metallb.io/v1beta1&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;IPAddressPool&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;pool&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;metallb-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;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;10.20.30.100-10.20.30.105&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&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;L2Advertisement&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;l2-advertisement&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;metallb-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;ipAddressPools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pool&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; metallb.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The IP range &lt;code&gt;10.20.30.100-10.20.30.105&lt;/code&gt; is the actual range on my network. Make sure the IPs you use aren't in your DHCP server's allocation range or assigned to anything else.&lt;/p&gt;

&lt;p&gt;MetalLB worked well. But then I started reading about Cilium.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replacing MetalLB and Flannel with Cilium
&lt;/h2&gt;

&lt;p&gt;Cilium is a networking, security, and load balancing solution for Kubernetes built on eBPF — a Linux kernel technology that lets run code in kernel space safely, without kernel modules. The main draws for me were the eBPF angle (genuinely interesting technology), the security features, and the observability tooling (Hubble and Tetragon). It can also replace both the CNI (Flannel in k3s's case) and MetalLB, which simplifies the stack.&lt;/p&gt;

&lt;p&gt;To be honest — at my current scale (small cluster, few users), I haven't noticed any measurable performance difference from the switch. I did this to learn and explore, not because I was hitting limits. But the observability alone has been worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reinstall k3s Without Flannel and kube-proxy
&lt;/h3&gt;

&lt;p&gt;To use Cilium as the CNI, k3s needs to be installed without its default networking components. On the master node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;INSTALL_K3S_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"v1.31.1+k3s1"&lt;/span&gt; sh &lt;span class="nt"&gt;-s&lt;/span&gt; - server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-init&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;servicelb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;traefik &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--flannel-backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable-network-policy&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable-kube-proxy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On worker nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;INSTALL_K3S_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"v1.31.1+k3s1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;K3S_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;token&amp;gt; sh &lt;span class="nt"&gt;-s&lt;/span&gt; - server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--server&lt;/span&gt; https://&amp;lt;master_ip&amp;gt;:6443 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;servicelb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;traefik &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--flannel-backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable-network-policy&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable-kube-proxy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--flannel-backend=none&lt;/code&gt; removes the default CNI.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--disable-kube-proxy&lt;/code&gt; removes kube-proxy since Cilium replaces it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--disable=servicelb&lt;/code&gt; removes k3s's built-in service load balancer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Install Cilium — Basic First
&lt;/h3&gt;

&lt;p&gt;I started with a basic Cilium install to make sure networking worked before adding L2 load balancing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add cilium https://helm.cilium.io/
helm repo update

helm &lt;span class="nb"&gt;install &lt;/span&gt;cilium cilium/cilium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; kube-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;kubeProxyReplacement&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;strict &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServiceHost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;127.0.0.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServicePort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6444
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kubeProxyReplacement=strict&lt;/code&gt; tells Cilium to fully replace kube-proxy using eBPF instead of iptables for service routing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Key Considerations for this Setup:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Localhost vs Node IP:&lt;/strong&gt; Using &lt;code&gt;127.0.0.1&lt;/code&gt; for &lt;code&gt;k8sServiceHost&lt;/code&gt; is ideal if Cilium is running as a DaemonSet on a node where K3s is also running (like a single-node or small-scale HA setup), as it hits the K3s supervisor proxy directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How it works:&lt;/strong&gt; When we point Cilium to &lt;code&gt;127.0.0.1:6444&lt;/code&gt;, it talks to the local K3s agent. This agent maintains a dynamic list of all available Master nodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failover:&lt;/strong&gt; If the current Master fails, the local proxy immediately reroutes Cilium’s traffic to a healthy Master.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Benefit:&lt;/strong&gt; Cilium remains connected to &lt;code&gt;localhost&lt;/code&gt;, completely unaware of the backend failure. This ensures &lt;strong&gt;seamless networking uptime&lt;/strong&gt; and removes the need for external infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Add L2 Load Balancing
&lt;/h3&gt;

&lt;p&gt;Once I confirmed everything was working, I upgraded to enable L2 announcements — Cilium's built-in equivalent of MetalLB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade cilium cilium/cilium &lt;span class="nt"&gt;--version&lt;/span&gt; 1.16.3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; kube-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; operator.replicas&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; l2announcements.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; externalIPs.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;kubeProxyReplacement&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;strict &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServiceHost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;127.0.0.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServicePort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6444 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; k8sClientRateLimit.qps&lt;span class="o"&gt;=&lt;/span&gt;50 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; k8sClientRateLimit.burst&lt;span class="o"&gt;=&lt;/span&gt;100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure the announcement policy — which interfaces Cilium uses to announce IPs:&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;cilium.io/v2alpha1&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;CiliumL2AnnouncementPolicy&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;default-l2-announcement-policy&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;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;interfaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ens192&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^eth[0-9]+'&lt;/span&gt;
  &lt;span class="na"&gt;externalIPs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;loadBalancerIPs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the IP pool — same range I had in MetalLB:&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;cilium.io/v2alpha1&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;CiliumLoadBalancerIPPool&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;default-pool&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;blocks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cidr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.20.30.100/29&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; cilium-l2-announcement-policy.yaml
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; cilium-load-balancer-ip-pool.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Enable Hubble
&lt;/h3&gt;

&lt;p&gt;Hubble is Cilium's observability platform — real-time network flow monitoring with a UI. I use it occasionally to debug traffic and see what's happening in the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade cilium cilium/cilium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; kube-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--reuse-values&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; hubble.relay.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; hubble.ui.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Current State
&lt;/h2&gt;

&lt;p&gt;The cluster runs k3s on three CentOS VMs, all as server nodes with etcd for HA. Cilium handles CNI, kube-proxy replacement, and L2 load balancing. NGINX handles ingress. MetalLB is gone.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>cilium</category>
      <category>k3s</category>
      <category>metallb</category>
    </item>
    <item>
      <title>CI/CD, GitLab Pipelines and Kaniko</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Thu, 16 Apr 2026 08:38:36 +0000</pubDate>
      <link>https://dev.to/yepchaos/cicd-gitlab-pipelines-and-kaniko-4oie</link>
      <guid>https://dev.to/yepchaos/cicd-gitlab-pipelines-and-kaniko-4oie</guid>
      <description>&lt;p&gt;CI/CD automates the build and deployment process — push code, pipeline runs, new version deployed on the cluster. Here's how I set it up for ASTRING using GitLab CI/CD, and why I ended up switching from Docker-in-Docker to Kaniko.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Initial Pipeline
&lt;/h2&gt;

&lt;p&gt;The first version used Docker-in-Docker (DinD) — a standard approach where the CI job spins up a Docker daemon inside a container to build the image.&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;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dockerize&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;

&lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;IMAGE_TAG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA&lt;/span&gt;

&lt;span class="na"&gt;dockerize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dockerize&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker:24.0.5&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker:dind&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t $IMAGE_TAG .&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker push $IMAGE_TAG&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker push $CI_REGISTRY_IMAGE:latest&lt;/span&gt;

&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;image&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;bitnami/kubectl:1.31&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mkdir -p ~/.kube&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "$KUBECONFIG_BASE64" | base64 -d &amp;gt; ~/.kube/config&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubectl apply -f deployment/astring/deployment.yaml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubectl apply -f deployment/astring/service.yaml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubectl apply -f deployment/astring/ingress.yaml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubectl rollout restart deployment/astring-backend -n astring&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This worked locally but broke as soon as I moved the GitLab runner onto the on-premise Kubernetes cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with DinD on k3s
&lt;/h2&gt;

&lt;p&gt;My GitLab runner runs as a pod on the same k3s cluster that hosts everything else. k3s uses containerd as its container runtime, not Docker. DinD assumes a Docker daemon is available — it tries to talk to &lt;code&gt;/var/run/docker.sock&lt;/code&gt;, which doesn't exist in a containerd environment. The build stage just failed.&lt;/p&gt;

&lt;p&gt;Beyond the compatibility issue, DinD also requires privileged containers to run the nested Docker daemon, which is a security concern on a shared cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching to Kaniko
&lt;/h2&gt;

&lt;p&gt;Kaniko is a tool that builds container images from a Dockerfile without needing a Docker daemon. It runs entirely in userspace, reads the Dockerfile layer by layer, and pushes the result directly to a registry. No privileged container, no Docker socket, works fine on containerd.&lt;/p&gt;

&lt;p&gt;Here's the updated pipeline:&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;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dockerize&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;

&lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;IMAGE_TAG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA&lt;/span&gt;

&lt;span class="na"&gt;dockerize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dockerize&lt;/span&gt;
  &lt;span class="na"&gt;image&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;gcr.io/kaniko-project/executor:v1.23.2-debug&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/kaniko/executor \&lt;/span&gt;
        &lt;span class="s"&gt;--context "${CI_PROJECT_DIR}" \&lt;/span&gt;
        &lt;span class="s"&gt;--dockerfile "${CI_PROJECT_DIR}/Dockerfile" \&lt;/span&gt;
        &lt;span class="s"&gt;--destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}" \&lt;/span&gt;
        &lt;span class="s"&gt;--destination "${CI_REGISTRY_IMAGE}:latest" \&lt;/span&gt;
        &lt;span class="s"&gt;--cache=true&lt;/span&gt;

&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
  &lt;span class="na"&gt;image&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;bitnami/kubectl:1.31&lt;/span&gt;
    &lt;span class="na"&gt;entrypoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;mkdir -p ~/.kube&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "$KUBECONFIG_BASE64" | base64 -d &amp;gt; ~/.kube/config&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubectl apply -f deployment/astring/deployment.yaml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubectl apply -f deployment/astring/service.yaml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubectl apply -f deployment/astring/ingress.yaml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kubectl rollout restart deployment/astring-backend -n astring&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dockerize&lt;/code&gt; stage now uses the Kaniko executor image directly. No services block, no Docker socket, no privileged mode needed. &lt;code&gt;--cache=true&lt;/code&gt; reuses unchanged layers across builds, which makes subsequent builds significantly faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Deploy Stage Works
&lt;/h2&gt;

&lt;p&gt;The deploy stage uses &lt;code&gt;kubectl&lt;/code&gt; to apply the manifests and trigger a rolling restart. It’s for the only testing purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubeconfig&lt;/strong&gt; is stored as a base64-encoded GitLab CI variable (&lt;code&gt;KUBECONFIG_BASE64&lt;/code&gt;). The pipeline decodes it at runtime and writes it to &lt;code&gt;~/.kube/config&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image tagging&lt;/strong&gt; uses the commit SHA (&lt;code&gt;$CI_COMMIT_SHORT_SHA&lt;/code&gt;) so every build produces a uniquely tagged image. The deployment manifest references the SHA tag, not &lt;code&gt;latest&lt;/code&gt; — this ensures &lt;code&gt;kubectl rollout restart&lt;/code&gt; actually pulls the new image. If we use &lt;code&gt;latest&lt;/code&gt; without &lt;code&gt;imagePullPolicy: Always&lt;/code&gt;, Kubernetes may skip the pull and restart with the cached image, which is not what we want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rolling restart&lt;/strong&gt; means Kubernetes updates pods one at a time — new pod comes up, old pod goes down. Zero downtime deployment without any extra configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets
&lt;/h2&gt;

&lt;p&gt;Config files and keys don't go into the image. They're stored as Kubernetes secrets and mounted into the pod at runtime. The pipeline itself only needs registry credentials and the kubeconfig — both stored as GitLab CI variables, never in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;This pipeline handles the backend. I'll write about the full cluster setup — k3s configuration, networking, ingress, and how everything is organized — in the next post.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>gitlab</category>
    </item>
    <item>
      <title>Front-End &amp; Struggles</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Fri, 10 Apr 2026 13:43:31 +0000</pubDate>
      <link>https://dev.to/yepchaos/front-end-struggles-14f9</link>
      <guid>https://dev.to/yepchaos/front-end-struggles-14f9</guid>
      <description>&lt;p&gt;I didn’t have much frontend experience. This post covers the struggles I ran into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting Point: React + TypeScript + Plain CSS
&lt;/h2&gt;

&lt;p&gt;React with TypeScript felt like the obvious choice — popular, good ecosystem, type safety. I started writing plain CSS modules for styling. Full control, right?&lt;/p&gt;

&lt;p&gt;The problem wasn't the code. It was that I had no design vision. I'd write a component, look at it, and know it looked bad but not know how to fix it. What colors? How much padding? How should this align? I couldn't answer these questions. The feedback loop was: write code → look bad → feel stuck → repeat. This went on for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tailwind CSS Didn't Solve the Real Problem
&lt;/h2&gt;

&lt;p&gt;I switched to Tailwind CSS thinking it would help. It sped things up — utility classes are fast to write, no context switching between files. But Tailwind is a tool for people who already know what they want to build. It doesn't give you design vision, it just makes it faster to execute one. I still didn't know what I wanted.&lt;/p&gt;

&lt;p&gt;I tried Figma. I couldn't make anything that looked good there either. The problem wasn't the tools.&lt;/p&gt;

&lt;p&gt;I restarted the project multiple times during this period — changing UI approach, structure, and direction. It felt like progress but mostly wasn't. This lasted around 3-4 weeks. Eventually I accepted that I wasn't going to figure out design from scratch and looked for a component library.&lt;/p&gt;

&lt;h2&gt;
  
  
  shadcn/ui
&lt;/h2&gt;

&lt;p&gt;I looked at Material UI and Ant Design. Both felt heavy and opinionated in ways that would fight me later. I wanted something that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Looked good out of the box&lt;/li&gt;
&lt;li&gt;Integrated with Tailwind&lt;/li&gt;
&lt;li&gt;I could actually own the code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;shadcn/ui fit all of this. The key difference is that components are generated into your codebase rather than installed as a dependency. This gives full control over behavior and styling, without being constrained by a library’s abstraction. That turned out to matter a lot later when I needed to adapt components for React Native.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js: Tried It, Left It
&lt;/h2&gt;

&lt;p&gt;Around this time I migrated to Next.js. SSR, SSG, the whole thing. After a while I realized most of my components were client-side anyway. ASTRING is a chat app — it's dynamic content fetched after load, not static pages that benefit from SSR. I moved back to React with Vite. Faster dev server, simpler setup, no framework fighting back.&lt;/p&gt;

&lt;h2&gt;
  
  
  State Management: Jotai
&lt;/h2&gt;

&lt;p&gt;Chat state is genuinely complex — active rooms, message lists, unread counts, real-time updates coming in from WebSocket, user presence. Redux felt like too much ceremony for this. Context API caused re-render problems as state got more interconnected.&lt;/p&gt;

&lt;p&gt;Jotai worked well. Atoms are simple to create, updates are granular, and the mental model maps cleanly to "this piece of state, these components that care about it." Chat state in particular became much cleaner — each room's state is an atom, components subscribe only to what they need.&lt;/p&gt;

&lt;h2&gt;
  
  
  React Native: Why I Left Ionic
&lt;/h2&gt;

&lt;p&gt;I built the mobile version with Ionic React first. Code reuse from the web was easy. But Ionic started showing limits for a chat app specifically — animations felt off, native components were lacking, the "native feel" wasn't there. Chat apps have specific UX expectations: smooth scrolling through message history, keyboard handling, swipe gestures, native-feeling transitions. Ionic's web-based approach couldn't deliver these well enough.&lt;/p&gt;

&lt;p&gt;I moved to React Native with Expo. Expo makes the setup significantly easier — no manual native build configuration, good tooling, OTA updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current Setup: Monorepo with pnpm Workspaces
&lt;/h2&gt;

&lt;p&gt;Moving to React Native meant I had two apps — web (React + Vite) and mobile (React Native + Expo). Rather than duplicate code, I set up a monorepo with pnpm workspaces. Nothing fancy.&lt;/p&gt;

&lt;p&gt;Shared packages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Services&lt;/strong&gt; — business logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API clients&lt;/strong&gt; — all backend communication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jotai atoms&lt;/strong&gt; — shared state: rooms, chats, user cache&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Web and mobile each have their own UI layer, but everything underneath is shared. State is defined once — a room list atom, a message cache atom, a user presence atom — and both platforms consume the same atoms. This means a bug fix or API change happens once, and state behavior is consistent across platforms.&lt;/p&gt;

&lt;p&gt;For styling on React Native I use NativeWind — Tailwind for React Native. Same utility classes I use on web, works on mobile. Makes the styling consistent and fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;shadcn/ui on React Native&lt;/strong&gt; — since shadcn components live in my codebase, I adapted them for React Native manually. &lt;code&gt;div&lt;/code&gt; → &lt;code&gt;View&lt;/code&gt;, &lt;code&gt;button&lt;/code&gt; → &lt;code&gt;Pressable&lt;/code&gt;, CSS → NativeWind classes. It's tedious but straightforward, and the result is consistent components across both platforms without two completely separate design systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Stands
&lt;/h2&gt;

&lt;p&gt;The code is messy in places. The UI is functional but not something I'm proud of visually. The monorepo structure is working well. Mobile is better than Ionic was for the specific things chat needs.&lt;/p&gt;

&lt;p&gt;The main thing I learned: frontend is a different skill set. I kept thinking better tools would solve a design problem. They didn't. What helped was accepting I needed pre-built components, adapting them rather than fighting them, and not over-engineering the state management.&lt;/p&gt;

</description>
      <category>react</category>
      <category>reactnative</category>
      <category>ionic</category>
      <category>frontend</category>
    </item>
    <item>
      <title>From Fly.io to On-Premise Kubernetes</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Thu, 09 Apr 2026 13:39:56 +0000</pubDate>
      <link>https://dev.to/yepchaos/from-flyio-to-on-premise-kubernetes-4bj9</link>
      <guid>https://dev.to/yepchaos/from-flyio-to-on-premise-kubernetes-4bj9</guid>
      <description>&lt;p&gt;Everything works in localhost. Exposing it to the internet is a different problem. I went through Fly.io, Linode managed Kubernetes, and eventually landed on an on-premise cluster. Each step had tradeoffs in both cost and operational complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Containers and Kubernetes
&lt;/h2&gt;

&lt;p&gt;Before getting into the details, here is the short explanation.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;container&lt;/strong&gt; is a lightweight, isolated unit that packages an application with its runtime and dependencies. Unlike virtual machines, containers share the host OS kernel, which makes them efficient in terms of startup time and resource usage. Docker is the standard tooling: define a &lt;code&gt;Dockerfile&lt;/code&gt;, build an image, and run it across environments with minimal variation.&lt;/p&gt;

&lt;p&gt;The problem: once we have multiple containers across multiple machines, managing them manually is chaos. Which machine runs what? What happens when a container crashes? How do you roll out updates without downtime?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes&lt;/strong&gt; solves this. It's an orchestration platform — you describe what you want (3 replicas of this service, always keep them running, expose them on this port) and Kubernetes figures out how to make it happen. The key building blocks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pod&lt;/strong&gt; — the smallest unit, one or more containers running together&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment&lt;/strong&gt; — describes how many pods to run and how to update them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service&lt;/strong&gt; — a stable network endpoint that routes traffic to the right pods&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingress&lt;/strong&gt; — routes external HTTP traffic into the cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The big win: if a pod crashes, Kubernetes restarts it. If a node goes down, it reschedules pods elsewhere. You stop thinking about individual machines and start thinking about desired state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: Fly.io + Vercel
&lt;/h2&gt;

&lt;p&gt;For the backend I started with &lt;a href="http://Fly.io" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;. Easy to deploy, cheap during development — I stayed under their $5 threshold. For the frontend, Vercel. Push to GitLab, it deploys automatically. Vercel still handles the frontend today, no complaints. Fly.io manages container just fine.&lt;/p&gt;

&lt;p&gt;Fly.io was fine for stateless services. The problem was stateful ones — ScyllaDB and NATS.&lt;/p&gt;

&lt;p&gt;Running stateful services on Kubernetes properly requires &lt;strong&gt;operators&lt;/strong&gt; — controllers that understand the specific lifecycle of a piece of software. ScyllaDB has its own operator that handles cluster bootstrapping, repairs, scaling, backup, topology changes. NATS has one too. On platforms like this, running Kubernetes operators isn’t possible because you don’t have access to a Kubernetes control plane. As a result, lifecycle management for stateful systems must be handled manually. You're stuck managing stateful services manually. I spent more time managing the platform than building the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: Linode Managed Kubernetes
&lt;/h2&gt;

&lt;p&gt;I needed real Kubernetes. Linode offered $100 credit on signup, which was enough to experiment properly.&lt;/p&gt;

&lt;p&gt;The setup: 3 worker nodes (1 CPU, 2GB RAM, 50GB storage each) plus a load balancer. Linode's managed Kubernetes is free for the control plane — we only pay for nodes and networking.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 nodes × $12/month = $36&lt;/li&gt;
&lt;li&gt;Load balancer = $10/month&lt;/li&gt;
&lt;li&gt;Total: $46/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I used Terraform with Linode's provider to provision the cluster — infrastructure as code, version controlled, easy to redeploy. Once the cluster was up, I could run operators properly. ScyllaDB and NATS behaved the way they were supposed to.&lt;/p&gt;

&lt;p&gt;When the $100 credit ran out, $46/month was hard to justify for a project still in testing. So I started thinking about on-premise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: On-Premise Kubernetes
&lt;/h2&gt;

&lt;p&gt;The same teacher from my IOI days - gave me access to three VMs on his company's infrastructure. Free. Each machine had 8 cores, 8GB RAM, and 50GB storage.&lt;/p&gt;

&lt;p&gt;I set up my own Kubernetes cluster on these using &lt;strong&gt;k3s&lt;/strong&gt; — a lightweight Kubernetes distribution that works well for on-premise and resource-constrained environments. I'll write a dedicated post on the k3s setup, but the short version: it's full Kubernetes without the overhead, and it runs fine on these VMs.&lt;/p&gt;

&lt;p&gt;Full control over the environment. I can run any operator, configure networking however I need, no platform restrictions. The tradeoff is that there's no managed control plane — if something breaks at the infrastructure level, I fix it myself. It’s acceptable also free just some of my time.&lt;/p&gt;

&lt;p&gt;I deployed everything: ScyllaDB cluster, NATS, PostgreSQL, Redis, the backend services. All running on three VMs, costing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Things Stand
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: Vercel, still works great&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend + all services&lt;/strong&gt;: On-premise k3s cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On-premise infrastructure requires more operational effort but provides full control and effectively zero cost when hardware is available. Next I'll write about the actual k3s setup — how I configured the cluster, networking, storage, and got everything running.&lt;/p&gt;

</description>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Object Storage &amp; CDN Journey</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Thu, 09 Apr 2026 13:18:38 +0000</pubDate>
      <link>https://dev.to/yepchaos/object-storage-cdn-journey-27ke</link>
      <guid>https://dev.to/yepchaos/object-storage-cdn-journey-27ke</guid>
      <description>&lt;p&gt;A chat application needs reliable object storage — media uploads, backups, logs. Sounds simple, but there’s lot of choices. I went through six different solutions before landing on something that actually made sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The S3 API
&lt;/h2&gt;

&lt;p&gt;Before getting into the journey, one thing worth explaining: almost every object storage provider today implements the &lt;strong&gt;S3 API&lt;/strong&gt; — the interface originally built by AWS for their Simple Storage Service.&lt;/p&gt;

&lt;p&gt;It's a RESTful interface: buckets as containers, objects accessed by unique keys, HTTP methods for everything. The key thing is it's a standard. Providers like Wasabi, MinIO, Backblaze, Cloudflare R2 — they all speak S3. That means I can swap providers without rewriting application logic, just change the endpoint and credentials. That portability matters a lot when you're still figuring out the right fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Provider Journey
&lt;/h2&gt;

&lt;h3&gt;
  
  
  AWS S3
&lt;/h3&gt;

&lt;p&gt;The obvious starting point. Reliable, feature-rich, integrates with everything. I used it early on and it worked fine — but the pricing model is higher than others. I stopped using it before things got expensive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backblaze B2
&lt;/h3&gt;

&lt;p&gt;Backblaze B2 has egress-free pricing, which sounds great. The problem: it only has American data centers. My servers and users aren't in America, so the latency was noticeable and unacceptable for a real-time chat app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tigris (via Fly.io)
&lt;/h3&gt;

&lt;p&gt;Tigris (&lt;a href="http://fly.io/" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;) provides globally distributed, S3-compatible storage with low latency, addressing the B2 latency limitations. However, its pricing model includes per-request charges in addition to storage. For an API-heavy workload like a chat system, this would scale poorly, so I decided not to go with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  MinIO
&lt;/h3&gt;

&lt;p&gt;I actually deployed MinIO in my cluster. It's open-source, S3-compatible, and simple to run. But running it yourself means managing infrastructure, handling high availability, paying for the compute. For a small project it's overkill — I was spending more time on storage ops than on the actual product.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wasabi
&lt;/h3&gt;

&lt;p&gt;Wasabi has egress-free pricing and good performance. I settled here for a while. But there's a catch: &lt;strong&gt;Wasabi doesn't support public bucket permissions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For private files, that's fine — I generate pre-signed URLs from the backend, the user gets a temporary link, no credentials exposed. But for public files like profile pictures, I had to build a backend service to forward them to users. Extra latency, extra backend load, not ideal.&lt;/p&gt;

&lt;p&gt;I made it work, but then realized a bigger problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Wasabi Pricing Problem
&lt;/h2&gt;

&lt;p&gt;Wasabi charges for a minimum of &lt;strong&gt;1TB&lt;/strong&gt; regardless of how much you actually store. My total data — user uploads, database backups, cluster backups — was under 10GB. I was paying &lt;strong&gt;$8/month&lt;/strong&gt; to store 10GB. That's bad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the Public File Problem First
&lt;/h2&gt;

&lt;p&gt;Before I figured out the pricing issue, I spent time solving the public file latency problem with Cloudflare caching. Worth documenting because it works well if you're stuck on Wasabi or something like this.&lt;/p&gt;

&lt;p&gt;The setup: every public file request goes through my backend at &lt;code&gt;/api/v1/media/file/*&lt;/code&gt;. I set Cloudflare cache rules on that path — mark responses eligible for cache, force an edge TTL of 1 year, bypass backend &lt;code&gt;Cache-Control&lt;/code&gt; headers. Once a file is cached at Cloudflare's edge, it never hits my backend or Wasabi again.&lt;/p&gt;

&lt;p&gt;Here's a real cached response:&lt;/p&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%2Ft4xwpufxzwfm1fpcqr7n.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%2Ft4xwpufxzwfm1fpcqr7n.png" alt=" " width="800" height="1112"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CF-Cache-Status: HIT&lt;/code&gt; — served from Cloudflare's edge, not my backend&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Age: 774&lt;/code&gt; — seconds it's been cached at the edge&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cache-Control: max-age=31536000&lt;/code&gt; — browser caches it for 1 year too&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero extra cluster resources, no Wasabi bandwidth on repeat requests, low latency globally. If you're using Wasabi and hitting this problem, this approach works.&lt;/p&gt;

&lt;p&gt;Because of the fixed fee, i decided to move anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Setup: Cloudflare R2
&lt;/h2&gt;

&lt;p&gt;Cloudflare R2 has a free tier of &lt;strong&gt;10GB&lt;/strong&gt;. My entire dataset fits in that. No egress fees, native CDN built in — so no need for the Cloudflare caching workaround above (though good to know it works). I moved everything to R2 and now pay nothing for storage.&lt;/p&gt;

&lt;p&gt;For backups, I'm keeping Backblaze B2 in mind for when data grows — egress-free and cheap for large volumes, as long as the latency to my users is acceptable for backup use cases (it is).&lt;/p&gt;

&lt;p&gt;Current state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare R2&lt;/strong&gt; — user uploads, all active data, everything under 10GB (free tier)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backblaze B2&lt;/strong&gt; — future home for backups once R2 free tier isn't enough&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The egress-free advantage of Wasabi turned out to be irrelevant at my scale. Under 1TB, you're just paying the minimum anyway. R2's free tier made the decision easy&lt;/p&gt;

</description>
      <category>objectstorage</category>
      <category>cdn</category>
    </item>
    <item>
      <title>Finding Rigth Database</title>
      <dc:creator>yep</dc:creator>
      <pubDate>Wed, 08 Apr 2026 14:04:20 +0000</pubDate>
      <link>https://dev.to/yepchaos/finding-rigth-database-15c1</link>
      <guid>https://dev.to/yepchaos/finding-rigth-database-15c1</guid>
      <description>&lt;p&gt;Most applications need to persist state. In a chat application, that state is massive, constantly growing, and high-frequency. The obvious starting point is a traditional RDBMS — but the specific access patterns of a real-time chat system eventually force a rethink.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with RDBMS for Chat
&lt;/h2&gt;

&lt;p&gt;I could use PostgreSQL for storing messages. It works, until it doesn't.&lt;/p&gt;

&lt;p&gt;Chat is different from most relational data. Messages don't join to other tables. What I actually need is simple: insert a message, fetch messages by room or user. That's it. So the requirements are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It grows fast — millions, then billions of rows&lt;/li&gt;
&lt;li&gt;No joins needed — just "give me all messages for room X"&lt;/li&gt;
&lt;li&gt;Reads and writes need to be fast&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traditional databases like PostgreSQL and MySQL weren't designed with this access pattern as the primary use case. Here's why that matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Partitioning
&lt;/h3&gt;

&lt;p&gt;As the message table grows, we can partition it — split it into smaller physical chunks based on some key, like room ID or time range. The database only scans the relevant partition instead of the whole table. Postgres supports this natively, but it handles it differently from distributed systems — partitions still live on a single machine, so we’re organizing data, not distributing the load.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Write Scaling Problem
&lt;/h3&gt;

&lt;p&gt;The bigger issue is writes. PostgreSQL and MySQL use a single-master model — one node handles all writes, replicas handle reads. Every message sent goes through that one master. At high write volume, that becomes  bottleneck.&lt;/p&gt;

&lt;p&gt;The common solution is sharding: split data across multiple independent database instances, each owning a slice. Hash the room ID to decide which shard it lives on. In theory, clean. In practice, painful — managing shard keys, handling rebalancing when nodes are added, cross-shard queries becoming a nightmare. I decided early on to avoid this entirely by choosing a database built for it natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cassandra and ScyllaDB
&lt;/h2&gt;

&lt;p&gt;This is where wide-column stores like Cassandra — and its C++ reimplementation, ScyllaDB — come in. Same architecture, ScyllaDB just rewrote it in C++ for better performance and lower latency.&lt;/p&gt;

&lt;p&gt;The core idea: instead of one master handling writes, Cassandra/ScyllaDB uses a &lt;strong&gt;ring topology&lt;/strong&gt;. Every node in the cluster owns a range of a hash space. When a message is written, the room ID gets hashed and routed to the node that owns that hash range. No single master, no write bottleneck — every node can accept writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replication&lt;/strong&gt; works naturally on top of this. With a replication factor of 3, a write doesn't just go to the primary node — it also goes to the next 2 nodes on the ring. So there are 3 copies of the data across different nodes. If one goes down, the data is still there. No manual failover, it's built into how the ring works.&lt;/p&gt;

&lt;p&gt;The other key advantage is the &lt;strong&gt;partition key&lt;/strong&gt;. By using room ID as the partition key, Cassandra/ScyllaDB guarantees all messages for that room are stored together on the same node. Pair that with a &lt;strong&gt;clustering key&lt;/strong&gt; on timestamp, and messages within a room are physically stored in time order — fetching history becomes one sequential read, already sorted. No ORDER BY, no extra cost.&lt;/p&gt;

&lt;p&gt;This turns random I/O into sequential I/O. Fetching chat history means finding the right node and reading one continuous stream. That's a hardware-level optimization that a single-master Postgres setup simply can't match at scale.&lt;/p&gt;

&lt;p&gt;The tradeoff: Cassandra/ScyllaDB is bad at full scans and joins, because those require hitting every node. But based on the requirements here, that doesn't matter — joins are never needed.&lt;/p&gt;

&lt;p&gt;This isn't just theory. Discord went through this exact problem — first scaling with Cassandra for billions of messages, then eventually migrating to ScyllaDB for better performance at trillions of messages. Worth reading if you want a production-scale perspective:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://discord.com/blog/how-discord-stores-billions-of-messages" rel="noopener noreferrer"&gt;How Discord Stores Billions of Messages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://discord.com/blog/how-discord-stores-trillions-of-messages" rel="noopener noreferrer"&gt;How Discord Stores Trillions of Messages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll write a dedicated post on Cassandra/ScyllaDB internals — replication strategies, consistency levels, and multi-DC support deserve their own space.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hybrid Architecture
&lt;/h2&gt;

&lt;p&gt;There's no perfect database. Different tools solve different problems, so I use both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; — relational, "small" data: users, friend lists, room metadata. Needs ACID compliance and complex queries, but doesn't grow at a massive rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cassandra/ScyllaDB&lt;/strong&gt; — the heavy data: every message ever sent. High-write throughput, fast sequential reads by room, horizontally scalable without a single write master.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each database does what it's actually good at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;There's more to cover here — consistency models, high availability, failover, and distributed systems fundamentals like Raft. I'll get into those in future posts. For now, this is the architectural reasoning behind the storage layer in ASTRING.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>cassandra</category>
      <category>scylladb</category>
    </item>
  </channel>
</rss>
