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

Kubernetes for the Homelab: K3s Setup, Workloads, and Beyond

kubernetesk3shomelabcontainersdevopsself-hostingingressstorage

Kubernetes has a reputation for being the domain of hyperscale cloud teams and enterprise platform engineers. That reputation isn’t entirely wrong — a full-fat Kubernetes cluster with all the trimmings is genuinely complex. But K3s, the lightweight distribution from Rancher/SUSE, has made running Kubernetes in a homelab not just practical but genuinely pleasant. It installs in 30 seconds, runs comfortably on a single machine with 2GB of RAM, and gives you the full Kubernetes API surface without the operational overhead of managing etcd clusters and cloud-provider integrations.

This guide is for people who know Docker Compose and want to move to Kubernetes — not to abandon Compose entirely, but to understand when K3s is the right tool, how to get it running, and how to use it well. By the end, you’ll have a working K3s cluster with ingress, TLS, persistent storage, and a solid understanding of how to operate it day-to-day.


Why K3s for the Homelab?

K3s is a fully conformant Kubernetes distribution packaged as a single binary under 100MB. The “K3s” name is a play on K8s (Kubernetes), with the idea that it’s half the size in every dimension. What makes it different from upstream Kubernetes is what it removes and what it bundles.

What K3s removes from upstream Kubernetes:

  • etcd (by default). K3s uses SQLite as its default datastore, which is adequate for single-node and small clusters. You can enable embedded etcd for multi-node HA, or point it at an external datastore (MySQL, PostgreSQL) for production-grade HA.
  • Cloud-provider integrations. No AWS, GCP, or Azure controllers. No cloud load balancer provisioners. K3s replaces these with its own ServiceLB (formerly Klipper LB), which handles LoadBalancer services on bare metal.
  • Alpha features and in-tree plugins. K3s strips out experimental features to keep the binary small and the attack surface small.

What K3s bundles and configures for you:

  • containerd as the container runtime (no Docker daemon needed)
  • Flannel as the default CNI (Container Network Interface) for pod networking
  • Traefik as the default ingress controller
  • CoreDNS for in-cluster DNS
  • metrics-server for kubectl top functionality
  • ServiceLB for bare-metal LoadBalancer services
  • Helm controller for managing Helm charts declaratively via CRDs

When to use K3s instead of Docker Compose:

Situation Better Tool
Single-node homelab with a few services Docker Compose
Multi-node cluster with self-healing across hosts K3s
You need rolling deployments without downtime K3s
GitOps workflow (declarative, git-driven) K3s
Learning Kubernetes for work K3s
Quick iteration, minimal config overhead Docker Compose
Raspberry Pi with 1GB RAM Docker Compose

Hardware requirements:

  • Single-node: 1 vCPU, 512MB RAM minimum; 2GB RAM recommended for any real workloads
  • Agent nodes: 1 vCPU, 512MB RAM each
  • Disk: 10GB minimum for the server, more for workloads and images
  • K3s runs well on: NUCs, Raspberry Pi 4/5, used mini PCs (Beelink, Minisforum), Proxmox VMs, and cloud VMs

Installing K3s

Single-Node Install

The simplest possible K3s install:

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

That’s it. The script downloads the binary, installs it as a systemd service, and starts the cluster. Within 30 seconds you have a running Kubernetes node.

With options — the flags you actually want:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Disable the bundled Traefik if you plan to install ingress-nginx or your own Traefik
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable traefik" sh -

# Enable embedded etcd for multi-node HA (must be done at install time on the first server)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--cluster-init" sh -

# Pin a specific version
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="v1.31.4+k3s1" sh -

# Use an external PostgreSQL datastore instead of SQLite
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--datastore-endpoint='postgres://user:pass@host:5432/k3s'" sh -

# Expose the cluster on a specific IP (useful when the node has multiple interfaces)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--node-external-ip=192.168.1.10 --advertise-address=192.168.1.10" sh -

Kubeconfig Setup

K3s writes its kubeconfig to /etc/rancher/k3s/k3s.yaml. By default it’s root-owned. To use kubectl as your regular user:

1
2
3
4
5
6
7
8
9
# Option 1: Copy and fix ownership
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $USER:$USER ~/.kube/config

# Option 2: Set KUBECONFIG env var (less permanent)
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml

# Option 3: Tell K3s to write a world-readable kubeconfig (less secure)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--write-kubeconfig-mode 644" sh -

To use this cluster from another machine, copy ~/.kube/config to that machine and replace 127.0.0.1 with your server’s IP. If you run multiple clusters, use kubectl config use-context to switch between them.

Adding Agent Nodes

On the server, get the node token:

1
sudo cat /var/lib/rancher/k3s/server/node-token

On each agent node:

1
2
3
4
curl -sfL https://get.k3s.io | \
  K3S_URL="https://192.168.1.10:6443" \
  K3S_TOKEN="<token-from-server>" \
  sh -

For a multi-server HA setup with embedded etcd, the additional server nodes join with:

1
2
3
4
curl -sfL https://get.k3s.io | \
  K3S_TOKEN="<token-from-server>" \
  INSTALL_K3S_EXEC="--server https://192.168.1.10:6443" \
  sh -

Verifying the Install

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Check node status
kubectl get nodes
# NAME       STATUS   ROLES                  AGE   VERSION
# k3s-node   Ready    control-plane,master   2m    v1.31.4+k3s1

# Check all pods across all namespaces
kubectl get pods -A
# NAMESPACE     NAME                                      READY   STATUS    RESTARTS   AGE
# kube-system   coredns-7b98449995-xqkfd                 1/1     Running   0          2m
# kube-system   local-path-provisioner-6c86858495-jzs5d  1/1     Running   0          2m
# kube-system   metrics-server-548d8b95d4-8dswk           1/1     Running   0          2m
# kube-system   svclb-traefik-xxxxx                      2/2     Running   0          2m
# kube-system   traefik-7454c7d557-kpxmb                  1/1     Running   0          2m

K3s-Specific Commands

K3s bundles its own versions of common container tooling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Use k3s's embedded kubectl (same as system kubectl if installed)
k3s kubectl get nodes

# Inspect container images at the containerd level
k3s ctr images ls

# Debug running containers (like docker exec but for containerd)
k3s crictl ps
k3s crictl logs <container-id>
k3s crictl exec -it <container-id> sh

Uninstalling

1
2
3
4
5
# Server node
/usr/local/bin/k3s-uninstall.sh

# Agent node
/usr/local/bin/k3s-agent-uninstall.sh

Core Kubernetes Concepts

Before deploying workloads, these are the abstractions you need to understand in K3s context.

Pod: The smallest deployable unit. One or more containers that share a network namespace and storage. You almost never create pods directly — Deployments manage them.

Deployment: Declares the desired state for a set of pods — which image to run, how many replicas, resource limits, probes. The Deployment controller continuously reconciles actual state toward desired state.

ReplicaSet: Created automatically by a Deployment to maintain the requested number of pod replicas. You interact with Deployments, not ReplicaSets directly.

Service: A stable network endpoint that load-balances traffic to a set of pods selected by labels. Types:

  • ClusterIP — reachable only inside the cluster (default)
  • NodePort — exposes a port on every node’s IP (good for quick access, not production)
  • LoadBalancer — in K3s, ServiceLB assigns the node’s IP to the service, making it reachable externally on the service port

Namespace: A virtual cluster within the cluster. Provides name scoping and is a natural boundary for RBAC and resource quotas. Use namespaces to separate stacks (e.g., monitoring, media, infra).

ConfigMap: Key-value store for non-secret configuration. Mounted as files or injected as environment variables.

Secret: Like ConfigMap but base64-encoded (not encrypted by default — just encoded). Use Sealed Secrets or External Secrets Operator for real encryption.

kubectl essentials:

 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
# Listing resources
kubectl get pods -n <namespace>
kubectl get pods -A                         # all namespaces
kubectl get pods -o wide                    # include node and IP info
kubectl get all -n <namespace>              # pods, services, deployments, etc.

# Inspecting resources
kubectl describe pod <pod-name> -n <namespace>
kubectl describe node <node-name>

# Logs
kubectl logs <pod-name> -n <namespace>
kubectl logs <pod-name> -n <namespace> -f   # follow
kubectl logs <pod-name> -n <namespace> --previous  # crashed container

# Exec into a pod
kubectl exec -it <pod-name> -n <namespace> -- bash

