<?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: SEON</title>
    <description>The latest articles on DEV Community by SEON (@seon).</description>
    <link>https://dev.to/seon</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3963614%2F9312fece-b0a1-4cdf-9adb-62fd1f21f29e.jpg</url>
      <title>DEV Community: SEON</title>
      <link>https://dev.to/seon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/seon"/>
    <language>en</language>
    <item>
      <title>Hybrid k3s #1: Cloud and home into one cluster — initial setup</title>
      <dc:creator>SEON</dc:creator>
      <pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/seon/hybrid-k3s-1-cloud-and-home-into-one-cluster-initial-setup-72i</link>
      <guid>https://dev.to/seon/hybrid-k3s-1-cloud-and-home-into-one-cluster-initial-setup-72i</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%2Fblog.seon.world%2Fimages%2Fk3s-1%2Fk3s-1-master-en-wm.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%2Fblog.seon.world%2Fimages%2Fk3s-1%2Fk3s-1-master-en-wm.png" alt="Hybrid k3s — current architecture" width="800" height="592"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  0. About this series
&lt;/h2&gt;

&lt;p&gt;This series is a record — written one piece at a time — of how I actually built the homelab shown in the diagram above, the one I'm running right now.&lt;/p&gt;

&lt;p&gt;What started as a toy project from a simple "could this even work?" turned, through satisfying performance and endless tearing-down-and-rebuilding, into a genuine toy that relieves the stress built up at work.&lt;/p&gt;

&lt;p&gt;It isn't a resource-rich cluster, but it has been more than enough to get a real taste of Kubernetes, and it keeps giving me new things I want to try next.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;6 nodes&lt;/strong&gt; — 2 Lightsail &lt;strong&gt;servers&lt;/strong&gt; (control plane + etcd) in the cloud (AWS Tokyo) + 4 &lt;strong&gt;Lima VM agents&lt;/strong&gt; on a home (Sapporo) iMac&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;19 vCPU / 61 GiB&lt;/strong&gt; total, &lt;strong&gt;49 namespaces&lt;/strong&gt; , &lt;strong&gt;248 pods (150 running)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Deployment via &lt;strong&gt;ArgoCD&lt;/strong&gt; , authentication via &lt;strong&gt;Keycloak OIDC&lt;/strong&gt; , with CloudNativePG, Vault, CrowdSec, Prometheus/Grafana, and more running on top&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It wasn't easy, but it wasn't hard enough to give up on either — so I'm going to write up, one at a time, the things I learned while building it and the things I want to keep.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This first story is about the foundation — how I started from &lt;strong&gt;two control-plane nodes in the cloud&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. Background
&lt;/h2&gt;

&lt;p&gt;There was no grand blueprint to begin with. The starting point was ordinary.&lt;/p&gt;

&lt;p&gt;Working with Kubernetes in my day job, things I want to dig into more keep coming up. Reading the docs is one thing; breaking and fixing a cluster with my own hands is another. There's an environment I can touch at work too, but it's limited, and a careless mistake there leads to noisy, annoying situations — so there were limits.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;I needed a cluster I could run however I wanted.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As it happened, a &lt;strong&gt;64GB-RAM iMac&lt;/strong&gt; , more than 10 years old, was sitting mostly idle at home. It still performs well enough, but it has an HDD so it's slow, its OS is past end-of-support, and it has handed its seat to a MacBook Pro M4 and is now resting. On the cloud side, I already had &lt;strong&gt;two small Lightsail instances&lt;/strong&gt; running personal services, and as those services grew, resources were gradually getting tight.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"What if I stopped keeping the idle home machine's resources and the cloud I'm already paying for separate, and used them as one?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The urge to learn and the pressure on resources converged on a single idea — &lt;strong&gt;combine the cloud and home into one cluster.&lt;/strong&gt; This article is the first dig: building the cloud-side foundation.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why k3s — a choice under limited resources
&lt;/h2&gt;

&lt;p&gt;First, let's prepare a Kubernetes (k8s) environment.&lt;/p&gt;

