just show up I turned three Framework Desktops into a Kubernetes clusterI had three Framework Desktops sitting on my desk doing nothing. Ryzen AI Max 300 APUs, 128 GB of unified memory each, a Micron 7450 960GB NVMe in each one dedicated to storage. Way overpowered for a homelab cluster. I wanted a Kubernetes setup I could tear down and rebuild in under 20 minutes, with no cloud, no VMs, just bare metal Fedora and Ansible. That’s what I built. The machinesThree identical Framework Desktops. Small, quiet, ridiculous specs for what most people use homelabs for. Each one runs as a K3s server (control plane and worker on every node). At this scale, splitting into dedicated control plane and worker nodes just wastes capacity. Three servers gives me an etcd quorum, so I can lose a node and keep running. Every node runs workloads. Ripping out the k3s defaultsK3s ships with Klipper for service load balancing and a bundled Traefik for ingress. I disabled both on day one.
Networkingkube-vipThe API server needs a stable address that doesn’t follow a single node. I picked kube-vip in ARP/L2 mode because it runs inside the cluster as a DaemonSet. No extra machine, no external load balancer, no DNS tricks. The manifests get templated into I considered HAProxy + keepalived but that would’ve meant a fourth machine or running it on the nodes outside of Kubernetes. Didn’t want that. MetalLBKlipper pins each LoadBalancer service to a single node. If that node reboots (which mine do, regularly, for firmware and kernel updates) the service IP disappears until it comes back. MetalLB in L2 mode advertises service IPs via ARP and fails over to another speaker within seconds. The VIP and the MetalLB pool live on the same subnet but don’t overlap:
No overlap, no conflicts, easy to debug. Traefik (self-managed)The bundled Traefik upgrades automatically whenever you upgrade k3s. That’s fine until you need to pin a version or customize the Helm values. I deploy my own via One commandThe entire deployment is Four playbooks, executed in order. The The init playbook targets only the first node. kube-vip manifests go down, then k3s installs with After all three nodes are up, one playbook deploys MetalLB, Longhorn, Traefik, and Argo CD via Helm from the init node. MetalLB needs its webhook ready before the IPAddressPool CRD can be applied, so there’s a wait built in. Longhorn deploys with a replica count of 3, giving every volume a copy on each node’s dedicated NVMe. The AMD VRAM thingThis is the part that’s specific to the hardware. The Ryzen AI Max APUs have unified memory, with no separate VRAM. The kernel allocates a chunk of system RAM as GTT memory for the GPU, controlled by TTM kernel parameters. By default the allocation is modest. Jeff Geerling’s writeup on increasing VRAM on AMD AI APUs pointed me in the right direction. I wanted 96 GB available for GPU workloads, so the The math: After reboot, a verification task checks Setting Rolling OS updatesThe part I’m most glad I automated. The Before touching a node, the playbook checks that etcd is healthy and all Longhorn volumes are fully replicated. If the cluster is already degraded, it stops. After each node comes back, it waits for the same conditions before moving to the next one. With a 3-node etcd cluster, you can’t afford to take a second node down while the first is still catching up. The playbook delegates all kubectl commands to a different node in the cluster. It picks a healthy peer so it can still talk to the API while the current node is down and rebooting. One thing I ran into: Longhorn’s conversion webhook can block the uncordon if the webhook pod was on the node that just rebooted and hasn’t rescheduled yet. The playbook handles this by restarting the webhook deployment and retrying. Those were some stressful minutes the first time it happened. The webhook recreates itself once Longhorn’s manager pod comes back, so it works out. The whole cordon-through-postchecks sequence runs inside an Ansible What I’d changeI’d configure the AMD VRAM allocation earlier. I left it at defaults initially and didn’t realize the GTT allocation was the bottleneck until GPU workloads were underperforming. The kernel parameter names weren’t obvious either. I’d also think harder about the MetalLB IP range. Twenty addresses seemed generous for a homelab, but services pile up faster than you’d expect once Argo CD is deploying things. That’s itThree Framework Desktops. K3s. Ansible all the way down. Eight roles, five playbooks, one I can wipe a node, reinstall Fedora, and bring it back into the cluster by running I’m pretty happy with it.
- luzkenin - @ 04-07-2026, 7:59 PM « Back |