# Apply and delete manifests
kubectl apply -f manifest.yaml
kubectl delete -f manifest.yaml
kubectl apply -k ./kustomization-dir/       # kustomize

# Port forward for local access
kubectl port-forward svc/<service-name> 8080:80 -n <namespace>

# Watch resources
kubectl get pods -n <namespace> -w

Deploying Workloads

A Complete Deployment and Service

Here’s a production-quality Deployment and Service for a simple web application:

 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
# app-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: default
  labels:
    app: myapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: myapp
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: nginx:1.27-alpine
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "250m"
              memory: "256Mi"
          livenessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 10
          env:
            - name: APP_ENV
              value: "production"
          envFrom:
            - configMapRef:
                name: myapp-config
---
apiVersion: v1
kind: Service
metadata:
  name: myapp
  namespace: default
spec:
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 80
  type: ClusterIP
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: myapp-config
  namespace: default
data:
  LOG_LEVEL: "info"
  MAX_CONNECTIONS: "100"

Apply it:

1
2
3
kubectl apply -f app-deployment.yaml
kubectl get pods -l app=myapp
kubectl get svc myapp

Rolling Updates and Rollbacks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Update the image (triggers a rolling update)
kubectl set image deployment/myapp myapp=nginx:1.28-alpine -n default

# Watch the rollout
kubectl rollout status deployment/myapp -n default

# View rollout history
kubectl rollout history deployment/myapp -n default

# Roll back to the previous version
kubectl rollout undo deployment/myapp -n default

# Roll back to a specific revision
kubectl rollout undo deployment/myapp --to-revision=2 -n default

DaemonSets

DaemonSets ensure one pod runs on every node. Useful for log collectors, monitoring agents, and network plugins:

 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
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-exporter
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app: node-exporter
  template:
    metadata:
      labels:
        app: node-exporter
    spec:
      hostNetwork: true
      hostPID: true
      containers:
        - name: node-exporter
          image: prom/node-exporter:v1.8.2
          args:
            - --path.rootfs=/host
          ports:
            - containerPort: 9100
              hostPort: 9100
          volumeMounts:
            - name: root
              mountPath: /host
              readOnly: true
      volumes:
        - name: root
          hostPath:
            path: /

CronJobs

For scheduled tasks like backups, data cleanup, or report generation:

 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
apiVersion: batch/v1
kind: CronJob
metadata:
  name: db-backup
  namespace: default
spec:
  schedule: "0 2 * * *"          # 2 AM daily
  concurrencyPolicy: Forbid       # Don't run if previous job is still running
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: backup
              image: postgres:16-alpine
              command: ["/bin/sh", "-c"]
              args:
                - pg_dump -h postgres -U $PGUSER $PGDATABASE | gzip > /backup/db-$(date +%Y%m%d).sql.gz
              envFrom:
                - secretRef:
                    name: postgres-credentials
              volumeMounts:
                - name: backup-storage
                  mountPath: /backup
          volumes:
            - name: backup-storage
              persistentVolumeClaim:
                claimName: backup-pvc

Namespaces for Stack Isolation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Create namespaces for logical grouping
kubectl create namespace monitoring
kubectl create namespace media
kubectl create namespace infra

# Deploy to a specific namespace
kubectl apply -f prometheus/ -n monitoring

# Set a default namespace for your current context
kubectl config set-context --current --namespace=monitoring

# Switch between namespaces quickly (install kubens for this)
# kubens monitoring

Ingress with K3s

K3s and Traefik

K3s ships Traefik v2 as its default ingress controller, managed by the Helm controller (you’ll see it as a HelmChart resource in kube-system). Traefik supports both the standard Kubernetes Ingress resource and its own IngressRoute CRD.

Standard Ingress resource (compatible with any ingress controller):

 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
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-ingress
  namespace: default
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
    traefik.ingress.kubernetes.io/router.tls: "true"
    traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
spec:
  ingressClassName: traefik
  rules:
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp
                port:
                  number: 80
  tls:
    - hosts:
        - myapp.example.com
      secretName: myapp-tls

Traefik IngressRoute CRD (more expressive, Traefik-specific):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: myapp-route
  namespace: default
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`myapp.example.com`)
      kind: Rule
      services:
        - name: myapp
          port: 80
      middlewares:
        - name: strip-prefix
  tls:
    certResolver: letsencrypt

