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

K3s on Raspberry Pi and Homelab: Lightweight Kubernetes That Actually Works

k3skubernetesraspberry-pihomelabcontainerstraefiklonghornself-hosting

Kubernetes has a reputation for being operationally complex, resource-hungry, and overkill for anything smaller than a mid-sized company. K3s — the lightweight Kubernetes distribution from Rancher/SUSE — exists to challenge that reputation. It runs comfortably on a Raspberry Pi 4 with 4 GB of RAM, installs in about 30 seconds, and is fully conformant Kubernetes. The same kubectl commands, the same manifests, the same Helm charts.

This guide walks through standing up a K3s cluster on Raspberry Pi hardware (or any homelab server), connecting multiple nodes, configuring ingress and TLS, adding persistent storage with Longhorn, and deploying real workloads. By the end you’ll have a cluster that can run most things you’d run in a cloud Kubernetes environment, on hardware that fits in a shoebox.


Why K3s for Homelab

Full Kubernetes (kubeadm clusters, EKS, GKE) has real overhead: etcd, kube-apiserver, kube-controller-manager, kube-scheduler, and the container runtime each need memory and CPU. On a cloud node with 16 GB of RAM that overhead is invisible. On a Raspberry Pi 4 with 4 GB, it’s a problem.

K3s addresses this by:

  • Bundling everything (server, agent, container runtime) into a single ~70 MB binary
  • Replacing etcd with SQLite for single-node and small clusters (etcd is still available for HA)
  • Removing alpha features, cloud provider integrations, and storage plugins that homelab users don’t need
  • Using containerd directly instead of Docker (Docker is no longer required)
  • Running the control plane components as goroutines in a single process rather than separate services

The result: a K3s server on a Pi 4 idles at around 300–400 MB of RAM. A kubeadm single-node cluster idles at 1.5–2 GB. On a 4 GB Pi, that difference is everything.

K3s is also fully upstream-conformant — it passes the CNCF Kubernetes conformance tests. There are no proprietary extensions, no vendor lock-in, and no missing APIs. Anything that runs on upstream Kubernetes runs on K3s.


Hardware Requirements

Raspberry Pi Options

Model RAM Recommended Use
Pi 4 (2 GB) 2 GB Worker node only, light workloads
Pi 4 (4 GB) 4 GB Single-node cluster or worker
Pi 4 (8 GB) 8 GB Server node or worker, comfortable headroom
Pi 5 (4 GB) 4 GB Excellent worker, faster CPU than Pi 4
Pi 5 (8 GB) 8 GB Best Pi option for a server node
Pi CM4 4–8 GB Good for compact cluster builds

Minimum for a useful single-node cluster: Pi 4 4 GB or Pi 5 4 GB. Recommended for a 3-node cluster: 1× Pi 4/5 8 GB (server), 2× Pi 4/5 4 GB (workers).

Storage

MicroSD cards are the Pi’s Achilles heel — they wear out, are slow, and corrupt under power loss. For a cluster you care about:

  • USB SSD boot: Flash the OS to a USB SSD (Samsung T7, WD My Passport SSD) and boot from USB. Pi 4 and 5 support USB boot natively. Dramatically better performance and longevity.
  • NVMe via CM4 or Pi 5: The Pi 5 has a PCIe slot via the GPIO header; with an adapter board you can attach an NVMe SSD directly.
  • Network storage for Longhorn: If using Longhorn for persistent volumes, local SSDs on each node are the right substrate.

Other Hardware

  • Power: Each Pi 4 needs a proper 5V/3A USB-C supply. Underpowered Pis cause mysterious instability. Use official Pi power supplies or quality third-party ones.
  • Networking: Wired Ethernet per node for the cluster network. Wi-Fi works but adds latency and failure modes.
  • Cooling: Active cooling (a fan case) is worth it for server nodes. Pis will thermal-throttle under sustained load without it.
  • Cluster case: Cases like the Turing Pi 2, Pi Tower, or DeskPi TallPi pack multiple Pis neatly with shared power distribution.

OS Preparation

1
2
3
4
5
6
7
8
9
# Use Raspberry Pi Imager to flash your SD card or USB SSD
# Choose: Raspberry Pi OS Lite (64-bit) for Pis
# Or: Ubuntu Server 24.04 LTS for more familiar Ubuntu tooling

# In Raspberry Pi Imager, configure:
# - Hostname: k3s-server-01 (or k3s-worker-01, etc.)
# - SSH: enabled, with your public key
# - User: pi (or your preferred username)
# - WiFi: leave unconfigured (use Ethernet)

