Migrating My Homelab from Docker Compose to K3s
Docker Compose still ran my homelab, but it no longer described it very well. Services were spread across old directories, routing lived in Traefik labels and dynamic files, and state was mounted wherever it had made sense at the time.
I wanted Git to describe what was running, Flux to correct drift, SOPS to keep secrets encrypted, and backups to be part of the migration rather than a promise to deal with later.
Keep the Old Edge
My first useful decision was to start with one K3s node and leave Docker Traefik in front:
Internet
-> Docker Traefik
-> K3s Traefik on a NodePort
-> Service
-> Pod pinned to the original machine
That made one application and one route the unit of migration. Everything else could stay in Compose.
I tested the path with whoami, then moved Phase10, Coin, and Via. Those first sites used locally built, node-pinned images. They proved the manifests, routing, and rollback pattern; registry-backed image pipelines came later.
Move by Risk
Stateless sites went first. Small SQLite services came next, but only after I had a backup and restore path. Media and hardware-bound services waited until the basic pattern was boring.
The repository settled into two layers:
k8s/apps/<group>/<app>/
deployment.yaml
service.yaml
httproute.yaml
kustomization.yaml
k8s/clusters/home/apps/<group>/<app>/
kustomization.yaml
The first layer describes the workload. The second opts the home cluster into it. Flux watches the cluster tree, decrypts SOPS-managed secrets, and applies the result. Renovate can update an image digest in a merge request instead of changing a live Compose file over SSH.
Keeping the old host paths helped with the move, but it was not a backup strategy. The backup timer stages application data on the source node, uses sqlite3 .backup for live databases, and copies snapshots off that node. A stateful service was not finished until I knew what to restore and had seen the backup run.
The Workloads That Mattered
qBittorrent and Gluetun had to share a network namespace so the torrent client could not fall back to the home connection when the VPN failed. In K3s they became two containers in one Pod. The acceptance test checked the observed egress address, not just whether Kubernetes called the Pod ready.
Z-Wave JS UI and Frigate stayed pinned to the machines with their USB device and GPU. Kubernetes did not make the hardware portable; it made the dependency visible in the manifest.
Move the Edge Last
Once the applications were already behind K3s, the public edge could move separately. I suspended Flux for the ownership handoff, stopped Docker Traefik only when the replacement was ready, and kept the old Compose edge as the rollback.
The final edge uses Gateway API, cert-manager, and a stable ingress address. After the public and Tailscale routes matched their previous behavior, I removed the temporary Docker-to-K3s bridge.
K3s did not give my stateful services high availability. Most still have one copy of their data and some remain tied to particular hardware. What it gave me was convergence: Git describes the workloads, Flux reports drift, image updates are reviewable, and a stateless service can move nodes without being reconstructed from memory.
Compose was a good way to start each service. K3s became a better way to operate the collection.