I recently migrated my homelab from Docker + Jenkins to K3S because it was my production infrastructure that runs K3S on AWS, and my homelab should mirror that same flow.
This guide documents exactly how I set up K3S, MetalLB, Traefik, cert-manager with Let's Encrypt, and ArgoCD. All on a single Ubuntu 26.04 VM.
One important thing most 2025 tutorials won't tell you is that ingress-nginx was archived on March 24, 2026, which means no more releases or security patches. The good news is K3S ships Traefik v3 as its default ingress controller, so you get a maintained, production-grade ingress out of the box.
What We're Building
| Component | Role |
|---|---|
| K3s v1.33 | Lightweight Kubernetes runtime |
| MetalLB v0.16.1 | LoadBalancer IP assignment for bare metal |
| Traefik v3 | Ingress controller (bundled with K3s) |
| cert-manager | Automated TLS certificate management |
| Let's Encrypt (HTTP-01) | Free, trusted certificates |
| ArgoCD | GitOps continuous delivery |
| Helm 3 | Package manager |
Prerequisites
- A VM or server running Ubuntu 24.04 and above
- A public domain with an A record pointing to your server's public IP (Optional)
- Port 80 and 443 open on your server/firewall (required for Let's Encrypt HTTP-01 challenge)
- A local IP subnet for MetalLB (e.g.
192.168.1.0/24) — adjust to match your network
Step 1: System Prep
Before installing anything, get the system into the right state.
# Update system
sudo apt update && sudo apt upgrade -y
# Install dependencies
sudo apt install -y curl wget git open-iscsi nfs-common
# Disable swap (Kubernetes requires this)
sudo swapoff -a
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
# Load required kernel modules
sudo modprobe overlay
sudo modprobe br_netfilter
# Persist kernel modules across reboots
cat <<EOF | sudo tee /etc/modules-load.d/k3s.conf
overlay
br_netfilter
EOF
# Set required sysctl parameters
cat <<EOF | sudo tee /etc/sysctl.d/k3s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
Step 2: Install K3s
K3s ships with its own built-in ServiceLB (Klipper). We disable it because MetalLB will handle LoadBalancer IP assignment instead. Traefik stays on.
curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=stable sh -s - \
--disable servicelb \
--write-kubeconfig-mode 644
# Wait for node to be ready
sudo kubectl wait --for=condition=ready node --all --timeout=120s
# Verify
sudo kubectl get nodes
sudo kubectl get pods -A
Set up kubeconfig for your non-root user:
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $USER:$USER ~/.kube/config
chmod 600 ~/.kube/config
# Persist in shell
echo 'export KUBECONFIG=~/.kube/config' >> ~/.bashrc
source ~/.bashrc
# Confirm it works
kubectl get nodes
You should see your node in Ready state.
Step 3: Install Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Verify
helm version
Step 4: Install MetalLB
On bare metal and VMs, LoadBalancer type services stay stuck in <pending> forever without a load balancer controller. MetalLB fixes this by assigning real IPs from a pool you define.
# Add the MetalLB Helm repo
helm repo add metallb https://metallb.github.io/metallb
helm repo update
# Install
helm install metallb metallb/metallb \
--namespace metallb-system \
--create-namespace \
--wait
# Confirm pods are running
kubectl get pods -n metallb-system
Now configure the IP address pool. Pick a range from your local subnet that is outside your router's DHCP range to avoid conflicts. Adjust 192.168.1.200-192.168.1.220 to match your network:
cat <<EOF | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: homelab-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.200-192.168.1.220
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: homelab-l2
namespace: metallb-system
spec:
ipAddressPools:
- homelab-pool
EOF
# Verify
kubectl get ipaddresspool -n metallb-system
kubectl get l2advertisement -n metallb-system
Step 5: Verify Traefik
K3S already installed Traefik v3 during setup. You don't need to install it separately. Just confirm it's running and that MetalLB has assigned it an IP:
# Traefik should be running in kube-system
kubectl get pods -n kube-system | grep traefik
# The service should now have an EXTERNAL-IP from your MetalLB pool
kubectl get svc traefik -n kube-system
You should see an IP in the 192.168.1.200-220 range (or whichever range you set) in the EXTERNAL-IP column. That's MetalLB doing its job.
Step 6: Install cert-manager
cert-manager automates the full lifecycle of TLS certificates — requesting, renewing, and storing them as Kubernetes secrets. We use the HTTP-01 challenge, which means Let's Encrypt will verify your domain by making an HTTP request to your server.
# Add the Jetstack 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 \
--wait
# Verify all 3 pods are running: controller, cainjector, webhook
kubectl get pods -n cert-manager
Now create a ClusterIssuer to tell cert-manager how to obtain certificates:
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your@email.com # ← replace with your email
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: traefik
EOF
# Verify the issuer is ready
kubectl get clusterissuer letsencrypt-prod
The STATUS should show True under READY.
Step 7: Install ArgoCD
ArgoCD gives you GitOps-based deployments — your cluster state is driven by Git, not manual kubectl apply commands. It's the right pattern for a homelab that mirrors how production should work.
# Add the Argo Helm repo
helm repo add argo https://argoproj.github.io/argo-helm
helm repo update
# Install ArgoCD
helm install argocd argo/argo-cd \
--namespace argocd \
--create-namespace \
--set server.service.type=LoadBalancer \
--wait
# Verify pods are running
kubectl get pods -n argocd
# Get the external IP (assigned by MetalLB)
kubectl get svc argocd-server -n argocd
# Get the initial admin password
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d && echo
Access the ArgoCD UI at https://<EXTERNAL-IP> with username admin and the password above. Change the password after the first login.
Step 8: Smoke Test
Deploy a quick test app to confirm the full stack is working end to end:
# Deploy a test nginx pod
kubectl create deployment test-app --image=nginx:alpine
# Expose it as a LoadBalancer service
kubectl expose deployment test-app \
--port=80 \
--type=LoadBalancer \
--name=test-app-svc
# Watch for MetalLB to assign an external IP
kubectl get svc test-app-svc --watch
# Once an IP appears, curl it
curl http://<EXTERNAL-IP>
# Clean up
kubectl delete deployment test-app
kubectl delete svc test-app-svc
If you get an nginx welcome page, everything is wired up correctly.
How to Deploy an App with TLS
Once you deploy a real app, here's what an Ingress resource looks like using Traefik + cert-manager. Just add the annotation, and cert-manager handles the certificate request automatically:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
namespace: default
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: traefik
rules:
- host: myapp.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app-svc
port:
number: 80
tls:
- hosts:
- myapp.yourdomain.com
secretName: myapp-tls
Apply it, and cert-manager will automatically:
- Detect the
cert-manager.io/cluster-issuerannotation - Create a Certificate resource
- Complete the HTTP-01 challenge with Let's Encrypt
- Store the issued cert in the
myapp-tlssecret - Renew it automatically before expiry
What's Next
With this foundation in place, here's what I'd layer on next:
- Prometheus + Grafana: observability for the cluster (you already have prom-client experience, this maps directly)
- Gitea + Woodpecker CI: lightweight self-hosted CI that replaces Jenkins
- Harbor: private container registry running inside the cluster
- Longhorn: persistent storage for stateful workloads
The goal is a homelab that serves as a direct rehearsal environment for production.
Summary
System prep → K3s → Helm → MetalLB → Traefik (built-in) → cert-manager → ArgoCD
Every piece here is actively maintained in 2026. No ingress-nginx, no Klipper ServiceLB conflicts, no Jenkins.
If this helped you, drop a comment with what you're running on your homelab. Always curious what other engineers are self-hosting.
Top comments (0)