TLS with cert-manager

cert-manager automates TLS certificate provisioning from Let’s Encrypt (and other ACME-compatible CAs). Install it via Helm:

1
2
3
4
5
6
7
8
9
# Add the cert-manager Helm repo
helm repo add jetstack https://charts.jetstack.io
helm repo update

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

HTTP-01 challenge ClusterIssuer (works if your server is publicly reachable on port 80):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik

DNS-01 challenge ClusterIssuer (works behind NAT; requires a supported DNS provider):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: you@example.com
    privateKeySecretRef:
      name: letsencrypt-dns-key
    solvers:
      - dns01:
          cloudflare:
            email: you@example.com
            apiTokenSecretRef:
              name: cloudflare-token
              key: api-token

Create the Cloudflare token secret:

1
2
3
kubectl create secret generic cloudflare-token \
  --from-literal=api-token=<your-cloudflare-api-token> \
  -n cert-manager

Full example: web app with automatic TLS via cert-manager:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-tls
  namespace: default
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  ingressClassName: traefik
  rules:
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp
                port:
                  number: 80
  tls:
    - hosts:
        - myapp.example.com
      secretName: myapp-tls-cert

cert-manager sees the tls block, creates a Certificate resource, solves the ACME challenge, and populates myapp-tls-cert with the certificate. Traefik picks it up automatically. Renewals happen automatically.

Replacing Bundled Traefik with ingress-nginx

Some teams prefer ingress-nginx for its configuration model or because they’re already familiar with it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# First, disable Traefik when installing K3s
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable traefik" sh -

# Or, on a running cluster, delete the HelmChart resource
kubectl delete helmchart traefik -n kube-system

# Install ingress-nginx
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.service.type=LoadBalancer

Persistent Storage

K3s local-path-provisioner

K3s ships with local-path-provisioner, which is the default StorageClass. It provisions hostPath volumes on the node under /var/lib/rancher/k3s/storage/. It’s fast, has zero dependencies, and works great for single-node setups.

Limitations: Data is tied to the node it was provisioned on. If a pod is rescheduled to another node, it won’t find its data. Not suitable for multi-node clusters without anti-affinity rules.

PersistentVolume and PersistentVolumeClaim

The pattern: a PersistentVolume (PV) is the actual storage. A PersistentVolumeClaim (PVC) is a request for storage. A StorageClass automates PV creation so you only ever need to write PVCs.

With the local-path StorageClass (the K3s default), creating a PVC is all you need:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: myapp-data
  namespace: default
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 10Gi

Use it in a Deployment:

 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
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-with-storage
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: myapp:latest
          volumeMounts:
            - name: data
              mountPath: /var/lib/myapp
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: myapp-data

Longhorn: Distributed Block Storage for Homelab HA

Longhorn is the go-to persistent storage solution for homelab K3s clusters. It provides:

  • Replication across nodes (configurable replica count)
  • Snapshots and incremental backups to S3/NFS
  • A web UI for visual management of volumes
  • CSI-compliant storage class — works with any Kubernetes storage consumer

Install Longhorn via Helm:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Install prerequisites on each node (Ubuntu/Debian)
sudo apt install -y open-iscsi nfs-common

# Enable iscsid
sudo systemctl enable --now iscsid

# Add the Longhorn Helm repo
helm repo add longhorn https://charts.longhorn.io
helm repo update

# Install Longhorn
helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --create-namespace \
  --set defaultSettings.defaultReplicaCount=2 \
  --set persistence.defaultClassIsDefault=true

After installation, Longhorn replaces local-path as the default StorageClass. Access the UI:

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

Then open http://localhost:8080 in your browser. Use an Ingress for permanent access.

NFS-based Storage

If you have a NAS (Synology, TrueNAS, etc.), the nfs-subdir-external-provisioner lets you provision PVCs backed by NFS subdirectories:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
helm repo add nfs-subdir-external-provisioner \
  https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/

helm install nfs-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \
  --namespace nfs-provisioner \
  --create-namespace \
  --set nfs.server=192.168.1.50 \
  --set nfs.path=/volume1/k8s-storage \
  --set storageClass.name=nfs-client \
  --set storageClass.defaultClass=false

