<?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>Running a Live Media Monitoring Platform on Kubernetes</title>
      <dc:creator>Syed Muhammad Jamal Uddin Gilani</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:11:39 +0000</pubDate>
      <link>https://dev.to/jamal-gilani/running-a-live-media-monitoring-platform-on-kubernetes-3en6</link>
      <guid>https://dev.to/jamal-gilani/running-a-live-media-monitoring-platform-on-kubernetes-3en6</guid>
      <description>&lt;p&gt;Media monitoring sounds simple from the outside: watch TV channels, pull out mentions, show sentiment on a dashboard. Underneath, it's a real-time media pipeline — live UDP video feeds coming in continuously, AI models transcribing and analyzing them, and a production system that isn't allowed to just "restart and try again" when something breaks, because the broadcast doesn't pause for you.&lt;/p&gt;

&lt;p&gt;That's the infrastructure challenge behind &lt;a href="https://betacodespk.com/electronic-media-monitoring/" rel="noopener noreferrer"&gt;BetaMedia&lt;/a&gt;, Beta Codes' electronic media monitoring platform, and it's the project where I moved from managing container networking to owning a full Kubernetes-based microservices deployment end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What BetaMedia Does
&lt;/h2&gt;

&lt;p&gt;BetaMedia is built to watch media so people don't have to — pulling in live TV broadcasts and digital media, then running them through AI to make them searchable and analyzable. On the product side that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live TV streaming and playback&lt;/strong&gt;, with clipping and archiving of past broadcasts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentiment and emotion analysis&lt;/strong&gt; across mentions, in real time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speech-to-text&lt;/strong&gt; for turning broadcast audio into searchable transcripts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR, ticker extraction, and "flasher" detection&lt;/strong&gt; for on-screen text, scrolling news tickers, and breaking-news banners&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facial recognition&lt;/strong&gt; for tracking public figures across clips&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customizable dashboards&lt;/strong&gt; built around KPIs like sentiment shifts and keyword frequency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's used by broadcasters, government and policy units, regulators, and security teams who need to know what's being said about a brand, a policy, or an event, as it's being said — not the next morning.&lt;/p&gt;

&lt;p&gt;None of that AI output means anything if the pipeline feeding it drops frames, falls behind, or goes down. That's the part I own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Challenge: Continuous Streams, Zero Room for Downtime
&lt;/h2&gt;

&lt;p&gt;Media monitoring is unlike a typical web app in one important way: &lt;strong&gt;the input never stops.&lt;/strong&gt; A broadcast doesn't wait for your deployment window. That shapes almost every infrastructure decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UDP video streams have to be captured and handed off to processing continuously, not batched&lt;/li&gt;
&lt;li&gt;Deployments and updates need to happen without interrupting streams already in flight&lt;/li&gt;
&lt;li&gt;A single-service app isn't enough — ingestion, transcoding, and each AI module need to scale and fail independently&lt;/li&gt;
&lt;li&gt;Storage has to keep up with continuously growing video, transcripts, and clips&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Kubernetes as the Backbone
&lt;/h2&gt;

&lt;p&gt;BetaMedia runs as a set of microservices on Kubernetes — ingestion, transcoding, and each AI module (speech-to-text, OCR, facial recognition, sentiment analysis) deployed and scaled independently rather than bundled into one monolith. That separation matters here specifically because these services have very different resource profiles: transcoding is CPU/GPU-heavy and constant, while something like ticker extraction only needs to run against new segments as they arrive.&lt;/p&gt;

&lt;p&gt;Managing the cluster means keeping that mix of workloads healthy together — right-sizing resources per service, keeping pods scheduled sensibly across nodes, and making sure a spike in one AI module doesn't starve the ingestion pipeline feeding everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving UDP Streams Into the Processing Pipeline
&lt;/h2&gt;

&lt;p&gt;Live TV feeds come in as UDP streams, which then have to be transcoded (FFmpeg) and routed into the application layer for AI processing. Getting this right in Kubernetes means building the networking and workflow so a stream goes from "raw broadcast feed" to "frames the AI services can consume" reliably and continuously — with the pipeline treated as a first-class, always-on system rather than a batch job that happens to run often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying to Production Without Breaking the Stream
&lt;/h2&gt;

&lt;p&gt;"Streamline the deployment process without failure" is a simple sentence that hides a lot of constraints once you're running live infrastructure. It meant building workflows where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Releases roll out predictably, with a clear path to roll back if something's wrong&lt;/li&gt;
&lt;li&gt;Deployments don't interrupt streams that are actively being ingested or processed&lt;/li&gt;
&lt;li&gt;The path from a code change to a running production service is a defined workflow, not a manual sequence of steps someone has to remember correctly every time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Working closely with the production side of this — rather than handing off a build and hoping — is what turned deployments from a risk event into a routine one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring: Watching the Watchers
&lt;/h2&gt;

