← back

Migrating My Homelab from Docker Compose to K3s

//Homelab/Kubernetes, K3s, Docker, Flux, GitOps, SOPS, Homelab

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.

Staged path from Docker Compose to a Flux-managed K3s cluster

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.