Use it in a PVC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: shared-data
  namespace: default
spec:
  accessModes:
    - ReadWriteMany   # NFS supports multiple pods reading and writing simultaneously
  storageClassName: nfs-client
  resources:
    requests:
      storage: 50Gi

ReadWriteMany (RWX) is one of NFS’s key advantages over local-path, which only supports ReadWriteOnce (single pod).


Migrating from Docker Compose

Conceptual Mapping

Docker Compose Concept Kubernetes Equivalent
service Deployment + Service
image container image in pod spec
ports Service ports + Ingress
volumes PersistentVolumeClaim
environment env in pod spec or ConfigMap/Secret
networks Namespace + optional NetworkPolicy
depends_on initContainers or readiness gates
restart: unless-stopped Deployment’s default behavior (always restarts)
healthcheck livenessProbe + readinessProbe
.env file Secret or ConfigMap

Kompose: Automated Conversion

Kompose converts a docker-compose.yml into Kubernetes manifests:

1
2
3
4
5
6
7
8
9
# Install kompose
curl -L https://github.com/kubernetes/kompose/releases/latest/download/kompose-linux-amd64 \
  -o /usr/local/bin/kompose && chmod +x /usr/local/bin/kompose

# Convert (outputs multiple YAML files)
kompose convert -f docker-compose.yml

# Or output a single file
kompose convert -f docker-compose.yml -o k8s-manifests.yaml

Kompose handles the basics well: it generates Deployments, Services, and PVCs from volumes. What it gets wrong or leaves incomplete:

  • Ingress: Kompose generates Services but doesn’t create Ingress resources. You need to write those manually.
  • Secrets: Environment variables from .env files or environment: blocks are inlined into Deployments as plaintext. Move sensitive values into Secrets.
  • Resource limits: Kompose doesn’t set resources.requests or resources.limits. Add these manually.
  • Health checks: healthcheck: in Compose converts to liveness probes, but the syntax often needs adjustment.
  • Network dependencies: depends_on isn’t fully supported. Use init containers or retry logic in your application.

A Worked Example

Original Docker Compose:

 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
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "app"]
      interval: 10s
      retries: 5

  web:
    image: myapp:latest
    ports:
      - "8080:8000"
    environment:
      DATABASE_URL: postgres://app:secret@postgres:5432/myapp
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres-data:

Converted to K3s manifests:

  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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: myapp
---
# postgres-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: postgres-credentials
  namespace: myapp
type: Opaque
stringData:
  POSTGRES_DB: myapp
  POSTGRES_USER: app
  POSTGRES_PASSWORD: secret
  DATABASE_URL: "postgres://app:secret@postgres:5432/myapp"
---
# postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  namespace: myapp
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 20Gi
---
# postgres-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  namespace: myapp
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
          envFrom:
            - secretRef:
                name: postgres-credentials
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "app"]
            initialDelaySeconds: 10
            periodSeconds: 10
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: postgres-data
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: myapp
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432
---
# web-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: myapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      initContainers:
        - name: wait-for-postgres
          image: busybox:1.36
          command: ['sh', '-c', 'until nc -z postgres 5432; do sleep 2; done']
      containers:
        - name: web
          image: myapp:latest
          ports:
            - containerPort: 8000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: postgres-credentials
                  key: DATABASE_URL
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8000
            initialDelaySeconds: 10
            periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: myapp
spec:
  selector:
    app: web
  ports:
    - port: 8000
      targetPort: 8000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web-ingress
  namespace: myapp
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  rules:
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 8000
  tls:
    - hosts:
        - myapp.example.com
      secretName: web-tls

Helm: The Kubernetes Package Manager

Helm is the standard tool for installing and managing third-party applications on Kubernetes. A Helm chart is a packaged set of Kubernetes manifests with templating. A release is an installed instance of a chart.

Installing Helm

1
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

Core Helm Workflow

 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
# Add a chart repository
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

# Search for charts
helm search repo bitnami/postgresql
helm search hub wordpress           # search Artifact Hub

# Install a chart
helm install my-postgres bitnami/postgresql \
  --namespace databases \
  --create-namespace \
  --set auth.postgresPassword=secret \
  --set primary.persistence.size=20Gi

