<?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: Syed Muhammad Jamal Uddin Gilani</title>
    <description>The latest articles on DEV Community by Syed Muhammad Jamal Uddin Gilani (@jamal-gilani).</description>
    <link>https://dev.to/jamal-gilani</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4102771%2F956191da-3e62-4459-bfc3-5ff1632048d2.jpeg</url>
      <title>DEV Community: Syed Muhammad Jamal Uddin Gilani</title>
      <link>https://dev.to/jamal-gilani</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jamal-gilani"/>
    <language>en</language>
    <item>
      <title>Hub-and-Spoke Kubernetes: Building a Multi-Cluster RKE2 Platform with HAProxy, Keepalived &amp; Centralized Operations</title>
      <dc:creator>Syed Muhammad Jamal Uddin Gilani</dc:creator>
      <pubDate>Wed, 02 Sep 2026 07:05:10 +0000</pubDate>
      <link>https://dev.to/jamal-gilani/hub-and-spoke-kubernetes-building-a-multi-cluster-rke2-platform-with-haproxy-keepalived--5ba9</link>
      <guid>https://dev.to/jamal-gilani/hub-and-spoke-kubernetes-building-a-multi-cluster-rke2-platform-with-haproxy-keepalived--5ba9</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Hub-and-Spoke Model&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of duplicating monitoring stacks, container registries, secret managers, and GitOps controllers in every cluster, we run them once in a central &lt;strong&gt;hub&lt;/strong&gt; cluster and connect all &lt;strong&gt;spoke&lt;/strong&gt; clusters back to it.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdptcso9ftt377yuvdhxo.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdptcso9ftt377yuvdhxo.png" alt="Image description1" width="799" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Hub-and-Spoke?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Without it:&lt;/strong&gt; Every cluster runs its own Prometheus, Grafana, Loki, ArgoCD, Harbor, and Vault. That's 4× the infrastructure, 4× the maintenance, 4× the dashboards to check, and zero correlation between clusters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With it:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Centralized monitoring&lt;/strong&gt; — One Grafana, one set of dashboards. Spoke clusters ship metrics (via remote-write Prometheus) and logs (via Promtail → central Loki) to the hub. You see all clusters in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single container registry&lt;/strong&gt; — Harbor runs in the hub. All spoke clusters pull images from one place. One vulnerability scan covers everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unified secrets management&lt;/strong&gt; — Vault in the hub, with agents or sidecar injectors in each spoke. Rotate a secret once, it propagates everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitOps from one control plane&lt;/strong&gt; — ArgoCD in the hub manages deployments across all spoke clusters. One PR, one review, deploys to the right cluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cluster Inventory
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cluster&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Masters&lt;/th&gt;
&lt;th&gt;Workers&lt;/th&gt;
&lt;th&gt;Shared Services&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hub (Internal Tools)&lt;/td&gt;
&lt;td&gt;Central operations&lt;/td&gt;
&lt;td&gt;3 × (8C/16G/300G)&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Grafana, Prometheus, Loki, ArgoCD, Harbor, Vault&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spoke A&lt;/td&gt;
&lt;td&gt;Application workloads&lt;/td&gt;
&lt;td&gt;3 × (8C/16G/300G)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Promtail, Prometheus (remote-write), Vault agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spoke B&lt;/td&gt;
&lt;td&gt;Application workloads&lt;/td&gt;
&lt;td&gt;3 × (8C/16G/300G)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Same as Spoke A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spoke C&lt;/td&gt;
&lt;td&gt;Application workloads&lt;/td&gt;
&lt;td&gt;3 × (8C/16G/300G)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Same as Spoke A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each cluster also has &lt;strong&gt;2 HAProxy nodes&lt;/strong&gt; (1C/2G/30G each) for load balancing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture of a Single Cluster
&lt;/h2&gt;

&lt;p&gt;Every cluster — hub or spoke — follows the same HA skeleton: a floating VIP in front of two HAProxy nodes, three masters running the control plane, and a data-plane tier of workers. The two diagrams below are the actual topology diagrams I drew while planning this, with every project/cluster name scrubbed out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hub Cluster Layout
&lt;/h3&gt;