Essential Pre-Installation Config

SSH into each node and run:

 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
# Update the OS
sudo apt-get update && sudo apt-get upgrade -y

# Set a static IP (edit to match your network)
sudo nmcli con modify "Wired connection 1" \
  ipv4.method manual \
  ipv4.addresses 192.168.1.101/24 \
  ipv4.gateway 192.168.1.1 \
  ipv4.dns "192.168.1.1 8.8.8.8"
sudo nmcli con up "Wired connection 1"

# Enable cgroups v2 (required for Kubernetes on Pi OS)
# Edit /boot/firmware/cmdline.txt (Pi OS) or /boot/cmdline.txt
sudo sed -i '$ s/$/ cgroup_enable=cpuset cgroup_enable=memory cgroup_memory=1/' \
  /boot/firmware/cmdline.txt

# Disable swap (Kubernetes requirement)
sudo swapoff -a
sudo systemctl disable dphys-swapfile 2>/dev/null || true
sudo sed -i '/ swap / s/^/#/' /etc/fstab

# Load required kernel modules
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter

# Required sysctl params
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF
sudo sysctl --system

# Reboot to apply cgroup changes
sudo reboot

Installing K3s

Single-Node Cluster (Fastest Start)

For a single Pi that acts as both server and worker:

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

That’s it. K3s is installed, started, and enabled as a systemd service. Verify:

1
2
3
4
5
6
sudo kubectl get nodes
# NAME            STATUS   ROLES                  AGE   VERSION
# k3s-server-01   Ready    control-plane,master   60s   v1.31.x+k3s1

sudo kubectl get pods -A
# All system pods should be Running within 2 minutes

Get the kubeconfig to use from your workstation:

1
2
3
sudo cat /etc/rancher/k3s/k3s.yaml
# Copy this to ~/.kube/config on your local machine
# Replace "127.0.0.1" with the Pi's actual IP

Multi-Node Cluster

On the server node (k3s-server-01):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Install server with specific options
curl -sfL https://get.k3s.io | sh -s - server \
  --cluster-init \
  --disable=traefik \          # We'll install our own Traefik via Helm
  --disable=servicelb \        # We'll use MetalLB instead
  --write-kubeconfig-mode=644 \
  --node-name=k3s-server-01 \
  --advertise-address=192.168.1.101 \
  --tls-san=192.168.1.101 \    # Include your server IP in TLS cert SANs
  --tls-san=k3s.home.arpa      # And your DNS name if you have one

Get the node token (needed to join workers):

1
2
sudo cat /var/lib/rancher/k3s/server/node-token
# K10abc123def456...

On each worker node:

1
2
3
4
5
6
7
K3S_TOKEN="K10abc123def456..."    # token from server
K3S_SERVER="https://192.168.1.101:6443"

curl -sfL https://get.k3s.io | sh -s - agent \
  --server=$K3S_SERVER \
  --token=$K3S_TOKEN \
  --node-name=k3s-worker-01

Verify from the server:

1
2
3
4
5
sudo kubectl get nodes -o wide
# NAME            STATUS   ROLES                  VERSION
# k3s-server-01   Ready    control-plane,master   v1.31.x+k3s1
# k3s-worker-01   Ready    <none>                 v1.31.x+k3s1
# k3s-worker-02   Ready    <none>                 v1.31.x+k3s1

Local Access from Your Workstation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# On the server
sudo cat /etc/rancher/k3s/k3s.yaml

# Copy to your local machine, replacing server IP
mkdir -p ~/.kube
ssh pi@192.168.1.101 "sudo cat /etc/rancher/k3s/k3s.yaml" \
  | sed 's/127.0.0.1/192.168.1.101/' \
  > ~/.kube/config-k3s-homelab

# Use this cluster
export KUBECONFIG=~/.kube/config-k3s-homelab
# Or merge into your existing kubeconfig:
# KUBECONFIG=~/.kube/config:~/.kube/config-k3s-homelab kubectl config view --flatten > ~/.kube/config

kubectl get nodes

Networking: MetalLB + Traefik

Kubernetes services of type LoadBalancer need something to hand out real IPs. In the cloud, the cloud provider does this. On homelab, you need MetalLB.

MetalLB

MetalLB assigns IPs from a pool you define to LoadBalancer services. Configure it to use a range in your LAN that won’t conflict with DHCP:

1
2
3
4
5
# Install MetalLB
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.5/config/manifests/metallb-native.yaml