# View installed releases
helm list -A

# Upgrade a release (update values or chart version)
helm upgrade my-postgres bitnami/postgresql \
  --namespace databases \
  --reuse-values \
  --set primary.persistence.size=40Gi

# View the computed values for an installed release
helm get values my-postgres -n databases

# Uninstall
helm uninstall my-postgres -n databases

values.yaml Overrides

Rather than specifying --set for every value, create a values.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# postgres-values.yaml
auth:
  postgresPassword: "supersecret"
  database: "myapp"
primary:
  persistence:
    size: 20Gi
    storageClass: local-path
  resources:
    requests:
      memory: 256Mi
      cpu: 100m
    limits:
      memory: 512Mi
      cpu: 500m
1
2
3
helm install my-postgres bitnami/postgresql \
  -f postgres-values.yaml \
  -n databases --create-namespace

Useful homelab Helm charts:

Chart Repo Purpose
jetstack/cert-manager jetstack TLS certificate automation
longhorn/longhorn longhorn Distributed block storage
ingress-nginx/ingress-nginx ingress-nginx Nginx-based ingress controller
prometheus-community/kube-prometheus-stack prometheus-community Full Prometheus + Grafana + Alertmanager stack
bitnami/postgresql bitnami PostgreSQL database
bitnami/redis bitnami Redis cache
kubernetes-dashboard/kubernetes-dashboard kubernetes-dashboard Web UI for cluster management

GitOps with Flux or ArgoCD

GitOps is the practice of using a git repository as the single source of truth for your cluster state. Instead of running kubectl apply by hand, a controller watches your git repo and ensures the cluster matches what’s committed. Infrastructure drift becomes visible as a git diff, and rollbacks are git reverts.

Flux

Flux is a CNCF graduated project that monitors git repos and applies changes. It’s CLI-first, lightweight, and integrates naturally with Helm and Kustomize.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Install the Flux CLI
curl -s https://fluxcd.io/install.sh | sudo bash

# Bootstrap Flux with a GitHub repo
flux bootstrap github \
  --owner=your-github-username \
  --repository=homelab-k3s \
  --branch=main \
  --path=./clusters/my-cluster \
  --personal

Bootstrap creates the flux-system namespace, installs the Flux controllers, and creates a GitRepository + Kustomization pointing at your repo. Any YAML committed under clusters/my-cluster/ is automatically applied.

A Flux HelmRelease for cert-manager:

 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
# clusters/my-cluster/cert-manager.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: jetstack
  namespace: flux-system
spec:
  interval: 24h
  url: https://charts.jetstack.io
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: cert-manager
  namespace: cert-manager
spec:
  interval: 30m
  chart:
    spec:
      chart: cert-manager
      version: "v1.*"
      sourceRef:
        kind: HelmRepository
        name: jetstack
        namespace: flux-system
  values:
    crds:
      enabled: true

ArgoCD

ArgoCD has a polished web UI and is favored by teams who want a visual overview of application health and sync status. It uses an App-of-Apps pattern for managing many applications.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Install ArgoCD
kubectl create namespace argocd
kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# Get the initial admin password
kubectl get secret argocd-initial-admin-secret \
  -n argocd \
  -o jsonpath="{.data.password}" | base64 -d

# Port-forward to the UI
kubectl port-forward svc/argocd-server 8080:443 -n argocd

An ArgoCD Application manifest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapp
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/your-username/homelab-k3s.git
    targetRevision: HEAD
    path: apps/myapp
  destination:
    server: https://kubernetes.default.svc
    namespace: myapp
  syncPolicy:
    automated:
      prune: true       # Delete resources removed from git
      selfHeal: true    # Revert manual changes to cluster
    syncOptions:
      - CreateNamespace=true

Recommendation: Use Flux if you prefer CLI-driven workflows and want minimal overhead. Use ArgoCD if you want a UI that shows application health, sync drift, and history at a glance. Both are excellent choices for a homelab GitOps setup.


Observability

kubectl top

K3s ships metrics-server, so kubectl top works out of the box:

1
2
3
4
5
6
kubectl top nodes
# NAME       CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
# k3s-node   210m         5%     1842Mi          46%

kubectl top pods -A
kubectl top pods -n monitoring --sort-by=memory

Kubernetes Dashboard

 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