&lt;p&gt;The hub only needs to run shared tooling for one "tenant" (itself), so it gets a single worker node. Masters pick up the slack, once the &lt;code&gt;NoSchedule&lt;/code&gt; taint is lifted (more on that later).&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48raua1wmiz12na0ygcz.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48raua1wmiz12na0ygcz.png" alt="Image description2" width="800" height="965"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Spoke Cluster Layout
&lt;/h3&gt;

&lt;p&gt;Spokes carry real application traffic, so they get more workers &lt;strong&gt;and&lt;/strong&gt; a MetalLB address pool for &lt;code&gt;LoadBalancer&lt;/code&gt;-type Services — something the hub doesn't need since its own services are exposed through Ingress. That extra range pushes the LB tier and masters a bit further up the subnet compared to the hub.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xxuv3uzz801vy8tf3hh.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xxuv3uzz801vy8tf3hh.png" alt="Image description3" width="800" height="967"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;There are &lt;strong&gt;3 spoke clusters&lt;/strong&gt; in this platform, and all three are byte-for-byte identical in topology — they only differ in the subnet octet (&lt;code&gt;192.168.X.0/24&lt;/code&gt;) and hostnames. I'm showing one generic template instead of three near-duplicate diagrams:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spoke&lt;/th&gt;
&lt;th&gt;Subnet (anonymized)&lt;/th&gt;
&lt;th&gt;Masters&lt;/th&gt;
&lt;th&gt;Workers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spoke A&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.11.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spoke B&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.12.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spoke C&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.13.0/24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;How HA works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Master failure:&lt;/strong&gt; If one master dies, the other two still have etcd quorum. The API server stays up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancer failure:&lt;/strong&gt; If haproxy-01 dies, Keepalived moves the VIP to haproxy-02 within ~4 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kubectl never breaks:&lt;/strong&gt; It always points at the VIP, which always points at a healthy load balancer, which always routes to a healthy master.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  IP Allocation Scheme
&lt;/h2&gt;

