Kubernetes for the Homelab: K3s from Scratch to Production
Running Kubernetes at home used to be a punishment. You stood up three Raspberry Pis with kubeadm, fought with cgroups for a weekend, and ended up with a cluster that ate more RAM than the workloads it was supposed to host. K3s changed the calculus: a single 70 MB binary, sane defaults, and a cluster that runs comfortably on a Mini PC with 8 GB of RAM. In 2026 it is the default answer for “I want real Kubernetes at home without renting a datacenter.”
This guide is the version of the K3s tutorial I wish I had when I migrated my home lab off Docker Compose. It covers single-node and three-node HA setups, persistent storage with Longhorn, MetalLB for real LoadBalancer IPs, ingress with both Traefik and ingress-nginx, automated TLS via cert-manager with the DNS-01 challenge, system upgrades that won’t brick your cluster at 3 AM, and a pragmatic migration path from docker-compose.yml to manifests you would actually deploy.
Why K3s, and why now
The lightweight Kubernetes landscape has consolidated. The three serious contenders for self-hosting in 2026 are K3s, k0s, and MicroK8s, with Talos Linux as a more opinionated option for people who want immutable infrastructure. Each has a personality.
| Distro | Footprint | Default datastore | Out-of-the-box add-ons | Strength | Weakness |
|---|---|---|---|---|---|
| K3s | ~70 MB bin | SQLite or etcd | Traefik, ServiceLB, local-path | Batteries-included, huge ecosystem | Slightly higher idle CPU than k0s |
| k0s | ~180 MB bin | etcd / kine | None — pure upstream | Minimal, vendor-neutral | You build everything yourself |
| MicroK8s | snap | dqlite | Plugin-based (ingress, storage) | One-command add-ons | Snap-only, Ubuntu-centric |
| Talos Linux | OS-level | etcd | None — pure upstream | Immutable, API-driven, no SSH | Steep learning curve, no kubectl exec-ish escape hatches into the host |
For a homelab the deciding factors are usually how much hand-holding you want and whether you care about the host OS being conventional. K3s wins for most people because the defaults are sensible (Traefik, local-path, ServiceLB are all on unless you disable them), the ecosystem assumes K3s, and the project is maintained by SUSE/Rancher with predictable monthly releases tracking upstream Kubernetes minor versions. If you specifically want an immutable Talos-style cluster, see the comparison at the end — it is genuinely better for some workloads.
Within K3s itself, the version skew rules matter. K3s tracks upstream Kubernetes (currently the v1.34 and v1.36 series are the maintained lines as of May 2026), and you cannot skip minor versions during upgrades. Pin your install to a minor and plan upgrades, do not just curl | sh quarterly and hope.
Hardware and OS prerequisites
K3s is forgiving but not magic. Some numbers from running it across a half dozen homelabs:
- Single-node, light (10–20 containers, one user): 2 vCPU, 4 GB RAM, 32 GB disk. A used Lenovo M720q runs this comfortably.
- Single-node, heavier (Longhorn, observability, a few DBs): 4 vCPU, 8 GB RAM, 128 GB SSD. Idle ~1.0–1.5 GB RAM, but Longhorn pushes that to 2.5 GB before any workloads.
- 3-node HA with embedded etcd: 2 vCPU, 4 GB RAM per node minimum; 8 GB is realistic once Longhorn and ingress traffic show up.
etcd hates slow storage. Spinning rust or a tired SD card causes etcdserver: request timed out errors that look like network problems but are not. Use SSDs. On Raspberry Pis use a USB 3 SSD, not the SD card. See Linux performance tuning for why.
OS choices that work well: Debian 12, Ubuntu 24.04 LTS, Rocky 9, openSUSE Leap. Avoid distros that ship a non-standard cgroup setup unless you know what you are doing. On Fedora or anything with SELinux enforcing, install the k3s-selinux package or you will see cryptic pod startup failures.
Three things to set on every node before installing K3s:
|
|
Clock skew across nodes will break etcd quorum. If your nodes drift more than a few seconds, fix NTP before you fix anything else.
Single-node install — the five-minute path
For a homelab where you are the only user and the cluster runs on one box, single-node K3s is the right answer for at least the first six months. Do not start with HA. You will spend more time fixing HA quirks than you will save in uptime.
The default install:
|
|
That is it. The installer drops a systemd unit, embeds a SQLite datastore, enables Traefik as the ingress controller, ServiceLB (Klipper) as the load balancer, and local-path-provisioner for default storage. Within 30 seconds:
|
|
To use a standalone kubectl, copy the kubeconfig out:
|
|
The default install is fine for “I want to run a few containers with Kubernetes-style manifests.” But for anything more involved, you almost always want to opt out of two defaults: ServiceLB (Klipper) and Traefik. Klipper conflicts with MetalLB, and Traefik installed via the bundled HelmChart CRD is harder to customize than a fresh install via Helm. Reinstall with the opt-outs:
|
|
Pin to a specific channel or version so you do not pick up a surprise upgrade later:
|
|
For air-gapped or bandwidth-limited environments, K3s supports a fully offline install via --airgap and a bundled image tarball. The official docs cover this; it is rarely needed at home unless your homelab is genuinely isolated.
Three-node HA with embedded etcd
When you outgrow single-node — usually because you want to take a node down for maintenance without taking your services down — you move to three server nodes with embedded etcd. K3s makes this straightforward but the failure modes are sharp, so read this section twice before running commands.
The architecture:
┌─────────────────────────┐
│ keepalived / DNS RR │ <- floating IP or
│ load balancer VIP │ DNS-based VIP
└────────┬────────────────┘
│ HTTPS :6443
┌────────────┼────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ server1 │ │ server2 │ │ server3 │
│ etcd │ │ etcd │ │ etcd │ <- quorum = 2
│ api │ │ api │ │ api │
│ kubelet │ │ kubelet │ │ kubelet │
└─────────┘ └─────────┘ └─────────┘
│ │ │
└────────────┼────────────┘
│
┌───────▼───────┐
│ Pod network │ (flannel/vxlan by default)
└───────────────┘
Three server nodes is the magic number. With one, you have no HA. With two, you cannot maintain etcd quorum if either fails. Five works but is overkill for a home. Quorum is (n/2)+1, so three tolerates one failure; five tolerates two.
The first node bootstraps the cluster with --cluster-init:
|
|
The other two nodes join the existing etcd cluster — note --server, not --cluster-init:
|
|
Critical: the network-related flags (--cluster-cidr, --service-cidr, --cluster-dns, --cluster-domain) must be identical on every server node. Setting them differently is the most common way to end up with a half-broken cluster that mostly works until it doesn’t. Use a /etc/rancher/k3s/config.yaml file on each node so you are reading from one source of truth:
|
|
For the API server’s floating address you have two reasonable options at home:
- DNS round-robin. Add three A records for
k3s.lanpointing at the three node IPs. Cheap, no extra software, but kubectl/clients hit dead nodes during failover. - VIP via keepalived or kube-vip. A virtual IP that automatically migrates between nodes when one fails. kube-vip running as a static pod on the control plane is the cleanest modern option.
For most homelabs DNS round-robin is fine — kubectl retries quickly enough that you will not notice a node being down for a minute. If you depend on the control plane API for production-like uptime (CI hitting it, GitOps controllers, etc.), use kube-vip.
A subtle gotcha: do not put your three K3s servers behind a Layer 4 TCP load balancer using a self-signed cert without putting that LB’s IP/hostname in --tls-san on every node. Otherwise certificate validation fails on the second node you join. The errors look like tls: failed to verify certificate: x509: certificate is valid for .... Always add every IP or hostname clients might use to --tls-san.
For more on networking choices upstream of the cluster, see Linux networking fundamentals and DNS deep dive.
MetalLB: getting real LoadBalancer IPs on bare metal
Out of the box, kubectl get svc on a cloud-managed cluster shows you a real external IP when you create a Service of type LoadBalancer. On bare metal, that IP stays <pending> forever because there is no cloud provider to allocate one. K3s’ built-in ServiceLB (Klipper) papers over this by using hostPort on each node, which is fine for very simple setups but conflicts with anything else trying to bind those ports and does not give you a single stable IP for a service.
MetalLB is the de facto answer. You need to install it after disabling ServiceLB (which you did above with --disable=servicelb).
|
|
Then carve out a slice of your LAN that DHCP does not touch and hand it to MetalLB. If your router serves 192.168.1.100–192.168.1.199, reserve 192.168.1.200–192.168.1.250 for the cluster:
|
|
Layer 2 mode works by having one node respond to ARP for the IP, and gratuitous ARPs trigger failover. It is simple, requires zero router configuration, and is correct for a flat home network. The downside is that traffic for any given Service IP only goes through one node at a time (no load distribution between nodes). For homelab traffic volumes this never matters.
If you have a router that speaks BGP (some Ubiquiti, OPNsense, MikroTik, pfSense with FRR), BGP mode distributes traffic across nodes and avoids the single-node bottleneck. Set it up if you are already running BGP for other reasons; do not introduce BGP for K3s alone.
Test it:
|
|
Hit 192.168.1.200 from any device on the LAN and you should see the whoami response.
Ingress: Traefik vs ingress-nginx
You only need MetalLB to expose individual services on dedicated IPs. For HTTP/HTTPS workloads — which is most homelab stuff — you put an ingress controller on a single MetalLB IP and route by hostname. Pick one ingress controller; running two is a recipe for confusion.
The two pragmatic options for K3s in 2026:
| Aspect | Traefik | ingress-nginx |
|---|---|---|
| K3s bundled | Yes (disable if you want Helm control) | No — install separately |
| Config style | CRDs (IngressRoute) + standard Ingress | Standard Ingress + extensive annotations |
| Middleware/auth | Rich first-party middleware | Annotations + ForwardAuth |
| Gateway API support | Yes (Traefik v3+) | Yes (separate ingress-nginx Gateway impl) |
| Docs/community | Excellent for K8s; popular in homelabs | Largest install base; Stack Overflow answers everything |
| Performance | Solid; fine for thousands of RPS | Slightly faster at the extreme tail |
Traefik is the natural choice if you also use it in Docker Compose elsewhere and like the configuration model — the Traefik complete guide on this blog covers it in depth and most of it transfers. ingress-nginx is the choice if you have an existing library of nginx-style annotations or want the option to copy/paste from the enormous online corpus.
A Traefik install via Helm so you can manage it as a real Helm release:
|
|
Or the equivalent ingress-nginx:
|
|
After either, a standard Ingress object routes by hostname:
|
|
Point your DNS — local Pi-hole, AdGuard Home, a *.lan.example.com wildcard A record on your real domain pointing to 192.168.1.201, whatever — and you have routed traffic.
A note on Gateway API: it is the long-term successor to Ingress and is GA in Kubernetes 1.32+. For a homelab today, Ingress is still simpler and better-documented for the controllers you will use. Adopt Gateway API when you have a specific reason to (cross-namespace routes, advanced traffic splitting). Do not adopt it just to be modern.
cert-manager and DNS-01 with Cloudflare
You can run plain HTTP on *.lan forever, but the moment you want to call your services from a browser that whines about mixed content, or you want to expose anything beyond LAN, you need TLS. The clean answer is cert-manager + Let’s Encrypt via the DNS-01 challenge. DNS-01 works for services not exposed to the internet, supports wildcard certificates, and does not require port 80 open inbound.
|
|
Create an API token in Cloudflare with Zone:Read and DNS:Edit permissions for the zone you want certificates for. Then:
|
|
Always test with the Let’s Encrypt staging endpoint first — https://acme-staging-v02.api.letsencrypt.org/directory — because production has rate limits that are easy to hit if your config is wrong. Once staging works, swap to prod.
Issuing a wildcard:
|
|
Reference the resulting secret from any Ingress and your *.lan.example.com services have real publicly-trusted TLS, even though they only resolve to RFC1918 addresses. That solves the mixed-content problem and lets you use the same hostname format internally and externally.
The flow looks like this:
┌──────────────┐ 1. create Cert ┌──────────────┐
│ You apply ├──────────────────►│ cert-manager │
│ Certificate │ │ controller │
└──────────────┘ └───────┬──────┘
│ 2. ACME order
▼
┌──────────────┐
│ Let's Encrypt│
└───────┬──────┘
│ 3. DNS-01 challenge
▼
┌──────────────┐ 4. write TXT ┌──────────────┐
│ Cloudflare ├◄────────────────┤ cert-manager │
│ DNS │ │ │
└──────┬───────┘ └──────────────┘
│ 5. LE verifies TXT record
▼
┌──────────────┐
│ Let's Encrypt│ 6. issues certificate
└──────┬───────┘
│
▼
┌──────────────┐
│ Secret in │
│ K8s store │
└──────────────┘
For more on TLS itself, see HTTPS and TLS explained.
Persistent storage: Longhorn vs the alternatives
The real test of a homelab Kubernetes cluster is whether you can run a Postgres pod, kill the node it lives on, and have the data come back somewhere else. Single-node clusters get away with the default local-path provisioner, which writes to a directory on the host. Multi-node clusters need real distributed storage or your stateful workloads will pin to a single node and die with it.
The three serious options for self-hosted Kubernetes block storage in 2026:
| Storage | Complexity | Footprint per node | Performance | Strength |
|---|---|---|---|---|
| local-path | Trivial | None | Native disk | Default, single-node only, no replication |
| Longhorn | Low | ~500 MB RAM + replicas | Decent, not stellar | UI, snapshots, backups to S3, friendly defaults |
| OpenEBS Mayastor | Medium | ~1 GB RAM, NVMe required | Excellent | NVMe-oF, very fast, but hardware-picky |
| Rook-Ceph | High | 2+ GB RAM per node | Excellent at scale | Block + object + file, battle-tested, but heavy for homelabs |
| NFS (CSI) | Trivial | None on the cluster | OK | Easy ReadWriteMany via an existing NAS |
For most homelabs, Longhorn is the sweet spot. It idles at around 500 MB RAM per node but gives you replication, a usable UI, snapshots, and backups to S3-compatible storage like MinIO or Backblaze B2 (see backup strategy for the broader picture).
Install:
|
|
The replicaCount=3 matches a three-node cluster. On a two-node cluster set it to 2; on single-node use 1 (and ask yourself why you are using Longhorn at all). Longhorn’s UI:
|
|
A few hard-earned lessons:
- Don’t put your busiest database on Longhorn. Fine for Gitea, Nextcloud, Home Assistant. Not fine for high-write Postgres or workloads doing constant small synchronous writes — replication amplifies them and node-to-node latency adds up. Use local-path with a node-affinity for those, or look at Mayastor.
- ReadWriteOnce is the default and the common case. Longhorn supports RWX but does so via an internal NFS share that is slow and a single point of failure. For RWX, mounting an existing NAS through the NFS CSI driver is often a better answer.
- Back up Longhorn volumes off-cluster. Set up a
BackupTargetpointing at MinIO or B2 and aRecurringJobfor snapshots. The day youkubectl delete nsthe wrong namespace, you will thank yourself.
A working PVC:
|
|
Deploying actual workloads
You now have a cluster, a load balancer, an ingress controller, TLS, and storage. The fun starts. A complete deployment of Gitea on K3s, pulling everything together:
|
|
Apply with kubectl apply -f gitea.yaml. Within a minute or two you have Gitea on https://gitea.lan.example.com with a real cert, persistent storage that survives pod restarts, and replicated across nodes if you set Longhorn replicas accordingly. SSH access for git push is not solved by this manifest — you would either expose port 22 via a second Service of type LoadBalancer with a dedicated MetalLB IP, or set Gitea to use a high port and update clones to use it.
Migrating from Docker Compose
If you are coming from a Compose-based homelab — and most people are — you are looking at a stack of docker-compose.yml files and wondering how to translate them. Two honest options.
Option 1: Kompose, then refine
kompose is an official Kubernetes project that converts Compose files to Kubernetes manifests. It is a starting point, not an answer.
|
|
The output gets you 70% of the way. The remaining 30% is what makes the difference between something that boots and something you want running:
- Resource requests and limits. Compose has no concept of these; Kubernetes really wants them. Without
requests, the scheduler cannot place pods sensibly and you get noisy-neighbor problems. - Liveness and readiness probes. Compose
healthchecktranslates poorly. Rewrite as proper probes that match what your app actually exposes. - Secrets out of env vars. Compose
.envfiles become KubernetesSecretobjects, mounted as env or files. Do not commit them. - Network mode and
network_mode: host. Kubernetes does not have a clean equivalent. Reconsider whether you actually need it; usually you do not. - Bind mounts. Compose’s
./data:/databecomes aPersistentVolumeClaim. Choose the right storage class. depends_onordering. Kubernetes does not honor startup order between Deployments. Use init containers or readiness probes to wait for dependencies.
A common pattern: convert with Kompose, then by hand merge things into Helm charts or a Kustomize overlay so you can deploy your whole stack with one command.
Option 2: Rewrite by hand, one service at a time
For a small homelab this is faster than fighting Kompose output. Migrate services in order of statelessness — your reverse proxy first, then stateless web apps, then databases last. Keep Compose running for un-migrated services and route between them with normal DNS.
A worked translation:
|
|
Becomes:
|
|
The translation is mostly mechanical but you have to make decisions Compose let you avoid: how many replicas, how much CPU and RAM, what your readiness signal is, what storage class to use. Those decisions are the ones that matter for production-grade self-hosting.
Once your stack is in YAML, your next move is usually to put it in git and let a controller deploy it. ArgoCD is the standard answer — see ArgoCD GitOps in production for setup.
Automated upgrades that don’t brick the cluster
K3s has a system-upgrade-controller that watches a release channel and rolls upgrades across nodes one at a time, draining each before upgrade and re-enabling it after. It is the right way to upgrade a multi-node cluster.
|
|
Then define two Plan objects — one for servers, one for agents — pinned to a channel:
|
|
concurrency: 1 means only one node at a time. On a three-node cluster this guarantees you keep quorum. The prepare step on the agent plan makes agents wait until servers are done. The channel URL controls what version it tracks; pinning to v1.34 keeps you on the 1.34 minor and picks up patch releases automatically. Do not pin to stable unless you genuinely want surprise minor upgrades.
The cardinal rule of Kubernetes upgrades: never skip minor versions. Going from 1.32 to 1.34 means doing 1.32 → 1.33 → 1.34, not 1.32 → 1.34. The kubelet/control-plane skew policy permits one minor of skew during a rolling upgrade, and skipping breaks things in subtle ways. The system-upgrade-controller respects this if you point it at the right channels in sequence.
Before any upgrade:
|
|
That snapshot is your “oh no” button. To restore:
|
|
Test the restore procedure once on a throwaway cluster before you need it for real.
Observability: where things go wrong
Kubernetes is observable in theory and opaque in practice. The minimum: install the kube-prometheus-stack Helm chart (Prometheus + Alertmanager + Grafana + sane dashboards in one shot), and Loki + Promtail for centralized logs. The Prometheus + Grafana stack and monitoring and observability posts cover setup in detail. Install observability before you need it — debugging with kubectl logs and kubectl describe alone gets miserable past a handful of services.
A few alerts worth firing from day one: any node NotReady for >5 min, any pod in CrashLoopBackOff for >5 min, etcd leader election thrashing, Longhorn volume Degraded, and any cert-manager certificate expiring in under 14 days.
Security: don’t run home like prod, but don’t be reckless
Cheap fixes worth making:
- Run pods as non-root. Set
runAsNonRoot: trueandrunAsUser: 1000in pod specs. Most well-maintained images support it. - NetworkPolicies. K3s’ default
flannelCNI does not enforce them. If you want real namespace isolation, swap to Calico or Cilium at install (--flannel-backend=none). For most homelabs this is overkill. - Don’t expose the API server. Use Tailscale or WireGuard for remote kubectl, not port-forwarded 6443.
- Image scanning. Trivy or Grype on a cron will surface known CVEs in deployed images.
- Secrets at rest. K3s stores
Secretobjects in base64, not encrypted. Enable--secrets-encryptionat install for AES-CBC encryption in the datastore.
For broader container security, see container security.
When K3s is the wrong answer
K3s is great for “Kubernetes on whatever boxes I have.” It is less great for:
- Immutable infrastructure. If you want the host OS to be uncustomizable and entirely declarative, Talos Linux is a better fit — you give up
apt installand SSH but make config drift effectively impossible. - Single-node Docker replacement only. One server, no multi-node plans, just containers with HTTPS? You do not need Kubernetes. Compose + Traefik does this with a fraction of the complexity (Traefik complete guide).
- Pure CKA-style learning. K3s’ opinionated defaults hide concepts that exist in upstream K8s. For exam prep or production transferability, use kubeadm or Talos.
- GPU-heavy AI workloads. K3s works with NVIDIA’s device plugin, but orchestration around GPU sharing, time-slicing, and MIG is more of an afterthought. For serious AI work see self-hosted AI inference and consider whether non-Kubernetes serves you better.
A decision tree for what to install
┌─────────────────────────────┐
│ Do you have 2+ nodes you │
│ want as one logical cluster?│
└──────────────┬──────────────┘
no │ │ yes
┌──────────────┘ └──────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────────┐
│ Compose + Traefik │ │ Need HA control plane? │
│ (or 1-node K3s) │ └─────────┬──────────────┘
└────────────────────┘ no │ │ yes
┌─────────────┘ └────────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────────┐
│ Single-node K3s │ │ 3 servers + embedded │
│ + local-path │ │ etcd + kube-vip │
└────────────────────┘ └─────────┬──────────────┘
│
┌──────────▼─────────┐
│ Multi-node storage:│
│ Longhorn (general) │
│ NFS-CSI (RWX) │
│ Mayastor (perf) │
└────────────────────┘
A realistic 90-day plan
If you are starting fresh and want a path that won’t blow up:
Week 1. Install single-node K3s on one box. Disable Traefik and ServiceLB. Install MetalLB, install Traefik via Helm, install cert-manager. Issue your first wildcard cert. Deploy one service end-to-end with HTTPS.
Weeks 2–4. Migrate three or four stateless services from Compose. Get comfortable with kubectl, namespaces, configmaps, secrets. Set up the kube-prometheus-stack and Loki.
Weeks 5–8. Add Longhorn. Migrate a stateful service. Set up automated backups to MinIO or B2. Practice a restore at least once on a throwaway PVC.
Weeks 9–12. Add two more nodes and convert to HA with embedded etcd. Install the system-upgrade-controller. Set up ArgoCD and put all your manifests in git. Now you have a real homelab platform.
Skip the “I’ll set up everything at once” plan. Every step above is something you will get wrong the first time, and you want to be wrong about one thing per week, not eight things on the same Saturday. The cluster you can build in 90 patient days is the cluster that will still be running in 90 weeks.
Comments