helm repo add kubernetes-dashboard https://kubernetes.github.io/dashboard/
helm upgrade --install kubernetes-dashboard kubernetes-dashboard/kubernetes-dashboard \
  --namespace kubernetes-dashboard \
  --create-namespace

# Create an admin ServiceAccount for access
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
  name: admin-user
  namespace: kubernetes-dashboard
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: admin-user
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: admin-user
    namespace: kubernetes-dashboard
EOF

# Generate a login token
kubectl create token admin-user -n kubernetes-dashboard

# Access the dashboard
kubectl port-forward svc/kubernetes-dashboard-kong-proxy 8443:443 -n kubernetes-dashboard

Lens IDE

Lens is a desktop application (Linux, macOS, Windows) that provides a rich GUI for managing Kubernetes clusters. It reads your ~/.kube/config and provides pod logs, exec, resource browsers, and built-in metrics visualization. Download from k8slens.dev.

kube-prometheus-stack

The full production-grade observability stack: Prometheus for metrics, Grafana for dashboards, and Alertmanager for alerting:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  --create-namespace \
  --set grafana.adminPassword=changeme \
  --set prometheus.prometheusSpec.retention=15d \
  --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.storageClassName=local-path \
  --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=20Gi

# Access Grafana
kubectl port-forward svc/kube-prometheus-stack-grafana 3000:80 -n monitoring

The stack ships with pre-built dashboards for node metrics, pod resource usage, Kubernetes API server, and more. Add an Ingress resource to expose Grafana permanently with TLS.


Maintenance and Operations

Upgrading K3s

Option 1: The System Upgrade Controller (recommended)

The System Upgrade Controller is a K3s-native way to manage rolling upgrades declaratively:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
kubectl apply -f https://github.com/rancher/system-upgrade-controller/releases/latest/download/system-upgrade-controller.yaml

# Create a Plan to upgrade to the latest stable version
kubectl apply -f - <<EOF
apiVersion: upgrade.cattle.io/v1
kind: Plan
metadata:
  name: k3s-server
  namespace: system-upgrade
spec:
  concurrency: 1
  cordon: true
  serviceAccountName: system-upgrade
  upgrade:
    image: rancher/k3s-upgrade
  channel: https://update.k3s.io/v1-release/channels/stable
  nodeSelector:
    matchExpressions:
      - key: node-role.kubernetes.io/control-plane
        operator: In
        values: ["true"]
EOF

Option 2: Manual upgrade

1
2
# Re-run the install script with the target version
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="v1.32.0+k3s1" sh -

Backup and Restore

SQLite backup (default datastore):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# K3s writes snapshots automatically to /var/lib/rancher/k3s/server/db/snapshots/
# Manual snapshot
sudo k3s etcd-snapshot save --name pre-upgrade-$(date +%Y%m%d)

# List snapshots
sudo k3s etcd-snapshot ls

# Restore (stop k3s first)
sudo systemctl stop k3s
sudo k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/pre-upgrade-20260325
sudo systemctl start k3s

Velero: full cluster backup including PVCs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Install Velero with S3 backend (Backblaze B2, MinIO, AWS S3, etc.)
velero install \
  --provider aws \
  --plugins velero/velero-plugin-for-aws:v1.10.0 \
  --bucket k3s-backups \
  --backup-location-config region=us-east-1,s3Url=https://s3.us-east-1.backblazeb2.com \
  --use-node-agent \
  --secret-file ./velero-credentials

# Create a backup
velero backup create daily-backup --include-namespaces '*'

# Schedule daily backups
velero schedule create daily --schedule="@daily" --include-namespaces '*'

Node Maintenance

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Cordon a node (prevent new pods from scheduling on it)
kubectl cordon k3s-node-2

# Drain a node (evict all pods, then cordon)
kubectl drain k3s-node-2 \
  --ignore-daemonsets \
  --delete-emptydir-data \
  --grace-period=60

# After maintenance, uncordon
kubectl uncordon k3s-node-2

Debugging

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Inspect a pod's state and recent events
kubectl describe pod <pod-name> -n <namespace>

# View all cluster events sorted by time
kubectl get events -A --sort-by=.lastTimestamp

# Follow logs from all pods matching a label selector
kubectl logs -l app=myapp -n default --follow