&lt;p&gt;A media monitoring platform that silently stops ingesting a channel is its own kind of failure — the dashboards look calm, but the data behind them just stopped. So monitoring here has to answer more than "is the pod up":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are streams actively being ingested, per channel/source?&lt;/li&gt;
&lt;li&gt;Are the AI processing microservices keeping up, or falling behind?&lt;/li&gt;
&lt;li&gt;Is storage growth on track, or about to become a problem?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dashboards built around these questions turn silent pipeline stalls into visible, actionable alerts instead of a client noticing a gap in coverage first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data: Databases, S3, and a Lot of Microservices
&lt;/h2&gt;

&lt;p&gt;Behind the AI modules sits the less visible work: managing the databases behind transcripts, mentions, and metadata, and the S3-compatible object storage handling the growing archive of video and clips. With this many independent microservices, keeping data infrastructure consistent, backed up, and correctly connected to the right service is as much of the job as the Kubernetes layer itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Project Taught Me
&lt;/h2&gt;

&lt;p&gt;BetaMedia taught me something different: how to run &lt;em&gt;many different services&lt;/em&gt;, with very different resource needs and failure modes, as one coherent, always-on system — where "always-on" isn't a nice-to-have; it's the actual product requirement.&lt;/p&gt;

&lt;p&gt;The two problems look similar from a distance — Kubernetes, monitoring, microservices — but the constraints that actually shape the architecture are completely different once you look closely. That's been one of the more useful lessons of doing DevOps across both.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a DevOps Engineer at Beta Codes.pk, working on Kubernetes, GitOps, and infrastructure for AI platforms including BetaMedia and BetaVision. Find me on &lt;a href="https://linkedin.com/in/jamal-al-gillani" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or check out my &lt;a href="https://jamal-uddin-1.github.io/portfolio" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>microservices</category>
      <category>kubernetes</category>
      <category>socialmedia</category>
    </item>
    <item>
      <title>Scaling AI Surveillance to 400+ Sites: The DevOps Behind BetaVision</title>
      <dc:creator>Syed Muhammad Jamal Uddin Gilani</dc:creator>
      <pubDate>Fri, 04 Sep 2026 15:40:07 +0000</pubDate>
      <link>https://dev.to/jamal-gilani/scaling-ai-surveillance-to-400-sites-the-devops-behind-betavision-4l0d</link>
      <guid>https://dev.to/jamal-gilani/scaling-ai-surveillance-to-400-sites-the-devops-behind-betavision-4l0d</guid>
      <description>&lt;p&gt;Most DevOps write-ups talk about deploying &lt;em&gt;an&lt;/em&gt; application to &lt;em&gt;a&lt;/em&gt; cluster. This one is about deploying the same AI platform to &lt;strong&gt;hundreds of independent, physically separate sites&lt;/strong&gt; each with its own hardware, its own network, and its own failure modes and still being able to see all of them from one dashboard.&lt;/p&gt;

&lt;p&gt;That's the problem I work on as a DevOps Engineer at Beta Codes.pk, building the infrastructure behind &lt;a href="https://betacodespk.com/smart-surveillance/" rel="noopener noreferrer"&gt;BetaVision&lt;/a&gt;, our AI-powered smart surveillance platform for urban and organizational security.&lt;/p&gt;

&lt;h2&gt;
  
  
  What BetaVision Does
&lt;/h2&gt;

&lt;p&gt;BetaVision is an AI-driven surveillance platform built around continuous, automated monitoring rather than passive CCTV footage. On the product side, it combines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;24/7 AI-driven monitoring&lt;/strong&gt; across critical infrastructure, public spaces, and high-risk areas&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video analytics&lt;/strong&gt; for spotting suspicious activity and unusual patterns automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License plate recognition (LPR)&lt;/strong&gt; for traffic control, parking, and law-enforcement use cases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facial recognition&lt;/strong&gt; for identifying individuals of interest and managing access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time alerts&lt;/strong&gt; that notify security personnel the moment something needs attention&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;centralized command-and-control dashboard&lt;/strong&gt; giving a single, unified view across every connected feed&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;mobile app&lt;/strong&gt; for remote monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's aimed at city governments, law enforcement agencies, corporate security teams, event organizers, retailers, and smart-city / property developments — anywhere a lot of camera feeds need to become actionable intelligence instead of just recorded video. The platform is deployed on GPU-enabled client and government infrastructure, running object detection, facial recognition, and ANPR/LPR on live feeds, with results streamed back to a central production dashboard.&lt;/p&gt;

&lt;p&gt;The AI/ML side is only half the story. The harder, less glamorous half is: how do you take a platform like this and reliably run it on &lt;strong&gt;400 sites&lt;/strong&gt;, each behind different networks, different hardware, different connectivity — and still ship updates, catch failures, and keep every stream visible in real time?&lt;/p&gt;

&lt;p&gt;That's the part I own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Challenge: One Codebase, Hundreds of Independent Deployments
&lt;/h2&gt;

&lt;p&gt;Every site runs its own instance of BetaVision — backend, frontend, and supporting services — on Docker containers, on-premise, on the client's own GPU hardware. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;400 separate deployment targets, not one cluster to scale horizontally&lt;/li&gt;
&lt;li&gt;No guarantee of stable public IPs or open inbound ports on client networks&lt;/li&gt;
&lt;li&gt;Updates that need to reach every site without 400 manual SSH sessions&lt;/li&gt;
&lt;li&gt;A central command-and-control dashboard that needs live visibility into sites it can't directly reach&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's how each of those gets solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making 400 Private Networks Publicly Reachable
&lt;/h2&gt;