# Wait for it to be ready
kubectl wait -n metallb-system deployment/controller --for=condition=Available --timeout=90s
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# metallb-config.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: homelab-pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.1.200-192.168.1.220   # Reserve this range in your router's DHCP exclusions
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: homelab-l2
  namespace: metallb-system
spec:
  ipAddressPools:
  - homelab-pool
1
kubectl apply -f metallb-config.yaml

Traefik as Ingress Controller

K3s ships with Traefik, but we disabled it above to install a clean version via Helm with more control:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Add Traefik Helm repo
helm repo add traefik https://traefik.github.io/charts
helm repo update

# Install Traefik
helm install traefik traefik/traefik \
  --namespace traefik \
  --create-namespace \
  --set deployment.replicas=1 \
  --set service.type=LoadBalancer \
  --set ingressClass.enabled=true \
  --set ingressClass.isDefaultClass=true \
  --set ports.web.redirectTo.port=websecure \
  --set logs.general.level=INFO

Check what IP MetalLB assigned to Traefik:

1
2
kubectl get svc -n traefik traefik
# EXTERNAL-IP will be something like 192.168.1.200

Point a wildcard DNS record at this IP. In your router or Pi-hole:

*.k8s.home.arpa → 192.168.1.200

cert-manager for Automatic TLS

1
2
3
4
# Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

kubectl wait -n cert-manager deployment/cert-manager --for=condition=Available --timeout=120s

For local-only HTTPS with a self-signed CA (no public domain needed):

 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
# local-ca-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned-issuer
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: homelab-ca
  namespace: cert-manager
spec:
  isCA: true
  commonName: homelab-ca
  secretName: homelab-ca-secret
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    name: selfsigned-issuer
    kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: homelab-ca-issuer
spec:
  ca:
    secretName: homelab-ca-secret
1
kubectl apply -f local-ca-issuer.yaml

For public TLS with Let’s Encrypt (requires a publicly accessible domain and DNS challenge or HTTP challenge):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# letsencrypt-issuer.yaml
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
    solvers:
    - http01:
        ingress:
          ingressClassName: traefik

Persistent Storage with Longhorn

By default K3s uses local-path storage — data lives on the node that runs the pod. If the pod moves to another node, data is gone. Longhorn distributes and replicates volumes across nodes.

Install Longhorn

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Prerequisites on each node
sudo apt-get install -y open-iscsi nfs-common

# Install via Helm
helm repo add longhorn https://charts.longhorn.io
helm repo update

helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --create-namespace \
  --set defaultSettings.defaultReplicaCount=2 \   # replicate data to 2 nodes
  --set defaultSettings.storageMinimalAvailablePercentage=15

# Wait for all components to be ready (takes 2-3 minutes)
kubectl -n longhorn-system rollout status deploy/longhorn-manager

Verify Longhorn has a storage class:

1
2
3
4
kubectl get storageclass
# NAME                   PROVISIONER
# local-path (default)   rancher.io/local-path
# longhorn               driver.longhorn.io

Make Longhorn the default:

1
2
kubectl patch storageclass local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
kubectl patch storageclass longhorn -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Accessing the Longhorn UI

 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
# longhorn-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: longhorn-ui
  namespace: longhorn-system
  annotations:
    cert-manager.io/cluster-issuer: homelab-ca-issuer
spec:
  ingressClassName: traefik
  rules:
  - host: longhorn.k8s.home.arpa
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: longhorn-frontend
            port:
              number: 80
  tls:
  - hosts:
    - longhorn.k8s.home.arpa
    secretName: longhorn-tls
1
2
kubectl apply -f longhorn-ingress.yaml
# Access at https://longhorn.k8s.home.arpa

Deploying Real Workloads

Example: Deploying Gitea

A self-hosted Git server is a good real-world test — it needs persistent storage, ingress, and a database.

 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
88
89
90
91
92
93
94
95
96
97
98
99
# gitea.yaml
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: 10Gi
---
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:latest
        ports:
        - containerPort: 3000
          name: http
        - containerPort: 22
          name: ssh
        env:
        - name: GITEA__database__DB_TYPE
          value: sqlite3
        - name: GITEA__server__ROOT_URL
          value: https://git.k8s.home.arpa
        - name: GITEA__server__SSH_DOMAIN
          value: git.k8s.home.arpa
        volumeMounts:
        - name: data
          mountPath: /data
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"
      volumes:
      - name: data
        persistentVolumeClaim:
          claimName: gitea-data
---
apiVersion: v1
kind: Service
metadata:
  name: gitea
  namespace: gitea