# Debug a crashing pod by overriding the command
kubectl debug -it <pod-name> -n <namespace> \
  --image=busybox \
  --copy-to=debug-pod \
  -- sh

# Check why a pod is pending
kubectl describe pod <pod-name> | grep -A 10 Events

Security Basics

RBAC

Kubernetes RBAC controls who can do what to which resources. The key objects:

  • ServiceAccount: an identity for pods or external tools
  • Role / ClusterRole: a set of permissions
  • RoleBinding / ClusterRoleBinding: binds a subject to a role

Example: give a monitoring ServiceAccount read access to pods and nodes:

 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
apiVersion: v1
kind: ServiceAccount
metadata:
  name: monitoring-reader
  namespace: monitoring
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: pod-node-reader
rules:
  - apiGroups: [""]
    resources: ["pods", "nodes", "nodes/metrics"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: monitoring-reader-binding
subjects:
  - kind: ServiceAccount
    name: monitoring-reader
    namespace: monitoring
roleRef:
  kind: ClusterRole
  name: pod-node-reader
  apiGroup: rbac.authorization.k8s.io

NetworkPolicies

By default, all pods can talk to all other pods. NetworkPolicies enforce pod-level firewall rules. With Flannel (K3s default), you need to replace it with a CNI that supports NetworkPolicy (Calico, Cilium) or install a NetworkPolicy engine alongside Flannel.

Example: deny all ingress to pods in a namespace, then allow only from a specific namespace:

 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: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all-ingress
  namespace: myapp
spec:
  podSelector: {}   # Applies to all pods in the namespace
  policyTypes:
    - Ingress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-from-ingress
  namespace: myapp
spec:
  podSelector:
    matchLabels:
      app: web
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - port: 8000

Pod Security Standards

Kubernetes has built-in Pod Security Standards (PSS) that replace the deprecated PodSecurityPolicy. Three levels: privileged, baseline, and restricted. Apply them per namespace via labels:

1
2
3
4
# Enforce the baseline standard on the myapp namespace
kubectl label namespace myapp \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/warn=restricted

Keeping Secrets Out of Manifests

Option 1: Sealed Secrets — encrypts secrets with a cluster-specific key so you can safely commit them to git:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Install Sealed Secrets controller
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
  -n kube-system

# Install kubeseal CLI
curl -L https://github.com/bitnami-labs/sealed-secrets/releases/latest/download/kubeseal-linux-amd64 \
  -o /usr/local/bin/kubeseal && chmod +x /usr/local/bin/kubeseal

# Seal a secret
kubectl create secret generic myapp-secret \
  --from-literal=API_KEY=supersecret \
  --dry-run=client -o yaml | \
  kubeseal -o yaml > myapp-sealed-secret.yaml

# The SealedSecret is safe to commit to git
git add myapp-sealed-secret.yaml

Option 2: External Secrets Operator — syncs secrets from external vaults (HashiCorp Vault, AWS Secrets Manager, Bitwarden, 1Password) into Kubernetes Secrets. Better for teams with an existing secrets store.


Conclusion

K3s makes running Kubernetes in a homelab genuinely achievable without a background in cloud infrastructure. The install is a one-liner, the default configuration is sensible, and the full Kubernetes ecosystem — Helm charts, GitOps tools, CNCF projects — works without modification.

The migration from Docker Compose doesn’t have to be all-or-nothing. Many homelabbers run Compose for simple, single-service stacks and K3s for anything that benefits from self-healing, rolling updates, or multi-node scheduling. Use Kompose to get your manifests bootstrapped, then invest the time to add resource limits, probes, proper secrets management, and an ingress setup you understand.

Where K3s really pays off is when you commit to a GitOps workflow. With Flux or ArgoCD watching your repo, your cluster becomes reproducible, auditable, and recoverable. Every change is a commit, every rollback is a revert, and the cluster’s state is always visible in your git history.

The tools covered here — cert-manager, Longhorn, Helm, kube-prometheus-stack, Sealed Secrets — form the foundation of a production-quality homelab cluster. From here, explore Kustomize for environment-specific overlays, Cilium for advanced networking, and Velero for backup automation. The Kubernetes ecosystem is large, but K3s gives you a stable, low-overhead base to build from.

Comments