<?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: forgehomelab forgehomelab</title>
    <description>The latest articles on DEV Community by forgehomelab forgehomelab (@forgehomelab).</description>
    <link>https://dev.to/forgehomelab</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%2F4143684%2F83308fa9-11ec-49da-8252-0d3a56ee5f95.png</url>
      <title>DEV Community: forgehomelab forgehomelab</title>
      <link>https://dev.to/forgehomelab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/forgehomelab"/>
    <language>en</language>
    <item>
      <title>I got tired of rebuilding my homelab Kubernetes cluster, so I automated the whole thing</title>
      <dc:creator>forgehomelab forgehomelab</dc:creator>
      <pubDate>Sat, 26 Sep 2026 01:56:14 +0000</pubDate>
      <link>https://dev.to/forgehomelab/i-got-tired-of-rebuilding-my-homelab-kubernetes-cluster-so-i-automated-the-whole-thing-5ap0</link>
      <guid>https://dev.to/forgehomelab/i-got-tired-of-rebuilding-my-homelab-kubernetes-cluster-so-i-automated-the-whole-thing-5ap0</guid>
      <description>&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%2Fx19ww2b5hmch0hkimajr.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%2Fx19ww2b5hmch0hkimajr.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every time my homelab Kubernetes cluster broke, I had to redo everything by hand: create VMs, set up HAProxy, init kubeadm, install Calico, copy join tokens around, join control-plane nodes, join workers...&lt;/p&gt;

&lt;p&gt;After the third or fourth time, I stopped fighting it and wrote a script that does the whole thing with one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it builds
&lt;/h2&gt;

&lt;p&gt;One KVM host → 6 VMs → a real kubeadm HA cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 ┌─────────────────────────────┐
  Client ──────▶ │ k8s-bastion (HAProxy)       │
                 │  :6443 → control-plane API  │
                 │  :80/:443 → ingress         │
                 └─────────────┬───────────────┘
                               │
        ┌──────────────┬───────┴──────┬──────────────┐
        ▼              ▼              ▼
  k8s-control1   k8s-control2   k8s-control3   (stacked etcd)

        ▼              ▼
  k8s-worker1    k8s-worker2                   (ingress-nginx)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;3 control-plane nodes with stacked etcd&lt;/li&gt;
&lt;li&gt;2 workers&lt;/li&gt;
&lt;li&gt;HAProxy in front of the API server (used as &lt;code&gt;controlPlaneEndpoint&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Calico CNI&lt;/li&gt;
&lt;li&gt;ingress-nginx, spread across workers and wired to ports 80/443&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not k3s, not kind. The same kubeadm layout you'd see in a real on-prem setup, which is exactly why I wanted it: I wanted to see how every piece fits together.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The script is split into stages, and &lt;code&gt;all&lt;/code&gt; just runs them in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./k8s_install-ha.sh prep      &lt;span class="c"&gt;# SSH key, host packages, download Rocky 9 cloud image&lt;/span&gt;
./k8s_install-ha.sh vms       &lt;span class="c"&gt;# create 6 VMs with cloud-init&lt;/span&gt;
./k8s_install-ha.sh ip        &lt;span class="c"&gt;# collect VM IPs, save a state file&lt;/span&gt;
./k8s_install-ha.sh bastion   &lt;span class="c"&gt;# HAProxy + bootstrap script on the bastion&lt;/span&gt;
./k8s_install-ha.sh &lt;span class="nb"&gt;install&lt;/span&gt;   &lt;span class="c"&gt;# containerd, kubeadm init, Calico, joins&lt;/span&gt;
./k8s_install-ha.sh addons    &lt;span class="c"&gt;# ingress-nginx + HAProxy 80/443&lt;/span&gt;
./k8s_install-ha.sh verify    &lt;span class="c"&gt;# check everything&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I learned along the way that made it much less painful:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud images + cloud-init instead of OS installs.&lt;/strong&gt; Each VM boots straight from the Rocky Linux 9 GenericCloud image. Kernel modules, sysctl, swap-off and base packages are all done by cloud-init on first boot, so there's no installer to click through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding VM IPs is surprisingly annoying.&lt;/strong&gt; Depending on timing, the guest agent isn't ready yet, or the lease isn't visible. The script tries the guest agent first, then libvirt, then DHCP leases by MAC address, and retries for a few minutes before giving up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Split it into stages and make each one safe to re-run.&lt;/strong&gt; Existing VMs, installed packages and already-joined nodes are skipped. When something fails halfway, I just fix it and re-run that stage instead of starting from zero. This alone saved me hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prepare nodes in parallel.&lt;/strong&gt; Installing containerd and kubeadm on 5 nodes one by one is slow, so that part runs in parallel from the bastion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;After about 10–20 minutes for the cluster part:&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           VERSION
k8s-control1.k8s.local    Ready    control-plane   v1.33.x
k8s-control2.k8s.local    Ready    control-plane   v1.33.x
k8s-control3.k8s.local    Ready    control-plane   v1.33.x
&lt;/span&gt;&lt;span class="gp"&gt;k8s-worker1.k8s.local     Ready    &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;v1.33.x
&lt;span class="gp"&gt;k8s-worker2.k8s.local     Ready    &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;v1.33.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Being honest about the limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bastion is a single point of failure.&lt;/strong&gt; The control plane is HA, but HAProxy in front of it is one node. No keepalived/VIP yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The default spec is chunky:&lt;/strong&gt; 30 vCPU / 88 GB RAM. Every node's CPU/RAM/disk is a variable at the top of the script, so you can shrink it for a smaller host, and the &lt;code&gt;prep&lt;/code&gt; step compares the plan against your host before creating anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's for homelab / learning&lt;/strong&gt;, not production. Firewalld is off, SELinux is permissive, and SSH host key checks are skipped to keep the lab simple.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you want to skip the setup
&lt;/h2&gt;

&lt;p&gt;I cleaned it up with a full README and put it on Gumroad, in case it's useful for anyone studying for CKA or building a homelab that looks like real infrastructure:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://forgehomelab.gumroad.com/l/k8s-ha-cluster-builder" rel="noopener noreferrer"&gt;K8s HA Cluster Builder on Gumroad&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>linux</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