&lt;p&gt;The hub and the spokes use slightly different offsets, because spokes reserve a block for MetalLB and the hub doesn't. Only the subnet octet (&lt;code&gt;x&lt;/code&gt; / &lt;code&gt;X&lt;/code&gt;) changes between clusters of the same type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hub cluster&lt;/strong&gt; (&lt;code&gt;192.168.x.0/24&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1 – .39&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Network infrastructure (reserved)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.40 – .49&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HAProxy load balancers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.50 – .59&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Master nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.60 – .99&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Worker nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Floating VIP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Node&lt;/th&gt;
&lt;th&gt;IP&lt;/th&gt;
&lt;th&gt;Specs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;haproxy-01&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.40&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1C / 2G / 30G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;haproxy-02&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.41&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1C / 2G / 30G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;master-01&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.50&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8C / 16G / 300G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;master-02&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.51&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8C / 16G / 300G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;master-03&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.52&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8C / 16G / 300G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;worker-01&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.60&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;As needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VIP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Floating&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Spoke cluster&lt;/strong&gt; (&lt;code&gt;192.168.X.0/24&lt;/code&gt;, X = per-spoke octet):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Range&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.1 – .50&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Network infrastructure (reserved)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.51 – .59&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MetalLB address pool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.60 – .69&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HAProxy load balancers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.70 – .79&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Master nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.80 – .99&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Worker nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Floating VIP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Node&lt;/th&gt;
&lt;th&gt;IP&lt;/th&gt;
&lt;th&gt;Specs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;haproxy-01&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.60&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1C / 2G / 30G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;haproxy-02&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.61&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1C / 2G / 30G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;master-01&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.70&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8C / 16G / 300G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;master-02&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.71&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8C / 16G / 300G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;master-03&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.72&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8C / 16G / 300G&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;worker-01 … worker-08&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.80&lt;/code&gt; – &lt;code&gt;.87&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;As needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VIP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Floating&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;The hub cluster uses only worker-01 (&lt;code&gt;.60&lt;/code&gt;). Spoke clusters use workers 01–08 (&lt;code&gt;.80&lt;/code&gt;–&lt;code&gt;.87&lt;/code&gt;) plus the MetalLB pool for &lt;code&gt;LoadBalancer&lt;/code&gt; services.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;For each cluster, generate a unique token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Each cluster gets its own token.&lt;/strong&gt; Don't reuse tokens across clusters.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1: Preparing the Nodes
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Run on:&lt;/strong&gt; Every master and worker node (NOT the haproxy nodes).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  1.1 — System Update
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt dist-upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1.2 — Host Resolution
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/hosts &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# RKE2 Cluster Nodes
192.168.x.50   master-01
192.168.x.51   master-02
192.168.x.52   master-03
192.168.x.60   worker-01
# Add remaining workers as needed
192.168.x.40   haproxy-01
192.168.x.41   haproxy-02
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1.3 — Disable Swap
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;swapoff &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/\sswap\s/ s/^/#/'&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kubelet won't start with swap enabled. Kubernetes does its own memory management — swap adds unpredictable latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.4 — Kernel Modules
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/modules-load.d/k8s.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
br_netfilter
overlay
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe overlay
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe br_netfilter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;overlay&lt;/code&gt; is required by containerd. &lt;code&gt;br_netfilter&lt;/code&gt; makes bridged traffic visible to iptables for pod networking.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.5 — Network Parameters
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-rke2.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;ip_forward&lt;/code&gt;, packets between pods on different nodes stop at the node boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.6 — Disable UFW
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop ufw
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl disable ufw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1.7 — Required Packages
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; curl wget &lt;span class="nb"&gt;tar &lt;/span&gt;nfs-common open-iscsi lsscsi sg3-utils
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nfs-common&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Mount NFS persistent volumes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;open-iscsi&lt;/code&gt;, &lt;code&gt;lsscsi&lt;/code&gt;, &lt;code&gt;sg3-utils&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Block storage (Longhorn)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  1.8 — Enable iSCSI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;iscsid
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start iscsid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1.9 — Reboot
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify after reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsmod | &lt;span class="nb"&gt;grep &lt;/span&gt;br_netfilter        &lt;span class="c"&gt;# ✓ loaded&lt;/span&gt;
sysctl net.ipv4.ip_forward       &lt;span class="c"&gt;# ✓ = 1&lt;/span&gt;
free &lt;span class="nt"&gt;-h&lt;/span&gt;                          &lt;span class="c"&gt;# ✓ swap = 0&lt;/span&gt;
systemctl is-active iscsid       &lt;span class="c"&gt;# ✓ active&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;✅ Complete on ALL master + worker nodes before Phase 2.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 2: HAProxy &amp;amp; Keepalived
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Run on:&lt;/strong&gt; haproxy-01 (.40) first, then haproxy-02 (.41).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2.1 — Install
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; haproxy keepalived curl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.2 — Sysctl Tuning
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-lb-tuning.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
net.ipv4.ip_nonlocal_bind = 1
net.core.somaxconn = 65535
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ip_nonlocal_bind&lt;/code&gt; lets HAProxy bind to the VIP before Keepalived assigns it. Without this, HAProxy crashes on startup when the VIP is on the other node.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.3 — File Descriptor Limits
&lt;/h3&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; /etc/systemd/system/haproxy.service.d

&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/system/haproxy.service.d/limits.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Service]
LimitNOFILE=100000
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.4 — HAProxy Configuration
&lt;/h3&gt;

&lt;p&gt;Find your interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-4&lt;/span&gt; addr show | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"192.168.x.40"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the config:&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 tee&lt;/span&gt; /etc/haproxy/haproxy.cfg &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
global
    log /dev/log local0
    log /dev/log local1 notice
    chroot /var/lib/haproxy
    user haproxy
    group haproxy
    daemon
    maxconn 30000

defaults
    log     global
    mode    tcp
    option  tcplog
    option  dontlognull
    option  tcpka
    timeout connect 5s
    timeout client  60s
    timeout server  60s
    retries 3

listen stats
    bind 127.0.0.1:8404
    mode http
    stats enable
    stats uri /stats
    stats refresh 10s

frontend rke2_registration_9345
    bind 192.168.x.100:9345
    maxconn 2500
    default_backend rke2_servers_9345

backend rke2_servers_9345
    balance roundrobin
    option tcp-check
    default-server inter 2s fall 2 rise 3 slowstart 10s maxconn 1000
    server master1 192.168.x.50:9345 check
    server master2 192.168.x.51:9345 check
    server master3 192.168.x.52:9345 check

frontend kubernetes_api_6443
    bind 192.168.x.100:6443
    maxconn 26000
    default_backend kube_apiservers_6443