&lt;p&gt;Client sites are, by design, locked-down networks. We don't control their firewalls, and we can't always get inbound access. But the whole point of BetaVision is that streams and results need to be visible centrally, 24/7.&lt;/p&gt;

&lt;p&gt;The solution is &lt;strong&gt;FRP (Fast Reverse Proxy)&lt;/strong&gt;, running as a client on each site and a server on our side. Every site's containers only need to make an &lt;em&gt;outbound&lt;/em&gt; connection — no inbound firewall rules required on the client's end. FRP tunnels the stream back through, and we wrap every tunnel in TLS so nothing is exposed in plaintext across the public internet.&lt;/p&gt;

&lt;p&gt;This one decision is what turns "400 isolated on-prem boxes" into "400 endpoints visible from a single pane of glass."&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker + Networking at Site Level
&lt;/h2&gt;

&lt;p&gt;Each site runs BetaVision's backend and frontend as Docker containers, with internal networks configured so services can talk to each other — the AI inference services, the API layer, the database layer, and the streaming layer all need to communicate reliably on-box before anything goes external through FRP.&lt;/p&gt;

&lt;p&gt;Multiply the usual "make sure your containers can talk to each other" problem by 400, across hardware you don't physically control, and debugging becomes as much about reproducibility as it is about the fix itself. A problem on site #217 needs to be diagnosable and fixable using the same playbook as site #12.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automating 400 Pipelines Without Losing Control
&lt;/h2&gt;

&lt;p&gt;Manually deploying to 400 sites isn't a scaling problem, it's a guaranteed-to-fail problem. So the deployment pipeline is built on &lt;strong&gt;GitHub Actions&lt;/strong&gt;, with dedicated pipelines per site for backend and frontend releases.&lt;/p&gt;

&lt;p&gt;A few things matter more here than in a typical single-cluster CI/CD setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Release history has to be real, not aspirational.&lt;/strong&gt; With this many deployment targets, "what version is site X running right now" needs to be answerable instantly, not reconstructed from memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-click releases replace multi-step manual processes.&lt;/strong&gt; What used to be a manual, error-prone rollout became a single pipeline trigger — this matters even more at 400-site scale than it did rolling this pattern out internally at 10-microservice scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures need to be isolated.&lt;/strong&gt; A bad deploy to one site should never cascade or block releases to the other 399.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Monitoring: Seeing Problems Before Clients Report Them
&lt;/h2&gt;

&lt;p&gt;None of this matters if you find out about outages from a client phone call. Monitoring across this many independent deployments has to answer two different questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is each individual site healthy?&lt;/li&gt;
&lt;li&gt;Is the aggregate platform healthy?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We run a Prometheus, Grafana, and Loki stack for visibility and alerting, so incidents surface through dashboards and automated alerts instead of user reports. At single-cluster scale this is standard practice; at 400-site scale, it's the difference between catching a dead stream in minutes versus finding out days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data: MinIO S3 and Databases, Per Site
&lt;/h2&gt;

&lt;p&gt;Each deployment needs its own storage and database layer — provisioned, backed up, and secured independently. We use MinIO S3 buckets for object storage and manage the database infrastructure per deployment, with backup and recovery built in from day one rather than bolted on after a data loss incident.&lt;/p&gt;

&lt;p&gt;Security runs through all of this, not around it — from access control down to how data moves off each site, aligned with our ISO 9001 and ISO 27001 certified information security standards.&lt;/p&gt;

&lt;h2&gt;
  
  
  From POC to Production, Repeatedly
&lt;/h2&gt;

&lt;p&gt;Before a site goes live, it usually starts as a proof of concept — deployed on a client's own GPU server, demoed live, sometimes directly to CIOs, under real production conditions. That means the deployment tooling has to work identically whether it's POC #1 or production site #380. There's no separate "demo mode" — if the automation only works for the polished cases, it doesn't scale to 400 real ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Actually Teaches You About DevOps
&lt;/h2&gt;

&lt;p&gt;Working on BetaVision reframed how I think about scale. It's not just "more replicas of the same thing." It's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reliability without central control&lt;/strong&gt; — most of your infrastructure lives on hardware and networks you don't own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability as a first-class deliverable&lt;/strong&gt;, not an afterthought — you cannot manually check 400 sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automation as risk reduction&lt;/strong&gt;, not just convenience — every manual step is a failure point multiplied by 400.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building infrastructure for a platform that needs to run in many independent, disconnected environments rather than one big cluster, I'd genuinely love to compare notes — feel free to reach out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a DevOps Engineer at Beta Codes.pk, working on Kubernetes, GitOps, and infrastructure for AI platforms deployed across hundreds of sites. Find me on &lt;a href="https://linkedin.com/in/jamal-al-gillani" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; or check out my &lt;a href="https://jamal-uddin-1.github.io/portfolio" rel="noopener noreferrer"&gt;portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>docker</category>
      <category>cicd</category>
    </item>
    <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>
