LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Kubernetes for the Homelab: K3s from Scratch to Production

kubernetesk3shomelabself-hostinglonghornmetallbtraefikcert-managergitopsdocker-compose

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:

1
2
3
4
5
6
7
sudo swapoff -a
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab

sudo sysctl -w net.ipv4.ip_forward=1
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-k3s.conf

sudo timedatectl set-ntp true

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:

1
curl -sfL https://get.k3s.io | sh -

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:

1
2
3
sudo k3s kubectl get nodes
NAME      STATUS   ROLES                  AGE   VERSION
denali    Ready    control-plane,master   28s   v1.34.3+k3s1

To use a standalone kubectl, copy the kubeconfig out:

1
2
sudo cat /etc/rancher/k3s/k3s.yaml | sed "s/127.0.0.1/$(hostname -I | awk '{print $1}')/" > ~/.kube/config
chmod 600 ~/.kube/config

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:

1
2
3
4
5
6
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server \
  --disable=traefik \
  --disable=servicelb \
  --write-kubeconfig-mode=644 \
  --tls-san=$(hostname -I | awk '{print $1}') \
  --tls-san=k3s.lan" sh -

Pin to a specific channel or version so you do not pick up a surprise upgrade later:

1
curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=v1.34 sh -

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
TOKEN="$(openssl rand -hex 32)"
echo "$TOKEN" > /root/k3s-token

curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server \
  --cluster-init \
  --token=$TOKEN \
  --disable=traefik \
  --disable=servicelb \
  --tls-san=k3s.lan \
  --tls-san=192.168.1.50" sh -

The other two nodes join the existing etcd cluster — note --server, not --cluster-init:

1
2
3
4
5
6
7
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server \
  --server https://192.168.1.51:6443 \
  --token=<token-from-node1> \
  --disable=traefik \
  --disable=servicelb \
  --tls-san=k3s.lan \
  --tls-san=192.168.1.50" sh -

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cluster-cidr: 10.42.0.0/16
service-cidr: 10.43.0.0/16
cluster-dns: 10.43.0.10
cluster-domain: cluster.local
disable:
  - traefik
  - servicelb
tls-san:
  - k3s.lan
  - 192.168.1.50

For the API server’s floating address you have two reasonable options at home:

  1. DNS round-robin. Add three A records for k3s.lan pointing at the three node IPs. Cheap, no extra software, but kubectl/clients hit dead nodes during failover.
  2. 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).

1
2
3
helm repo add metallb https://metallb.github.io/metallb
helm repo update
helm install metallb metallb/metallb -n metallb-system --create-namespace

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: homelab-pool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.1.200-192.168.1.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: homelab-l2
  namespace: metallb-system
spec:
  ipAddressPools:
    - homelab-pool

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:

1
2
3
4
5
6
kubectl create deployment whoami --image=traefik/whoami --port=80
kubectl expose deployment whoami --port=80 --type=LoadBalancer

kubectl get svc whoami
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)        AGE
whoami   LoadBalancer   10.43.197.221   192.168.1.200   80:32486/TCP   8s

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
helm repo add traefik https://traefik.github.io/charts
helm repo update
kubectl create namespace traefik

helm install traefik traefik/traefik -n traefik \
  --set service.type=LoadBalancer \
  --set service.spec.loadBalancerIP=192.168.1.201 \
  --set ports.web.redirectTo.port=websecure \
  --set ingressClass.enabled=true \
  --set ingressClass.isDefaultClass=true

Or the equivalent ingress-nginx:

1
2
3
4
5
6
7
8
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

helm install ingress-nginx ingress-nginx/ingress-nginx \
  -n ingress-nginx --create-namespace \
  --set controller.service.type=LoadBalancer \
  --set controller.service.loadBalancerIP=192.168.1.201 \
  --set controller.ingressClassResource.default=true

After either, a standard Ingress object routes by hostname:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: whoami
  namespace: default
spec:
  rules:
    - host: whoami.lan.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: whoami
                port:
                  number: 80

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.

1
2
3
4
5
6
helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --set crds.enabled=true

Create an API token in Cloudflare with Zone:Read and DNS:Edit permissions for the zone you want certificates for. Then:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: v1
kind: Secret
metadata:
  name: cloudflare-api-token
  namespace: cert-manager
type: Opaque
stringData:
  api-token: REDACTED_CLOUDFLARE_TOKEN
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: you@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
        selector:
          dnsZones:
            - example.com

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: lan-wildcard
  namespace: traefik
spec:
  secretName: lan-wildcard-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  commonName: "*.lan.example.com"
  dnsNames:
    - "*.lan.example.com"
    - "lan.example.com"

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# nodes need open-iscsi installed first
sudo apt-get install -y open-iscsi nfs-common

helm repo add longhorn https://charts.longhorn.io
helm repo update

kubectl create namespace longhorn-system

helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --set defaultSettings.defaultDataPath="/var/lib/longhorn" \
  --set persistence.defaultClassReplicaCount=3

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:

1
kubectl -n longhorn-system port-forward svc/longhorn-frontend 8080:80

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 BackupTarget pointing at MinIO or B2 and a RecurringJob for snapshots. The day you kubectl delete ns the wrong namespace, you will thank yourself.