backend kube_apiservers_6443
    balance roundrobin
    option tcp-check
    default-server inter 2s fall 2 rise 3 slowstart 15s maxconn 10000
    server master1 192.168.x.50:6443 check
    server master2 192.168.x.51:6443 check
    server master3 192.168.x.52:6443 check
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two frontends bind to the VIP: port &lt;strong&gt;9345&lt;/strong&gt; (node registration) and port &lt;strong&gt;6443&lt;/strong&gt; (Kubernetes API). Both round-robin across the 3 masters with health checks every 2 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.5 — Start HAProxy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;haproxy
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start haproxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.6 — Health Check Script
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/keepalived/check_haproxy.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;SCRIPT&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
systemctl is-active --quiet haproxy || exit 1
curl -sf http://127.0.0.1:8404/stats &amp;gt;/dev/null || exit 1
exit 0
&lt;/span&gt;&lt;span class="no"&gt;SCRIPT

&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /etc/keepalived/check_haproxy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keepalived runs this every 2 seconds. Non-zero exit = HAProxy is dead = VIP moves to backup.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.7 — Keepalived Configuration
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;On haproxy-01 (PRIMARY):&lt;/strong&gt;&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="nv"&gt;INTERFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eth0"&lt;/span&gt;   &lt;span class="c"&gt;# Replace with your actual interface&lt;/span&gt;

&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/keepalived/keepalived.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
global_defs {
    router_id HAPROXY_01
    enable_script_security
    script_user root
}

vrrp_script chk_haproxy {
    script "/etc/keepalived/check_haproxy.sh"
    interval 2
    fall 2
    rise 2
    timeout 2
}