&lt;p&gt;But for the resources I had in my cloud environment, standard k8s was too heavy. In my dreams I wanted to run wild on a multi-cluster with thousands of nodes; in reality it was a small AWS Lightsail instance of about $150/month and a single 10-plus-year-old iMac near retirement.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I had to pick "which Kubernetes to go with" first. Here's what my research turned up.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;For this situation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Managed (EKS/GKE/AKS)&lt;/td&gt;
&lt;td&gt;The cloud runs the control plane for you&lt;/td&gt;
&lt;td&gt;Control-plane fee + node cost → conflicts with low cost / reusing idle gear, excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vanilla Kubernetes (kubeadm)&lt;/td&gt;
&lt;td&gt;Assemble upstream yourself&lt;/td&gt;
&lt;td&gt;The most orthodox but heavy and hands-on → a burden for low-spec/small scale, excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;k3s&lt;/strong&gt; (Rancher/SUSE)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Single-binary lightweight distro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lightweight distro — &lt;strong&gt;finalist&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k0s · MicroK8s&lt;/td&gt;
&lt;td&gt;Lightweight distros of a similar kind&lt;/td&gt;
&lt;td&gt;Likewise lightweight distros — &lt;strong&gt;finalist&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;minikube · kind&lt;/td&gt;
&lt;td&gt;For local dev/testing&lt;/td&gt;
&lt;td&gt;Not meant for persistent multi-node operation → excluded&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Filtering this way, the &lt;strong&gt;candidates narrowed to three lightweight distros: k3s, k0s, and MicroK8s.&lt;/strong&gt; Digging deeper into the three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;k3s (chosen)&lt;/th&gt;
&lt;th&gt;k0s&lt;/th&gt;
&lt;th&gt;MicroK8s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Maker&lt;/td&gt;
&lt;td&gt;Rancher/SUSE&lt;/td&gt;
&lt;td&gt;Mirantis&lt;/td&gt;
&lt;td&gt;Canonical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packaging&lt;/td&gt;
&lt;td&gt;Single binary&lt;/td&gt;
&lt;td&gt;Single binary&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;snap package&lt;/strong&gt; (depends on snapd)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default datastore&lt;/td&gt;
&lt;td&gt;SQLite (kine); embedded etcd for HA&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;etcd standard&lt;/strong&gt; (kine for other DBs too)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dqlite&lt;/strong&gt; (distributed SQLite, Raft)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HA approach&lt;/td&gt;
&lt;td&gt;Switches to etcd with multiple servers&lt;/td&gt;
&lt;td&gt;Provided by default&lt;/td&gt;
&lt;td&gt;Automatic HA at 3+ nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control plane&lt;/td&gt;
&lt;td&gt;server also runs workloads&lt;/td&gt;
&lt;td&gt;Internal components as separate processes, &lt;strong&gt;control-plane isolation&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Per node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default CNI&lt;/td&gt;
&lt;td&gt;flannel (lightweight, limited policy)&lt;/td&gt;
&lt;td&gt;kube-router/calico&lt;/td&gt;
&lt;td&gt;calico (HA variant)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundling&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Essential components included&lt;/strong&gt; (Traefik, ServiceLB, local-path…)&lt;/td&gt;
&lt;td&gt;Minimal, easy to swap default components&lt;/td&gt;
&lt;td&gt;Enable add-ons with &lt;code&gt;microk8s enable&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why k3s.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;All three are CNCF-compliant lightweight distros, but they differ in character.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;k0s&lt;/strong&gt; keeps the control plane separate from workloads, which is clean, but it ships with fewer things, so there's more to plug in yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MicroK8s&lt;/strong&gt; has the convenience of enabling add-ons with a single &lt;code&gt;microk8s enable&lt;/code&gt; line, but in return it's tied to snap, and there are reported cases of dqlite CPU/consensus instability on write-heavy clusters. (&lt;a href="https://github.com/canonical/microk8s/issues/3227" rel="noopener noreferrer"&gt;GitHub Issue #3227&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;k3s&lt;/strong&gt; , on the other hand, has essential components bundled into a single binary, so the initial setup is the fastest, and the path of moving to embedded etcd with multiple servers fits naturally with this kind of "cloud + home HA." Add low-spec/ARM support and the depth of its docs and community, and for the goal of learning and low-cost operation at once, k3s fit best. (comparison sources: &lt;a href="https://palark.com/blog/small-local-kubernetes-comparison/" rel="noopener noreferrer"&gt;Palark&lt;/a&gt; · &lt;a href="https://www.portainer.io/blog/k0s-vs-k3s" rel="noopener noreferrer"&gt;Portainer&lt;/a&gt; · &lt;a href="https://www.nops.io/blog/k0s-vs-k3s-vs-k8s/" rel="noopener noreferrer"&gt;nOps&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;k3s repackages that Kubernetes &lt;strong&gt;as a single binary (under 100MB) while staying 100% compatible (CNCF certified).&lt;/strong&gt; Its requirements are essentially just a modern kernel + cgroups, so it's no strain even on low-spec hardware. (&lt;a href="https://docs.k3s.io/" rel="noopener noreferrer"&gt;What is K3s&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Just three reasons it's light:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Single binary, single process.&lt;/strong&gt; Components that run separately in regular Kubernetes — &lt;code&gt;kube-apiserver&lt;/code&gt;, &lt;code&gt;kube-scheduler&lt;/code&gt;, &lt;code&gt;kube-controller-manager&lt;/code&gt;, &lt;code&gt;kubelet&lt;/code&gt;, &lt;code&gt;kube-proxy&lt;/code&gt; — are wrapped into one &lt;code&gt;k3s&lt;/code&gt; process, with the containerd runtime built in. (&lt;a href="https://docs.k3s.io/architecture" rel="noopener noreferrer"&gt;Architecture&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexible datastore.&lt;/strong&gt; A single server uses SQLite by default; &lt;strong&gt;with multiple servers, embedded etcd is selected automatically&lt;/strong&gt; (external MySQL/Postgres are also possible). (&lt;a href="https://docs.k3s.io/datastore" rel="noopener noreferrer"&gt;Datastore&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Essential components included.&lt;/strong&gt; flannel (CNI), CoreDNS, Traefik (Ingress), ServiceLB, local-path (storage), and metrics-server are brought up together at install time. That's that much less to assemble yourself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As a bonus, k3s nodes come in two kinds — &lt;strong&gt;server&lt;/strong&gt; (control plane + datastore) and &lt;strong&gt;agent&lt;/strong&gt; (workload only) — which made it a good match for a hybrid setup like "cloud = server, home = agent." You'll see this in the diagrams from chapter 4 onward.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The control plane — three is the rule, but a two-node challenge
&lt;/h2&gt;

&lt;p&gt;Originally I ran personal services in the cloud with &lt;strong&gt;Docker Compose&lt;/strong&gt;. &lt;strong&gt;The small instance handled the DB&lt;/strong&gt; , and &lt;strong&gt;the large instance handled several microservices.&lt;/strong&gt; Moving these two to Kubernetes, my first worry was the control plane.&lt;/p&gt;

&lt;p&gt;For Kubernetes to be stable, &lt;strong&gt;control-plane HA&lt;/strong&gt; is the baseline. k3s's embedded etcd can't accept writes unless it keeps a majority (quorum), and the official HA guide recommends &lt;strong&gt;3 or more servers (an odd number).&lt;/strong&gt; With &lt;code&gt;n&lt;/code&gt; nodes the quorum is &lt;code&gt;(n/2)+1&lt;/code&gt;, and the node count minus the quorum is how many node failures you can tolerate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;servers&lt;/th&gt;
&lt;th&gt;quorum&lt;/th&gt;
&lt;th&gt;failures tolerated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;The rule is &lt;strong&gt;three.&lt;/strong&gt; But adding one more instance was tight on the wallet, so I changed the goal:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;_I know three is the right answer, but for now let me run &lt;strong&gt;two as stably as possible.&lt;/strong&gt; _&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In choosing two, I made two things clear.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;First, don't pile everything on one node.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I once put the control plane and services all on a single node and got badly burned. Lightsail is a &lt;strong&gt;burstable CPU&lt;/strong&gt; model: each plan has a per-vCPU baseline %, and when load stays above it for a while it &lt;strong&gt;spends the burst capacity&lt;/strong&gt; it had accrued, dropping to baseline once it hits 0. With the control plane (apiserver, etcd) on the same node, the moment the CPU dries up, cluster control itself stops — so I split the load across two nodes.&lt;/p&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;plan&lt;/th&gt;
&lt;th&gt;vCPU&lt;/th&gt;
&lt;th&gt;baseline&lt;/th&gt;
&lt;th&gt;role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;server-A&lt;/td&gt;
&lt;td&gt;8GB ($44/mo)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;cluster-init · control-plane+etcd+worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;server-B&lt;/td&gt;
&lt;td&gt;16GB ($84/mo)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;td&gt;join · control-plane+etcd+worker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Checking usage at the time of writing, both are below baseline (the sustainable zone), accruing burst (&lt;code&gt;kubectl top nodes&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
cp-8gb-init 482m 24% 4565Mi 58%
cp-16gb-join 1153m 28% 10096Mi 65%

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Second, admit that two is not HA, and take out insurance.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As the table shows, with two nodes, losing even one loses quorum and writes stop (pods already running keep going under kubelet, so it's "no changes" rather than "total outage"). I cover that risk with &lt;strong&gt;etcd automatic snapshots.&lt;/strong&gt; Since I gave no extra config, it runs with k3s defaults — &lt;code&gt;0 */12 * * *&lt;/code&gt; (twice a day), keep 5, stored at &lt;code&gt;/var/lib/rancher/k3s/server/db/snapshots&lt;/code&gt;. (&lt;a href="https://docs.k3s.io/cli/etcd-snapshot" rel="noopener noreferrer"&gt;etcd-snapshot&lt;/a&gt;) Since they only pile up locally, pushing them to NAS/object storage later is a task I've left for the backup installment.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Today's star — Tailscale
&lt;/h2&gt;

&lt;p&gt;The control plane is on Lightsail in Tokyo; the machine I'll use as a worker is the home iMac in Sapporo.&lt;/p&gt;

&lt;p&gt;These two &lt;strong&gt;don't share a private network.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The home machine sits behind a router on a private IP (192.168.x), so it can't be reached directly from outside, and opening ports to expose it would mean exposing cluster ports like kubelet (10250) and VXLAN (8472) to the internet — dangerous. For k3s to bind nodes into one cluster, everyone has to be able to call each other by &lt;strong&gt;one stable address&lt;/strong&gt; , and the current setup doesn't have that.&lt;/p&gt;

&lt;p&gt;So I went looking for a method among VPNs and meshes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;For this situation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct port exposure + public IP&lt;/td&gt;
&lt;td&gt;Expose as-is without a VPN&lt;/td&gt;
&lt;td&gt;Effectively exposes kubelet/VXLAN to the internet → dangerous, dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;raw WireGuard&lt;/td&gt;
&lt;td&gt;Fast kernel VPN, manual keys/peers&lt;/td&gt;
&lt;td&gt;Fast, but NAT traversal, key management, and access control are all manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenVPN&lt;/td&gt;
&lt;td&gt;Traditional hub-style VPN&lt;/td&gt;
&lt;td&gt;Hub-centric rather than mesh, heavy to set up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ZeroTier&lt;/td&gt;
&lt;td&gt;Managed mesh VPN&lt;/td&gt;
&lt;td&gt;A solid candidate, similar in flavor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tailscale&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;WireGuard + coordination (mesh)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Automatic NAT traversal, ACLs, MagicDNS, unattended keys, free for personal use ← &lt;strong&gt;chosen&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headscale&lt;/td&gt;
&lt;td&gt;Self-hosted Tailscale control server&lt;/td&gt;
&lt;td&gt;More freedom but the burden of self-operation → consider later&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;After a lot of trial and deliberation that took plenty of time, in the end I chose Tailscale.&lt;/strong&gt; It's a WireGuard-based mesh VPN: install a daemon on each machine and log in, and it joins a private network (a &lt;strong&gt;tailnet&lt;/strong&gt; ) tied to your account, with each machine getting one address in the &lt;code&gt;100.x&lt;/code&gt; range. That address is reachable by the same value from anywhere — whether the machine is in Tokyo or behind a router in Sapporo — and Tailscale handles NAT traversal for you.&lt;/p&gt;

&lt;p&gt;It means you can lay down a "virtual LAN" that puts the cloud and home on one plane. (And up to 100 machines register for free.)&lt;/p&gt;

&lt;p&gt;When k3s registers a node, it stamps the address given via &lt;code&gt;--node-ip&lt;/code&gt; as that node's identity (InternalIP). So by setting this value to a Tailscale address from the start, a home node joining later lands on the same &lt;code&gt;100.x&lt;/code&gt; plane as-is. That's why I install &lt;strong&gt;Tailscale before k3s.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Tailscale: sign up · install · verify
&lt;/h2&gt;

&lt;p&gt;The order is sign up → install → verify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① Sign up.&lt;/strong&gt; Log in at &lt;a href="https://login.tailscale.com" rel="noopener noreferrer"&gt;login.tailscale.com&lt;/a&gt; with an &lt;strong&gt;SSO account&lt;/strong&gt; like Google, GitHub, or Microsoft, and a tailnet for that account is created automatically. There's no separate signup form; SSO is the signup.&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%2Fblog.seon.world%2Fimages%2Fk3s-1%2Fk3s-1-tailscale-signin-wm.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%2Fblog.seon.world%2Fimages%2Fk3s-1%2Fk3s-1-tailscale-signin-wm.png" alt="Tailscale sign-in screen" width="800" height="697"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② (For servers) Prepare an auth key.&lt;/strong&gt; Cloud servers have no browser, so issue an &lt;strong&gt;auth key&lt;/strong&gt; (&lt;code&gt;tskey-…&lt;/code&gt;) in advance from the admin console under &lt;strong&gt;Settings → Keys.&lt;/strong&gt; You can skip this if you'll connect interactively.&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%2Fblog.seon.world%2Fimages%2Fk3s-1%2Fk3s-1-tailscale-keys-wm.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%2Fblog.seon.world%2Fimages%2Fk3s-1%2Fk3s-1-tailscale-keys-wm.png" alt="Tailscale admin console Keys" width="508" height="718"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Install &amp;amp; connect.&lt;/strong&gt; On each of the two cloud nodes ( &lt;strong&gt;Amazon Linux 2023&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;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up &lt;span class="c"&gt;# authenticate via the printed URL (headless: --authkey tskey-… )&lt;/span&gt;
tailscale ip &lt;span class="nt"&gt;-4&lt;/span&gt; &lt;span class="c"&gt;# this node's 100.x address — used directly as --node-ip in ch.6&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;④ Verify.&lt;/strong&gt; If both nodes appear in the admin console &lt;strong&gt;Machines&lt;/strong&gt; page (&lt;a href="https://login.tailscale.com/admin/machines" rel="noopener noreferrer"&gt;login.tailscale.com/admin/machines&lt;/a&gt;) with their &lt;code&gt;100.x&lt;/code&gt; address and hostname, it worked.&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%2Fblog.seon.world%2Fimages%2Fk3s-1%2Fk3s-1-tailscale-machines-wm.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%2Fblog.seon.world%2Fimages%2Fk3s-1%2Fk3s-1-tailscale-machines-wm.png" alt="Tailscale admin console Machines list" width="800" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can also check from the node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale status &lt;span class="c"&gt;# list of machines in the tailnet + each one's 100.x&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this, the two cloud nodes see each other by &lt;code&gt;100.x&lt;/code&gt; in one tailnet. Now I bring up k3s with these addresses. (&lt;a href="https://tailscale.com/kb/1031/install-linux" rel="noopener noreferrer"&gt;Tailscale Linux install&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Installing k3s (with Tailscale addresses)
&lt;/h2&gt;

&lt;p&gt;Put the &lt;code&gt;100.x&lt;/code&gt; you got in chapter 5 straight into &lt;code&gt;--node-ip&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;server-A (8GB)&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;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;K3S_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;shared-secret&amp;gt; &lt;span class="nv"&gt;INSTALL_K3S_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;v1.34.3+k3s1 &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-s&lt;/span&gt; - server &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--cluster-init&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--node-ip&lt;/span&gt; 100.71.x.x &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--node-external-ip&lt;/span&gt; &amp;lt;publicA&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--advertise-address&lt;/span&gt; 100.71.x.x &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--flannel-backend&lt;/span&gt; vxlan

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--cluster-init&lt;/code&gt; — initializes embedded etcd as the first server. (&lt;a href="https://docs.k3s.io/cli/server" rel="noopener noreferrer"&gt;server flags&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--node-ip 100.71.x.x&lt;/code&gt; — advertises the Tailscale address received in ch.5 as the InternalIP.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--node-external-ip&lt;/code&gt; / &lt;code&gt;--advertise-address&lt;/code&gt; — public IP (for external exposure), apiserver advertise address (Tailscale).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--flannel-backend vxlan&lt;/code&gt; — CNI backend (the default, stated explicitly).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;K3S_TOKEN can be a value you set yourself, like choosing a password, or left blank for k3s to generate automatically. But since you need to know this value to join, save it separately or just pass the value at the path below.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/var/lib/rancher/k3s/server/node-token&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;server-B (16GB) — joins as the second server.&lt;/strong&gt; This node, too, joins the tailnet first, then just connects with the same token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;K3S_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;secret&amp;gt; &lt;span class="nv"&gt;INSTALL_K3S_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;v1.34.3+k3s1 &lt;span class="se"&gt;\&lt;/span&gt;
  sh &lt;span class="nt"&gt;-s&lt;/span&gt; - server &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--server&lt;/span&gt; https://172.26.x.x:6443 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--node-ip&lt;/span&gt; 100.99.x.x

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--server https://172.26.x.x:6443&lt;/code&gt; = server-A's address (a private IP, since it's the same VPC).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--node-ip 100.99.x.x&lt;/code&gt; = this node's Tailscale address.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two Lightsail boxes are in the &lt;strong&gt;same AWS VPC&lt;/strong&gt; , so joining itself used the private IP, but the InternalIP advertised to the cluster is Tailscale (&lt;code&gt;100.x&lt;/code&gt;) for both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firewall&lt;/strong&gt; — open only the minimum externally. (&lt;a href="https://docs.k3s.io/installation/requirements" rel="noopener noreferrer"&gt;requirements&lt;/a&gt;)&lt;/p&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;use&lt;/th&gt;
&lt;th&gt;exposure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;80 / 443&lt;/td&gt;
&lt;td&gt;Traefik Ingress&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;SSH&lt;/td&gt;
&lt;td&gt;my IP only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6443 / 2379-2380 / 8472 / 10250&lt;/td&gt;
&lt;td&gt;apiserver·etcd·flannel·kubelet&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;closed publicly&lt;/strong&gt; , private/Tailscale internal only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  7. Cluster setup — complete with two nodes
&lt;/h2&gt;

&lt;p&gt;Attaching the home iMac as an agent is covered in the next article.&lt;/p&gt;

&lt;p&gt;For now I've built the cluster with &lt;strong&gt;two Lightsail boxes, Tailscale applied.&lt;/strong&gt; Listing the nodes, you can confirm both are &lt;code&gt;Ready&lt;/code&gt; on the same version and runtime.&lt;br&gt;
&lt;/p&gt;

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

NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
…3-146&lt;span class="o"&gt;(&lt;/span&gt;8GB&lt;span class="o"&gt;)&lt;/span&gt; Ready control-plane,etcd 139d v1.34.3+k3s1 100.71.x.x 52.x.x.x Amazon Linux 2023.7.20250512 6.1.134-…amzn2023.x86_64 containerd://2.1.5-k3s1
…2-70&lt;span class="o"&gt;(&lt;/span&gt;16GB&lt;span class="o"&gt;)&lt;/span&gt; Ready control-plane,etcd 139d v1.34.3+k3s1 100.99.x.x 3.x.x.x Amazon Linux 2023.9.20251105 6.1.156-…amzn2023.x86_64 containerd://2.1.5-k3s1

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check whether the two nodes are etcd voting members (look at Conditions in &lt;code&gt;kubectl describe node &amp;lt;name&amp;gt;&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Conditions:
  Type Status Reason Message
  ---- ------ ------ -------
  EtcdIsVoter True MemberNotLearner Node is a voting member of the etcd cluster
  MemoryPressure False KubeletHasSufficientMemory kubelet has sufficient memory available
  DiskPressure False KubeletHasNoDiskPressure kubelet has no disk pressure
  PIDPressure False KubeletHasSufficientPID kubelet has sufficient PID available
  Ready True KubeletReady kubelet is posting ready status

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check that the k3s default bundle came up too (&lt;code&gt;kubectl get pods -n kube-system&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="c"&gt;# kubectl get pods -n kube-system → k3s default bundle only (excerpt)&lt;/span&gt;
coredns-7f496c8d7d-nx9jc 1/1 Running 139d &lt;span class="c"&gt;# DNS&lt;/span&gt;
local-path-provisioner-578895bd58-mgxpm 1/1 Running 139d &lt;span class="c"&gt;# local storage (default SC)&lt;/span&gt;
metrics-server-7b9c9c4b9c-76ldg 1/1 Running 139d &lt;span class="c"&gt;# metrics (kubectl top)&lt;/span&gt;
traefik-78df465dcc-66kn8 1/1 Running 9d &lt;span class="c"&gt;# Ingress (server-A)&lt;/span&gt;
traefik-78df465dcc-gs4q7 1/1 Running 8d &lt;span class="c"&gt;# Ingress (server-B) → one per node = 2 replicas&lt;/span&gt;
helm-install-traefik-crd-pmk4t 0/1 Completed 139d &lt;span class="c"&gt;# Helm Job that installed the bundle (completed)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That concludes setting up two cloud instances as a k3s cluster. It isn't just that I installed k3s — I also configured Tailscale so that, later, any machine can join as an agent regardless of where it is or what form it takes, as long as it's an environment where k3s can be configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Next
&lt;/h2&gt;

&lt;p&gt;The AWS Lightsail nodes are now formed into a cluster, and the groundwork for nodes to join is all set.&lt;/p&gt;

&lt;p&gt;In the end it came down to one command per node, but this stage took more time than I expected.&lt;/p&gt;

&lt;p&gt;To this two-node cluster, I'll now &lt;strong&gt;bring in the iMac resting at home, in earnest.&lt;/strong&gt; I'll install Lima VMs on the iMac, create an agent on each, join them to the same tailnet, and write up the problems I ran into after joining — solving them along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;k3s — What is K3s / Architecture / Datastore: &lt;a href="https://docs.k3s.io/" rel="noopener noreferrer"&gt;https://docs.k3s.io/&lt;/a&gt; · /architecture · /datastore&lt;/li&gt;
&lt;li&gt;k3s — HA Embedded etcd / Server flags / etcd-snapshot / Requirements: &lt;a href="https://docs.k3s.io/datastore/ha-embedded" rel="noopener noreferrer"&gt;https://docs.k3s.io/datastore/ha-embedded&lt;/a&gt; · /cli/server · /cli/etcd-snapshot · /installation/requirements&lt;/li&gt;
&lt;li&gt;Lightweight distro comparison (k3s·k0s·MicroK8s): &lt;a href="https://palark.com/blog/small-local-kubernetes-comparison/" rel="noopener noreferrer"&gt;https://palark.com/blog/small-local-kubernetes-comparison/&lt;/a&gt; · &lt;a href="https://www.portainer.io/blog/k0s-vs-k3s" rel="noopener noreferrer"&gt;https://www.portainer.io/blog/k0s-vs-k3s&lt;/a&gt; · &lt;a href="https://www.nops.io/blog/k0s-vs-k3s-vs-k8s/" rel="noopener noreferrer"&gt;https://www.nops.io/blog/k0s-vs-k3s-vs-k8s/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tailscale — Linux install: &lt;a href="https://tailscale.com/kb/1031/install-linux" rel="noopener noreferrer"&gt;https://tailscale.com/kb/1031/install-linux&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS Lightsail — burst CPU / baseline: &lt;a href="https://docs.aws.amazon.com/lightsail/latest/userguide/baseline-cpu-performance.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/lightsail/latest/userguide/baseline-cpu-performance.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>k3s</category>
      <category>tailscale</category>
      <category>etcd</category>
    </item>
  </channel>
</rss>