A working PVC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: gitea-data
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 20Gi

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
apiVersion: v1
kind: Namespace
metadata:
  name: gitea
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: gitea-data
  namespace: gitea
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: longhorn
  resources:
    requests:
      storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gitea
  namespace: gitea
spec:
  replicas: 1
  selector:
    matchLabels: { app: gitea }
  template:
    metadata:
      labels: { app: gitea }
    spec:
      containers:
        - name: gitea
          image: gitea/gitea:1.23
          ports:
            - { name: http, containerPort: 3000 }
            - { name: ssh,  containerPort: 22 }
          env:
            - { name: USER_UID, value: "1000" }
            - { name: USER_GID, value: "1000" }
          volumeMounts:
            - name: data
              mountPath: /data
          readinessProbe:
            httpGet: { path: /, port: http }
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet: { path: /, port: http }
            initialDelaySeconds: 120
            periodSeconds: 30
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: gitea-data
---
apiVersion: v1
kind: Service
metadata:
  name: gitea
  namespace: gitea
spec:
  selector: { app: gitea }
  ports:
    - { name: http, port: 80, targetPort: http }
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: gitea
  namespace: gitea
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts: [gitea.lan.example.com]
      secretName: gitea-tls
  rules:
    - host: gitea.lan.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: gitea
                port: { number: 80 }

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.

1
2
3
4
5
sudo apt-get install kompose

kompose convert -f docker-compose.yml -o ./k8s/
ls ./k8s/
# gitea-deployment.yaml  gitea-service.yaml  gitea-data-persistentvolumeclaim.yaml ...

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 healthcheck translates poorly. Rewrite as proper probes that match what your app actually exposes.
  • Secrets out of env vars. Compose .env files become Kubernetes Secret objects, 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:/data becomes a PersistentVolumeClaim. Choose the right storage class.
  • depends_on ordering. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# docker-compose.yml
services:
  app:
    image: ghcr.io/me/app:1.4
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/app
    ports:
      - "8080:8080"
    volumes:
      - ./uploads:/data/uploads
    depends_on:
      - db
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - db:/var/lib/postgresql/data
volumes:
  db:

Becomes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
apiVersion: v1
kind: Secret
metadata: { name: app-secrets, namespace: myapp }
type: Opaque
stringData:
  database-url: postgres://app:secret@db:5432/app
  postgres-password: secret
---
apiVersion: v1
kind: Service
metadata: { name: db, namespace: myapp }
spec:
  selector: { app: db }
  ports: [{ port: 5432, targetPort: 5432 }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: db, namespace: myapp }
spec:
  serviceName: db
  replicas: 1
  selector: { matchLabels: { app: db } }
  template:
    metadata: { labels: { app: db } }
    spec:
      containers:
        - name: db
          image: postgres:16
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef: { name: app-secrets, key: postgres-password }
          volumeMounts:
            - { name: data, mountPath: /var/lib/postgresql/data }
  volumeClaimTemplates:
    - metadata: { name: data }
      spec:
        accessModes: [ReadWriteOnce]
        storageClassName: longhorn
        resources: { requests: { storage: 20Gi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: app, namespace: myapp }
spec:
  replicas: 2
  selector: { matchLabels: { app: app } }
  template:
    metadata: { labels: { app: app } }
    spec:
      containers:
        - name: app
          image: ghcr.io/me/app:1.4
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef: { name: app-secrets, key: database-url }
          ports: [{ containerPort: 8080 }]
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
          resources:
            requests: { cpu: 100m, memory: 128Mi }
            limits:   { cpu: 500m, memory: 512Mi }

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.

1
kubectl apply -f https://github.com/rancher/system-upgrade-controller/releases/latest/download/system-upgrade-controller.yaml

Then define two Plan objects — one for servers, one for agents — pinned to a channel:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
  name: server-plan
  namespace: system-upgrade
spec:
  concurrency: 1
  cordon: true
  nodeSelector:
    matchExpressions:
      - { key: node-role.kubernetes.io/control-plane, operator: Exists }
  serviceAccountName: system-upgrade
  channel: https://update.k3s.io/v1-release/channels/v1.34
  upgrade:
    image: rancher/k3s-upgrade
---
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
  name: agent-plan
  namespace: system-upgrade
spec:
  concurrency: 1
  cordon: true
  nodeSelector:
    matchExpressions:
      - { key: node-role.kubernetes.io/control-plane, operator: DoesNotExist }
  prepare:
    args: [prepare, server-plan]
    image: rancher/k3s-upgrade
  serviceAccountName: system-upgrade
  channel: https://update.k3s.io/v1-release/channels/v1.34
  upgrade:
    image: rancher/k3s-upgrade

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:

1
sudo k3s etcd-snapshot save --name pre-upgrade-$(date +%Y%m%d)

That snapshot is your “oh no” button. To restore:

1
2
sudo systemctl stop k3s
sudo k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/pre-upgrade-20260521

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: true and runAsUser: 1000 in pod specs. Most well-maintained images support it.
  • NetworkPolicies. K3s’ default flannel CNI 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 Secret objects in base64, not encrypted. Enable --secrets-encryption at 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 install and 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