vrrp_instance VI_RKE2 {
    state MASTER
    interface &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INTERFACE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
    virtual_router_id 51
    priority 120
    advert_int 1

    unicast_src_ip 192.168.x.40
    unicast_peer {
        192.168.x.41
    }

    authentication {
        auth_type PASS
        auth_pass &amp;lt;your-vrrp-password&amp;gt;
    }

    virtual_ipaddress {
        192.168.x.100/24
    }

    track_script {
        chk_haproxy
    }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How VRRP failover works:&lt;/strong&gt; Both nodes send heartbeats. The higher-priority node (120) holds the VIP. If the primary stops advertising — crash, network failure, failed health check — the backup (priority 110) takes ownership of the VIP by adding it to its own interface. Recovery is automatic when the primary comes back.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Multi-cluster note:&lt;/strong&gt; Each cluster needs a unique &lt;code&gt;virtual_router_id&lt;/code&gt;. If all clusters share the same L2 network, use 51 for the hub, 52 for Spoke A, 53 for Spoke B, 54 for Spoke C.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  2.8 — Start Keepalived
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;keepalived
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start keepalived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.9 — Repeat on haproxy-02
&lt;/h3&gt;

&lt;p&gt;Same steps, but in the Keepalived config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;router_id&lt;/span&gt; &lt;span class="n"&gt;HAPROXY_02&lt;/span&gt;
&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;BACKUP&lt;/span&gt;
&lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;110&lt;/span&gt;
&lt;span class="n"&gt;unicast_src_ip&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="n"&gt;x&lt;/span&gt;.&lt;span class="m"&gt;41&lt;/span&gt;
&lt;span class="n"&gt;unicast_peer&lt;/span&gt; {
    &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="n"&gt;x&lt;/span&gt;.&lt;span class="m"&gt;40&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.10 — Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl is-active haproxy
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl is-active keepalived
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:8404/stats | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
ip addr show | &lt;span class="nb"&gt;grep &lt;/span&gt;192.168.x.100   &lt;span class="c"&gt;# VIP visible on primary&lt;/span&gt;
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 2 192.168.x.100             &lt;span class="c"&gt;# Reachable from the network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;✅ Load balancer tier ready.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 3: Bootstrapping RKE2
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Strictly sequential.&lt;/strong&gt; master-01 must be fully running before touching master-02.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3.1 — First Master (.50)
&lt;/h3&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; /etc/rancher/rke2

curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.rke2.io | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;INSTALL_RKE2_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"server"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;INSTALL_RKE2_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"v1.35.2+rke2r1"&lt;/span&gt; sh -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  First Master Config
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/rancher/rke2/config.yaml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
token: "&amp;lt;your-cluster-token&amp;gt;"

tls-san:
  - "192.168.x.50"
  - "192.168.x.51"
  - "192.168.x.52"
  - "192.168.x.100"
cni: cilium
disable-cloud-controller: true

disable: rke2-ingress-nginx

etcd-expose-metrics: true
etcd-snapshot-schedule-cron: "0 2 * * *"
etcd-snapshot-retention: 7
etcd-snapshot-compress: true
etcd-s3: true
etcd-s3-config-secret: rke2-etcd-snapshot-s3-config

etcd-arg:
  - "heartbeat-interval=200"
  - "election-timeout=2000"

kube-apiserver-arg:
  - "audit-log-maxage=30"
  - "audit-log-maxbackup=10"
  - "audit-log-maxsize=100"
  - "audit-log-mode=blocking"
  - "request-timeout=120s"
  - "default-not-ready-toleration-seconds=120"
  - "default-unreachable-toleration-seconds=120"
  - "event-ttl=2h"

kube-controller-manager-arg:
  - "terminated-pod-gc-threshold=200"
  - "bind-address=0.0.0.0"

kube-scheduler-arg:
  - "bind-address=0.0.0.0"

kube-proxy-arg:
  - "metrics-bind-address=0.0.0.0"

node-taint:
  - "node-role.kubernetes.io/control-plane:NoSchedule"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key settings explained:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared secret for cluster join authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tls-san&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All master IPs + VIP in the API server's TLS cert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;disable: rke2-ingress-nginx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Install your own ingress controller instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;etcd-snapshot-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Automated daily backups, compressed, keep 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node-taint: NoSchedule&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep workloads off masters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bind-address=0.0.0.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expose metrics for Prometheus scraping&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CNI is left as default (Canal).&lt;/strong&gt; Add &lt;code&gt;cni: calico&lt;/code&gt; or &lt;code&gt;cni: cilium&lt;/code&gt; based on your needs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Start and Wait
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;rke2-server.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start rke2-server.service

&lt;span class="c"&gt;# Watch logs (takes 2-5 min)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; rke2-server &lt;span class="nt"&gt;-f&lt;/span&gt;

&lt;span class="c"&gt;# Wait for registration port&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;':9345'&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;"Waiting for port 9345..."&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;10
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Ready for additional masters"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Verify and Set Up kubectl
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; /var/lib/rancher/rke2/bin/kubectl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kubeconfig&lt;/span&gt; /etc/rancher/rke2/rke2.yaml get nodes

&lt;span class="c"&gt;# Set up shortcuts&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
export PATH=&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="sh"&gt;:/var/lib/rancher/rke2/bin
source &amp;lt;(kubectl completion bash)
alias k=kubectl
complete -F __start_kubectl k
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.kube
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/rancher/rke2/rke2.yaml ~/.kube/config
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;:&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; ~/.kube/config
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.kube/config
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3.2 — Additional Masters (.51, .52)
&lt;/h3&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; /etc/rancher/rke2

curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.rke2.io | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;INSTALL_RKE2_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"server"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;INSTALL_RKE2_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"v1.35.2+rke2r1"&lt;/span&gt; sh -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The config is identical to the first master &lt;strong&gt;plus one line at the top:&lt;/strong&gt;&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 tee&lt;/span&gt; /etc/rancher/rke2/config.yaml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
server: "https://192.168.x.100:9345"
token: "&amp;lt;your-cluster-token&amp;gt;"

tls-san:
  - "192.168.x.50"
  - "192.168.x.51"
  - "192.168.x.52"
  - "192.168.x.100"

# ... rest identical to first master config ...

node-taint:
  - "node-role.kubernetes.io/control-plane:NoSchedule"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;server:&lt;/code&gt; line is the only difference — it tells this node to &lt;strong&gt;join&lt;/strong&gt; the existing cluster through the VIP instead of bootstrapping a new one.&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 &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;rke2-server.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start rke2-server.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; rke2-server &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for &lt;code&gt;Ready&lt;/code&gt;, then repeat for master-03. After all three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME        STATUS   ROLES                       AGE
master-01   Ready    control-plane,etcd,master   10m
master-02   Ready    control-plane,etcd,master   5m
master-03   Ready    control-plane,etcd,master   2m
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3.3 — Worker Nodes
&lt;/h3&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; /etc/rancher/rke2

curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.rke2.io | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;INSTALL_RKE2_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"agent"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;INSTALL_RKE2_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"v1.35.2+rke2r1"&lt;/span&gt; sh -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;strong&gt;&lt;code&gt;agent&lt;/code&gt;&lt;/strong&gt; not &lt;code&gt;server&lt;/code&gt;.&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 tee&lt;/span&gt; /etc/rancher/rke2/config.yaml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
server: "https://192.168.x.100:9345"
token: "&amp;lt;your-cluster-token&amp;gt;"

disable:
  - rke2-ingress-nginx

kube-proxy-arg:
  - "metrics-bind-address=0.0.0.0"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;rke2-agent.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start rke2-agent.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat for all workers. Spoke clusters get 8 workers (.60–.67), the hub gets 1 worker (.60).&lt;/p&gt;




&lt;h2&gt;
  
  
  Connecting the Spokes to the Hub
&lt;/h2&gt;

&lt;p&gt;Once all clusters are running, the hub-and-spoke connectivity looks like this:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9b69tq9ycupo5wj0phvg.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9b69tq9ycupo5wj0phvg.png" alt="Image description4" width="800" height="535"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How Each Service Connects
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Prometheus (Metrics):&lt;/strong&gt;&lt;br&gt;
Each spoke cluster runs its own Prometheus instance, but instead of storing metrics locally, it uses &lt;code&gt;remote_write&lt;/code&gt; to push them to the hub's central Prometheus. The hub's Grafana queries the central Prometheus for dashboards across all clusters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loki (Logs):&lt;/strong&gt;&lt;br&gt;
Each spoke cluster runs Promtail as a DaemonSet. Promtail ships container logs to the hub's Loki endpoint. In Grafana, you can filter logs by &lt;code&gt;cluster&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt;, and &lt;code&gt;pod&lt;/code&gt; — all from one place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ArgoCD (GitOps):&lt;/strong&gt;&lt;br&gt;
ArgoCD runs in the hub and registers each spoke cluster as a target. Application manifests live in Git. A PR merge triggers ArgoCD to sync the changes to the correct spoke cluster. One ArgoCD instance, one Git source of truth, deploys everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Harbor (Container Registry):&lt;/strong&gt;&lt;br&gt;
Harbor runs in the hub cluster. All spoke clusters have their containerd configured to pull images from the hub's Harbor instance. One scan, one set of vulnerability reports, one place to manage image lifecycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vault (Secrets):&lt;/strong&gt;&lt;br&gt;
HashiCorp Vault runs in the hub. Spoke clusters run the Vault Agent Injector, which authenticates using Kubernetes service accounts and injects secrets into pods at runtime. Rotate a database password in Vault once — all clusters get it.&lt;/p&gt;


&lt;h2&gt;
  
  
  Hub Cluster: Special Considerations
&lt;/h2&gt;

&lt;p&gt;The hub cluster has only 1 worker node, but it runs critical shared services. A few things to consider:&lt;/p&gt;
&lt;h3&gt;
  
  
  Removing the NoSchedule Taint
&lt;/h3&gt;

&lt;p&gt;With only 1 worker, you might need the masters to share the workload:&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 pods on masters (only for the hub cluster)&lt;/span&gt;
kubectl taint nodes master-01 &lt;span class="se"&gt;\&lt;/span&gt;
  node-role.kubernetes.io/control-plane:NoSchedule-
kubectl taint nodes master-02 &lt;span class="se"&gt;\&lt;/span&gt;
  node-role.kubernetes.io/control-plane:NoSchedule-
kubectl taint nodes master-03 &lt;span class="se"&gt;\&lt;/span&gt;
  node-role.kubernetes.io/control-plane:NoSchedule-
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 3 masters have 24 CPUs and 48 GB RAM combined — plenty for Grafana, ArgoCD, Harbor, and Vault alongside the control plane.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage Planning
&lt;/h3&gt;

&lt;p&gt;Hub services are storage-heavy:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Storage Needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prometheus&lt;/td&gt;
&lt;td&gt;Time-series data from all clusters. Plan 50–100 GB+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loki&lt;/td&gt;
&lt;td&gt;Log chunks from all clusters. Plan 100 GB+ (use object storage)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Harbor&lt;/td&gt;
&lt;td&gt;Container images. Plan 200 GB+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vault&lt;/td&gt;
&lt;td&gt;Minimal (secrets are small), but HA storage backend needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is why the masters have 300 GB each. Consider using Longhorn or a dedicated NFS server for persistent volumes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Execution Cheat Sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FOR EACH CLUSTER (hub first, then spokes):

PHASE 1 — All master + worker nodes (parallel):
  ├── apt update &amp;amp; upgrade
  ├── /etc/hosts
  ├── disable swap
  ├── kernel modules (overlay, br_netfilter)
  ├── sysctl (iptables bridge, ip_forward)
  ├── disable UFW
  ├── install packages
  ├── enable iscsid
  └── reboot

PHASE 2 — HAProxy nodes (primary first):
  ├── install haproxy + keepalived
  ├── sysctl (nonlocal_bind, somaxconn)
  ├── systemd NOFILE limit
  ├── haproxy.cfg
  ├── start haproxy
  ├── check_haproxy.sh
  ├── keepalived.conf
  ├── start keepalived
  └── verify VIP

PHASE 3 — RKE2 (sequential):
  ├── master-01: first-master config, start, wait for :9345
  ├── master-02: additional-master config (server: line), start
  ├── master-03: same
  └── workers: agent install, start

POST-CLUSTER:
  Hub:  Install Grafana, Prometheus, Loki, ArgoCD, Harbor, Vault
  Spokes: Install Promtail, Prometheus (remote-write), Vault agent
          Register as ArgoCD target
          Configure containerd to pull from Hub's Harbor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Master won't start:&lt;/strong&gt;&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 &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; rke2-server &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Node stuck in NotReady:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe node &amp;lt;node-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Connection refused on port 9345:&lt;/strong&gt;&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 &lt;/span&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;9345
curl &lt;span class="nt"&gt;-k&lt;/span&gt; https://192.168.x.100:9345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;VIP not failing over:&lt;/strong&gt;&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 &lt;/span&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; keepalived &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;tcpdump &lt;span class="nt"&gt;-i&lt;/span&gt; eth0 vrrp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Spoke metrics not appearing in Hub Grafana:&lt;/strong&gt;&lt;br&gt;
Check the spoke's Prometheus remote-write config — the endpoint should point to the hub's Prometheus push endpoint, and network policies should allow cross-cluster traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spoke logs missing in Hub Loki:&lt;/strong&gt;&lt;br&gt;
Check Promtail's config on the spoke — &lt;code&gt;clients.url&lt;/code&gt; should point to &lt;code&gt;http://&amp;lt;hub-loki-gateway&amp;gt;:3100/loki/api/v1/push&lt;/code&gt;. Verify the hub's Loki ingester isn't rejecting streams (check for "rate limit" or "stream limit" errors).&lt;/p&gt;




&lt;h2&gt;
  
  
  Ports Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Protocol&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;6443&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;Kubernetes API server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9345&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;RKE2 node registration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2379–2380&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;etcd client/peer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8472&lt;/td&gt;
&lt;td&gt;UDP&lt;/td&gt;
&lt;td&gt;VXLAN overlay (Canal/Flannel)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10250&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;kubelet API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8404&lt;/td&gt;
&lt;td&gt;TCP&lt;/td&gt;
&lt;td&gt;HAProxy stats (localhost)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;112&lt;/td&gt;
&lt;td&gt;VRRP&lt;/td&gt;
&lt;td&gt;Keepalived heartbeat&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Building clusters manually takes 10× longer than running Ansible. But now when something breaks at 2 AM — and in a hub-and-spoke setup, the hub going down means &lt;em&gt;all clusters&lt;/em&gt; lose their operational visibility — I know exactly which component failed and why.&lt;/p&gt;

&lt;p&gt;The hub-and-spoke model is worth the added complexity. One Grafana to check instead of four. One ArgoCD to manage instead of four. One Harbor to scan instead of four. The operational overhead drops dramatically once the initial setup is done.&lt;/p&gt;

&lt;p&gt;If you're managing more than two clusters, centralize your tooling. Your future self — the one debugging a production incident across three clusters at midnight — will thank you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on building a hub-and-spoke platform with 4 production RKE2 clusters. All project names, secrets, credentials, and identifying details have been anonymized.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Questions? Hit the comments — I've probably run into the same issue.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>docker</category>
      <category>sre</category>
    </item>
  </channel>
</rss>
