Why I Moved the Homelab to Kubernetes

— homelab, kubernetes, k3s, networking, pihole

Part of project(s): Homelab

To be fair, not one single digital implementations require Kubernetes for 11 containers. Why am I doing this? Why not.

The Docker setup from the last post worked. It ran for weeks. It never dramatically exploded. But, I found that jellyfin containers sometimes would crash especially when running H265/HEVC playbacks.

Normally, I would just SSH into piloma using Termius and then restart the containers. As no humans are born a saint, my patience can’t handle it anymore.

What actually went wrong

Nothing bounded memory

alomalab has 1.5GB of RAM. Here it is right now, and this is the healthy state:

free -h
               total        used        free      shared  buff/cache   available
Mem:           1.5Gi       880Mi       137Mi       384Ki       701Mi       676Mi
Swap:          2.0Gi       250Mi       1.8Gi

In short, every container is competing with the last remaining available free memory and kernel OOM killer as the tiebreaker. The OOM killer picks by a heuristic score, not by what you care about, so the thing that dies is frequently not the thing that misbehaved.

And when swap gets involved on a Celeron with a spinning-rust-speed SSD, the machine doesn’t crash, it just becomes unusable for a few minutes, which is harder to diagnose than a crash.

The honest framing: Docker Compose does support memory limits. I could have set them. But limits are only half of it — the other half is a scheduler that knows how much RAM each machine has left and refuses to place work that won’t fit. Compose has no concept of that, because Compose has no concept of the other machine.

restart: unless-stopped is not high availability

Every compose file had restart: unless-stopped, which I had quietly filed under “self-healing”. It isn’t. It restarts a container on the same host when the process exits. That covers exactly one failure mode: the process crashed.

It does nothing for:

There’s no health check driving restarts, no notion of “ready” versus “running”, and no possibility of “this workload can’t run here, run it somewhere else”, because there is no somewhere else in Compose’s world.

Two machines, two sources of truth

It’s convenient that Portainer lets me view both hosts container. But, the state still lived in a dozen YAML files across two filesystems, edited in place over SSH, with no history.

I could not answer basic questions:

For someone who moved a whole website to Terraform specifically to avoid this feeling, the irony was getting hard to ignore. I had declarative infrastructure in the cloud and hand-clicked infrastructure in my own house.

Ports were a registry in my head

Cockpit took :9090, so Prometheus moved to :9091. Grafana wanted :3000, so did Homepage, so Grafana became :3001. Every one of those decisions was correct and every one of them existed nowhere except in my head and in the compose file that happened to encode it.

Add a twelfth service and the first question is “which ports are free?”, and the only way to answer it is to go read every compose file on two machines.

Why k3s specifically

Kubernetes solves precisely the five things above: it has a scheduler that knows what each node has, real service discovery by name, health checks that drive restarts, one declarative source of truth for the whole cluster, and service ports that are cluster-internal so collisions stop mattering.

The problem is that Kubernetes is also famously enormous, and I have 3.5GB of RAM across two machines.

k3s is Rancher’s distribution that strips it down: a single binary under 100MB, etcd swapped for SQLite by default, and a pile of cloud provider integrations removed. It’s a certified Kubernetes distribution, not a toy — it’s the same API, just packaged for machines like mine.

Which node is the control plane

Counter-intuitively, the Pi.

piloma (Pi 5)alomalab (laptop)
RAM2.0Gi1.5Gi
Cores42
NetworkEthernet-capable, stableWiFi
Uptime disciplineNever touchedClose the lid

The control plane is the component that must never go away — lose it and the cluster keeps serving traffic but you can’t change anything or reschedule anything. The Pi has more RAM, twice the cores, no lid, and no moving parts. The laptop is the one with a battery, a WiFi link and a human who occasionally tidies the shelf it lives on.

So: piloma is the control plane, alomalab is the worker.

Local DNS in Pi-hole

Before installing anything, the naming problem needs solving, because “type the IP address into every config” is what I’m trying to escape.

Using Pi-Hole as a Local DNS Server, I can escape this numbers nightmare. In Settings → Local DNS → DNS Records, map names to node IPs:

alomalab.internal    192.168.0.10
piloma.internal      192.168.0.14

And in Local DNS → CNAME Records, point the per-service names at those:

main.alomalab.internal      → alomalab.internal
grafana.alomalab.internal   → alomalab.internal
prom.alomalab.internal      → alomalab.internal
jellyfin.piloma.internal    → piloma.internal

A note on why it’s shaped this way.

Use .internal, not .local. .local is claimed by mDNS/Avahi and you will get intermittent, maddening resolution failures where it works on one device and not another. .internal was formally reserved for private use, so nothing on the public internet will ever claim it.

Installing k3s

On piloma, the control plane:

curl -sfL https://get.k3s.io | sh -s - server \
  --disable local-storage \
  --flannel-backend=host-gw \
  --write-kubeconfig-mode 0644

Breaking that down, because every flag here is a real decision:

The host-gw thing

Pods get their own IP addresses on their own network (10.42.x.x here), which must work across machines: a pod on alomalab has to reach a pod on piloma.

The default answer, VXLAN, wraps every pod packet inside another UDP packet, ships it to the other node, and unwraps it. That works over any network topology, including nodes in different subnets or different data centres, and it costs you CPU on both ends plus about 50 bytes of overhead per packet.

host-gw does the obvious cheaper thing instead: it just adds a route on each node saying “the 10.42.1.0/24 pod subnet lives at 192.168.0.10, send it there”. No encapsulation. Packets go out as plain IP.

The catch is that this only works if the nodes are on the same layer-2 network, because you’re relying on them being able to hand each other packets directly. Both of mine are on the same 192.168.0.0/24 LAN, so that holds. On a Celeron where CPU is the scarcest thing I own, skipping encapsulation on every single packet is a real saving.

In short, pod traffic is unencryted locally and using Local Lan setup is fine for this specific case only. Otherwise, you will be exiled.

Joining the worker

Grab the node token from the control plane:

sudo cat /var/lib/rancher/k3s/server/node-token

Then on worker:

curl -sfL https://get.k3s.io | K3S_URL=https://192.168.0.14:6443 \
  K3S_TOKEN=<token> sh -

Setting K3S_URL is what makes the installer set up an agent instead of a server. Treat that token like a root password for the cluster anyone holding it can join a node and run workloads.

It’s up

[!tips] Set k as kubectl as kubectl is so unergonomic to write

alias k='kubectl'
k get nodes -o wide

You should see the registered nodes

NAME       STATUS   ROLES           AGE    VERSION        INTERNAL-IP    OS-IMAGE              KERNEL-VERSION                 CONTAINER-RUNTIME
alomalab   Ready    <none>          2d2h   v1.36.3+k3s1   192.168.0.10   Ubuntu 26.04 LTS      7.0.0-29-generic (amd64)       containerd://2.3.2-k3s2
piloma     Ready    control-plane   2d3h   v1.36.3+k3s1   192.168.0.14   Debian GNU/Linux 13   6.18.39+rpt-rpi-2712 (arm64)   containerd://2.3.2-k3s2

Two nodes, two architectures, one API.

kubectl label node alomalab node-role.kubernetes.io/worker=true

The cost of the control plane on the Pi:

               total        used        free      shared  buff/cache   available
Mem:           2.0Gi       1.3Gi       171Mi       116Mi       646Mi       633Mi
Swap:          2.0Gi       115Mi       1.9Gi

That left me with 171MB of memory to be integrated with other services.

Nothing is deployed yet. In the next post I put all the services back, and find out which of my assumptions about container images survive contact with two CPU architectures.

Setting Up Kubernetes Dashboard

What’s the purpose of a program if there’s no dashboard to follow it through.

Before that though, kubectl get pods one namespace at a time gets old fast, and I wanted something to glance at from my phone without SSHing into piloma. I followed this guide to get the official Kubernetes Dashboard running.

1. Create the yml

The dashboard ships as a single recommended manifest, so there’s no need to hand-write anything:

kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.7.0/aio/deploy/recommended.yaml

That creates a kubernetes-dashboard namespace and drops in the deployment, service, and RBAC objects it needs. Applying it from piloma means it lands wherever the scheduler decides, not necessarily on piloma itself.

2. Export piloma’s kubeconfig to the remote client

kubectl on piloma works out of the box because k3s writes /etc/rancher/k3s/k3s.yaml locally, and I’d already made it world-readable with --write-kubeconfig-mode 0644 when installing. To drive the dashboard (and the cluster generally) from my actual laptop instead of an SSH session, that file needs to travel:

scp piloma.internal:/etc/rancher/k3s/k3s.yaml ~/.kube/config

The one catch: k3s bakes server: https://127.0.0.1:6443 into that file, which is correct only on piloma itself. Off the node, that line needs to point at piloma’s actual address, so I edited it to:

server: https://piloma.internal:6443

using the .internal name I’d already set up in Pi-hole, rather than hardcoding the IP again.

3. kubectl proxy

With a working kubeconfig, the dashboard’s service is reachable through the API server’s built-in proxy.

kubectl proxy

Then the dashboard is at:

http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/

kubectl proxy authenticates using the kubeconfig from step 2, so whatever RBAC permissions that identity has are the permissions the dashboard session gets.

4. Create an admin user and grab its token

A ServiceAccount with a ClusterRoleBinding to cluster-admin gets a token with full access to the cluster:

# dashboard-admin.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: admin-user
  namespace: kubernetes-dashboard
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: admin-user
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: admin-user
    namespace: kubernetes-dashboard
kubectl apply -f dashboard-admin.yaml

Then mint a token for it:

kubectl -n kubernetes-dashboard create token admin-user

Use the output as the Bearer Token for the Dashboard.

Next up, migrating from the Docker Compose to Kubernetes-Based Deployments