spec:
  selector:
    app: gitea
  ports:
  - name: http
    port: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: gitea
  namespace: gitea
  annotations:
    cert-manager.io/cluster-issuer: homelab-ca-issuer
spec:
  ingressClassName: traefik
  rules:
  - host: git.k8s.home.arpa
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: gitea
            port:
              number: 3000
  tls:
  - hosts:
    - git.k8s.home.arpa
    secretName: gitea-tls
1
2
3
kubectl apply -f gitea.yaml
kubectl -n gitea rollout status deploy/gitea
# Access at https://git.k8s.home.arpa

Resource Management on Pi Hardware

Pi nodes have limited RAM. Set requests and limits on every workload:

1
2
3
4
5
6
7
resources:
  requests:
    memory: "64Mi"     # Guaranteed allocation
    cpu: "50m"         # 0.05 CPU cores
  limits:
    memory: "256Mi"    # Hard cap — pod OOMKilled if exceeded
    cpu: "500m"        # 0.5 CPU cores

Without limits, one misbehaving pod can starve the entire node. Set them consistently.

Node resource summary:

1
2
kubectl top nodes          # Actual usage (requires metrics-server)
kubectl describe node k3s-worker-01  # Allocatable resources and current requests

Install metrics-server if not present:

1
2
3
4
5
6
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

# On Pi, add --kubelet-insecure-tls flag
kubectl patch deployment metrics-server -n kube-system \
  --type='json' \
  -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'

Upgrading K3s

K3s upgrades are straightforward with the system-upgrade-controller:

1
2
# Install the upgrade controller
kubectl apply -f https://github.com/rancher/system-upgrade-controller/releases/latest/download/system-upgrade-controller.yaml
 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
# upgrade-plan.yaml
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
  name: k3s-server
  namespace: system-upgrade
spec:
  concurrency: 1
  cordon: true
  nodeSelector:
    matchExpressions:
    - {key: node-role.kubernetes.io/control-plane, operator: In, values: ["true"]}
  serviceAccountName: system-upgrade
  upgrade:
    image: rancher/k3s-upgrade
  channel: https://update.k3s.io/v1-release/channels/stable
---
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
  name: k3s-agent
  namespace: system-upgrade
spec:
  concurrency: 1
  cordon: true
  nodeSelector:
    matchExpressions:
    - {key: node-role.kubernetes.io/control-plane, operator: NotIn, values: ["true"]}
  serviceAccountName: system-upgrade
  prepare:
    image: rancher/k3s-upgrade
    args: ["prepare", "k3s-server"]
  upgrade:
    image: rancher/k3s-upgrade
  channel: https://update.k3s.io/v1-release/channels/stable
1
2
kubectl apply -f upgrade-plan.yaml
# K3s will now automatically upgrade nodes when new stable releases are available

Backing Up the Cluster State

K3s uses SQLite (or etcd in HA mode) for cluster state. Back it up regularly:

1
2
3
4
5
6
7
8
9
# SQLite backup (single-node / embedded DB)
sudo k3s etcd-snapshot save --name homelab-backup

# Snapshots go to /var/lib/rancher/k3s/server/db/snapshots/
ls -lh /var/lib/rancher/k3s/server/db/snapshots/

# Copy to NAS or offsite storage
rsync -av /var/lib/rancher/k3s/server/db/snapshots/ \
  nas.home.arpa:/backups/k3s/

As a systemd timer:

1
2
3
4
5
6
7
8
# /etc/systemd/system/k3s-backup.service
[Unit]
Description=K3s cluster state backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/k3s etcd-snapshot save --name daily-backup
ExecStartPost=/usr/bin/rsync -av /var/lib/rancher/k3s/server/db/snapshots/ nas.home.arpa:/backups/k3s/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# /etc/systemd/system/k3s-backup.timer
[Unit]
Description=Daily K3s backup

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target
1
sudo systemctl enable --now k3s-backup.timer

Troubleshooting Common Pi Issues

Node Not Joining Cluster

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Check agent logs on worker
sudo journalctl -u k3s-agent -f

# Common causes:
# 1. Firewall blocking port 6443 on server
sudo ufw allow from 192.168.1.0/24 to any port 6443

# 2. Token mismatch — re-verify
sudo cat /var/lib/rancher/k3s/server/node-token   # on server

# 3. Time skew between nodes — sync NTP
sudo timedatectl set-ntp true

Pods Stuck in Pending

1
2
3
4
5
6
7
8
kubectl describe pod <pod-name> -n <namespace>
# Look for Events section — "0/3 nodes available" means resource pressure

# Check node resources
kubectl describe nodes | grep -A 5 "Allocated resources"

# Check if PVC is unbound (storage issue)
kubectl get pvc -A

High Memory Pressure

1
2
3
4
5
6
7
8
# Check actual memory usage per pod
kubectl top pods -A --sort-by=memory

# Check if any pods are being OOMKilled
kubectl get events -A | grep OOMKilled

# Increase swap only as an emergency measure
sudo dphys-swapfile install   # NOT recommended for production

Longhorn Volume Degraded After Node Reboot

1
2
3
4
5
6
7
8
# Check Longhorn volume health
kubectl get volumes -n longhorn-system

# A 2-replica volume can survive one node being offline
# Wait for the node to come back — Longhorn auto-heals

# Force reattach if needed
kubectl -n longhorn-system get volume <volume-name> -o yaml

A Reference Cluster Architecture

A practical 3-node Pi homelab cluster:

┌─────────────────────────────────────────────────────────┐
│  Router / Pi-hole DNS (192.168.1.1)                     │
│  *.k8s.home.arpa → 192.168.1.200 (MetalLB IP)          │
└────────────────────┬────────────────────────────────────┘
                     │ Gigabit Ethernet
        ┌────────────┼────────────┐
        │            │            │
   ┌────▼────┐  ┌────▼────┐  ┌───▼─────┐
   │ Pi 5 8G │  │ Pi 4 4G │  │ Pi 4 4G │
   │ server  │  │ worker-1│  │ worker-2│
   │.1.101   │  │.1.102   │  │.1.103   │
   │ USB SSD │  │ USB SSD │  │ USB SSD │
   └─────────┘  └─────────┘  └─────────┘
        │            │            │
   Control plane   Workloads   Workloads
   etcd/SQLite     Longhorn     Longhorn
   Traefik LB      replicas     replicas

Stack running on this cluster:

  • MetalLB (LoadBalancer IPs)
  • Traefik (Ingress + TLS termination)
  • cert-manager (automatic certificates)
  • Longhorn (replicated persistent storage)
  • metrics-server (resource monitoring)
  • Gitea (self-hosted Git)
  • Uptime Kuma (monitoring)
  • Anything else you’d normally run in Docker Compose

Total hardware cost: ~$300–350 for the Pis, cases, SSDs, and power supplies. Monthly electricity: ~15–20W idle for all three nodes combined, roughly $1.50–2/month.


From Docker Compose to K3s

If you’re running services in Docker Compose on a single machine and want to migrate to K3s, the path is straightforward: each Compose service becomes a Deployment, each volume becomes a PVC, and each port mapping becomes a Service + Ingress.

A simple migration of a Compose service:

1
2
3
4
5
6
7
8
9
# docker-compose.yml (before)
services:
  uptime-kuma:
    image: louislam/uptime-kuma:latest
    volumes:
      - kuma-data:/app/data
    ports:
      - "3001:3001"
    restart: unless-stopped
 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
# k8s-uptime-kuma.yaml (after)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: uptime-kuma
  namespace: monitoring
spec:
  replicas: 1
  selector:
    matchLabels:
      app: uptime-kuma
  template:
    metadata:
      labels:
        app: uptime-kuma
    spec:
      containers:
      - name: uptime-kuma
        image: louislam/uptime-kuma:latest
        ports:
        - containerPort: 3001
        volumeMounts:
        - name: data
          mountPath: /app/data
        resources:
          requests: {memory: "128Mi", cpu: "100m"}
          limits: {memory: "256Mi", cpu: "500m"}
      volumes:
      - name: data
        persistentVolumeClaim:
          claimName: uptime-kuma-data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: uptime-kuma-data
  namespace: monitoring
spec:
  accessModes: [ReadWriteOnce]
  storageClassName: longhorn
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
  name: uptime-kuma
  namespace: monitoring
spec:
  selector:
    app: uptime-kuma
  ports:
  - port: 3001
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: uptime-kuma
  namespace: monitoring
  annotations:
    cert-manager.io/cluster-issuer: homelab-ca-issuer
spec:
  ingressClassName: traefik
  rules:
  - host: status.k8s.home.arpa
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: uptime-kuma
            port:
              number: 3001
  tls:
  - hosts: [status.k8s.home.arpa]
    secretName: uptime-kuma-tls

The Kubernetes version is more verbose, but it gains: automatic restarts with health checks, rolling deployments with zero downtime, data replicated across nodes by Longhorn, and proper HTTPS via cert-manager. That’s the trade-off you’re making when you move from Compose to K3s — more config, more capability, more operational robustness.

Comments