Build a Homelab Kubernetes Cluster
Set up k3s on a single server with MetalLB, Longhorn, and a microservices app.
Introduction
30 Second Summary
Running your own services at home means you control the hardware, the network, and the monthly bill (which is zero). The missing piece is a platform that can orchestrate containers on your server the way the cloud does.
In this project, you will build a single-node Kubernetes cluster using k3s on your Ubuntu server, configure MetalLB for bare-metal load balancing and Longhorn for persistent storage, then deploy a microservices application that proves the full stack works end-to-end.
What You'll Build
A production-grade homelab Kubernetes platform on a single physical server, capable of running containerized microservices with real networking, persistent storage, and Ingress routing accessible from any device on your LAN.
By the end of this project, you'll have:
- A single-node k3s cluster running on your Ubuntu 24.04 server with Helm configured for chart-based deployments.
- A complete homelab platform with MetalLB L2 load balancing providing real IPs on your LAN and Longhorn persistent storage managing volumes with snapshots and backups.
- A deployed microservices application proving the full stack works: Ingress routing, LoadBalancer IPs, and persistent volumes.
- Secret Mission: Add a Prometheus + Grafana monitoring stack to observe cluster health from a LAN-accessible dashboard.
Are there any prerequisites?
This project assumes you have one Ubuntu 24.04 LTS server (physical or dedicated VM) with at least 32GB RAM, connected to your local network, with Docker already installed. You should also be comfortable with Kubernetes concepts like Deployments, Services, Ingress, and PVCs.
Install k3s Cluster and Helm
Your Ubuntu server is ready with Docker installed, but you need a way to orchestrate containers with proper networking, storage, and service discovery.
In this step, you will install k3s (a lightweight Kubernetes distribution) to run a single-node cluster, then install Helm for managing chart-based deployments in later steps.
In this step, get ready to:
- Disable the Docker daemon to avoid runtime conflicts.
- Install k3s server on your Ubuntu machine.
- Install Helm and configure it to use the k3s kubeconfig.
Disable the Docker daemon
k3s bundles its own containerd runtime. Running the Docker daemon alongside it causes iptables conflicts that break pod networking.
Your Docker CLI and Dockerfiles remain intact. Only the background daemon is stopped.
- Stop and disable the Docker daemon by running this command:
sudo systemctl disable --now docker docker.socket containerd
- Verify the Docker daemon is stopped by running:
sudo systemctl status docker
You should see inactive (dead) in the output confirming Docker is no longer running.
Why disable Docker instead of uninstalling it?
Both Docker and k3s use iptables rules to route container traffic. When both are active, their rules conflict and pods lose network connectivity.
Disabling the daemon keeps your Docker CLI available for building images locally. Only the background service that manages running containers is stopped.
🙋♀️ Docker still showing as active?
If systemctl status docker still shows active, try running sudo systemctl stop docker manually and then re-run the disable command.
Make sure you include both docker.socket and containerd in the command. The socket can re-activate the daemon on its own.
help me troubleshoot Docker daemon not stopping on my Ubuntu server
Install k3s server
With Docker out of the way, you can now install k3s. Your server runs both the control plane and workloads on a single node.
- Install k3s with the built-in ServiceLB disabled (you will replace it with MetalLB later) by running:
curl -sfL https://get.k3s.io | sh -s - --disable servicelb
What does this command do?
The install script downloads the k3s binary, sets it up as a systemd service, and starts the server automatically. It also installs kubectl, crictl, and ctr as bundled utilities.
The --disable servicelb flag prevents k3s from deploying its built-in load balancer (klipper-lb). You will install MetalLB in a later step to handle this instead.
- Verify the server is running by checking the node status:
sudo kubectl get nodes
You should see your node listed with STATUS Ready. It may take 15-30 seconds to reach Ready state.
Node showing NotReady?
Wait 30 seconds and re-run the command. k3s needs time to download container images for its internal components.
If it stays NotReady for over 2 minutes, check the k3s logs with sudo journalctl -u k3s for errors related to networking or containerd.
help me troubleshoot my k3s node stuck in NotReady status
Install Helm
Helm is a package manager for Kubernetes that lets you install complex applications (like MetalLB and Longhorn) using pre-built charts.
- Install Helm by running the official install script:
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
What does this script do?
The script auto-detects your system architecture and downloads the appropriate Helm binary to /usr/local/bin/helm.
- Configure Helm to use the k3s kubeconfig by running:
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
helm version
You should see Helm's version string printed (e.g. version.BuildInfo{Version:"v3.x.x"...}). This confirms Helm can communicate with your cluster.
- Persist the KUBECONFIG setting so it survives new terminal sessions:
echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc
Why set KUBECONFIG?
k3s writes its kubeconfig to /etc/rancher/k3s/k3s.yaml instead of the default ~/.kube/config location. Without this export, Helm and other tools cannot find the cluster credentials.
🙋♀️ Helm version showing a connection error?
Make sure you ran the export KUBECONFIG=/etc/rancher/k3s/k3s.yaml command in the same terminal session before running helm version.
If you see a permission denied error, try running sudo chmod 644 /etc/rancher/k3s/k3s.yaml to allow your user to read the kubeconfig.
help me fix Helm not connecting to my k3s cluster
Your single-node cluster is running and Helm is ready. Next up, you will install MetalLB to give your services real IP addresses on your LAN.
Install MetalLB for Bare-Metal Load Balancing
Your k3s cluster is up and running with your node in Ready state. Now you need a way for services inside the cluster to get real IP addresses on your LAN.
In cloud environments, Kubernetes can request LoadBalancer IPs from the cloud provider automatically. On bare metal, there is no cloud API to call. MetalLB fills that gap by announcing IPs from a pool you define, using Layer 2 ARP on your local network.
In this step, get ready to:
- Deploy MetalLB in native mode to the cluster.
- Configure an IP address pool and L2 advertisement.
- Verify Traefik receives a LoadBalancer IP from the pool.
Deploy MetalLB using native manifests
MetalLB has two deployment modes: native (lightweight, built-in L2 and simple BGP) and FRR-K8s (full BGP with BFD). For a homelab that only needs L2, native mode is the right choice.
- Apply the MetalLB v0.16.1 native manifests by running this command:
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.16.1/config/manifests/metallb-native.yaml
What does this command do?
This downloads and applies all the Kubernetes resources MetalLB needs: a metallb-system namespace, the controller Deployment (handles IP assignment), the speaker DaemonSet (announces IPs via ARP), and the required RBAC permissions.
- Wait for all MetalLB pods to reach Ready state by running:
kubectl wait --namespace metallb-system --for=condition=ready pod --selector=app=metallb --timeout=90s
You should see output confirming that multiple pods have met the condition.
Pods not reaching Ready?
If the wait times out, check pod status with kubectl get pods -n metallb-system. Look for CrashLoopBackOff or ImagePullBackOff in the STATUS column.
CrashLoopBackOff usually means a version mismatch. ImagePullBackOff means the node cannot reach the container registry. Confirm your server has internet access and retry.
Help me troubleshoot MetalLB pods that are not starting
Configure an L2 address pool and advertisement
MetalLB is running, but it has no IP addresses to hand out yet. You need to tell it which IPs on your LAN it can assign to LoadBalancer services.
- Create a file called metallb-config.yaml on your server by running:
cat > metallb-config.yaml << 'EOF'
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
What does this configuration do?
The IPAddressPool defines the range of IPs MetalLB can assign. These must be IPs on your LAN subnet that your router will NOT assign via DHCP.
The L2Advertisement tells MetalLB to announce those IPs using Layer 2 ARP. When a service requests a LoadBalancer IP, MetalLB picks one from the pool and responds to ARP requests for it on the network.
💡 Adjust the IP range to your network
The range 192.168.1.200-192.168.1.220 is a placeholder. Check your router's DHCP settings and pick a range of free IPs on your subnet that your router will not hand out. For example, if your subnet is 10.0.0.0/24, you might use 10.0.0.200-10.0.0.220.
- Apply the configuration by running:
kubectl apply -f metallb-config.yaml
You should see two resources created: the IPAddressPool and the L2Advertisement.
Seeing a validation error?
If you see an error about unknown fields or invalid API version, confirm the MetalLB CRDs were installed in the previous step. Run kubectl get crd | grep metallb to check. You should see CRDs like ipaddresspools.metallb.io listed.
Help me fix MetalLB configuration errors
Verify MetalLB assigned an IP to Traefik
k3s ships Traefik as its default Ingress controller, exposed as a LoadBalancer service. Now that MetalLB is running with a configured pool, Traefik should automatically receive an external IP.
- Check the Traefik service by running:
kubectl get svc -n kube-system traefik
The EXTERNAL-IP column should now show an IP from your MetalLB pool (for example, 192.168.1.200).
- Test that Traefik is responding on that IP by running:
curl http://[[TRAEFIK_LB_IP="your Traefik external IP from the command above"]]
You should see a 404 response. This is expected because Traefik has no Ingress routes configured yet, but the response proves the full chain is working: MetalLB assigned the IP, Traefik is listening on it, and your node is advertising it on the LAN.
EXTERNAL-IP still showing Pending?
If the Traefik service still shows <pending> in the EXTERNAL-IP column, MetalLB has not assigned an IP.
Confirm the MetalLB speaker pod is running with kubectl get pods -n metallb-system -o wide. Then confirm your IPAddressPool was applied correctly with kubectl get ipaddresspool -n metallb-system.
Help me figure out why MetalLB is not assigning an external IP
✔️ Awesome, I've got everything!
Great. Double check you have metallb-config.yaml saved and that Traefik has a real external IP.
ⓧ I'd like to double check the full code
Here is the complete 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
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: homelab-l2
namespace: metallb-system
spec:
ipAddressPools:
- homelab-pool
Your cluster now has real LoadBalancer capabilities on your LAN. Next up, you'll install Longhorn to give the cluster persistent storage with snapshot and backup support.
Install Longhorn for Distributed Storage
Install Longhorn for Persistent Storage
Your cluster now has real load balancing via MetalLB. Any service you create with type LoadBalancer gets a LAN-accessible IP. The next piece of the puzzle is persistent storage.
Kubernetes pods are ephemeral. When a pod restarts, any data it held disappears. Longhorn solves this by providing block storage that automatically manages volumes, snapshots, and backups. Even on a single node, Longhorn gives you a proper StorageClass with volume lifecycle management.
In this step, get ready to:
- Install iSCSI and NFS prerequisites.
- Deploy Longhorn via Helm.
- Set Longhorn as the default StorageClass for your cluster.
Install Longhorn prerequisites
Longhorn's V1 data engine uses iSCSI under the hood to mount volumes into pods. It also needs NFS support for ReadWriteMany volumes and backups.
- Install the prerequisites by running these commands:
sudo apt-get update
sudo apt-get install -y open-iscsi nfs-common
sudo systemctl enable --now iscsid
What do these packages do?
open-iscsi provides the iSCSI initiator that Longhorn uses to attach block storage volumes to pods. The iscsid daemon must be running for volumes to mount correctly.
nfs-common provides NFS client utilities. Longhorn needs this for RWX (ReadWriteMany) volumes and for backup operations.
- Verify the iscsid service is running by running:
sudo systemctl status iscsid
You should see active (running) in the output.
iscsid not active?
If the service shows as inactive or failed, try starting it explicitly with sudo systemctl start iscsid and then re-check the status.
If you see a package-not-found error during installation, make sure sudo apt-get update ran successfully first.
Help me troubleshoot iscsid not starting on my Ubuntu server
Install Longhorn via Helm
With the prerequisites in place, you can deploy Longhorn itself.
- Add the Longhorn Helm chart repository and install Longhorn by running these commands:
helm repo add longhorn https://charts.longhorn.io
helm repo update
helm install longhorn longhorn/longhorn --namespace longhorn-system --create-namespace --version 1.12.0
What does this command do?
- helm repo add registers the Longhorn chart repository so Helm knows where to fetch the package.
- helm repo update downloads the latest chart index from all registered repositories.
- helm install deploys Longhorn 1.12.0 into its own namespace (longhorn-system), creating the namespace if it does not exist.
- Longhorn takes 1-2 minutes to spin up all its pods. Watch the rollout by running:
kubectl -n longhorn-system get pod --watch
Wait until all pods show Running or Completed in the STATUS column. Press Ctrl+C to stop watching once all pods are ready.
Pods stuck in CrashLoopBackOff or Pending?
If pods remain in CrashLoopBackOff, the most common cause is that iscsid is not running. Go back and verify with sudo systemctl status iscsid.
If pods are stuck in Pending, check that your node is in Ready state with kubectl get nodes.
Help me debug Longhorn pods that are not starting
Verify the Longhorn StorageClass
When Longhorn deploys successfully, it registers a StorageClass so that any PersistentVolumeClaim can automatically get a Longhorn-backed volume.
- Confirm Longhorn's StorageClass is registered by running:
kubectl get storageclass
You should see longhorn listed alongside the existing local-path StorageClass that k3s ships by default.
- Set longhorn as the default StorageClass and remove the default annotation from local-path by running these commands:
kubectl patch storageclass longhorn -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
Why set Longhorn as the default?
The default StorageClass is what Kubernetes uses when a PVC does not explicitly name a storageClassName. By making Longhorn the default, every new volume automatically gets managed storage with snapshot and backup capabilities instead of a basic local-path volume.
- Verify the default was updated by running:
kubectl get storageclass
You should see longhorn (default) in the output. The local-path StorageClass should no longer show the default tag.
Don't see the longhorn StorageClass?
If longhorn is missing from the StorageClass list, the Longhorn CSI driver pods are likely not yet Running. Check with kubectl -n longhorn-system get pod and wait for all pods to reach Running state.
If both patch commands ran without error but kubectl get storageclass still shows local-path as default, double check the JSON in the patch command for missing quotes or braces.
Help me fix my Longhorn StorageClass not appearing
Your cluster now has persistent storage. Any volume you create will be managed by Longhorn with snapshot support. Next up, you will deploy a microservices application that puts the entire stack to the test.
Deploy a Microservices Application
Your cluster now has all three layers of a production platform: compute (k3s), networking (MetalLB), and storage (Longhorn). The real test of any infrastructure is whether it can run a real workload end-to-end.
In this step, you will deploy a multi-service application that exercises every layer at once. A stateless web service proves Ingress routing and load balancing work. A stateful Redis service proves Longhorn persistent volumes work.
In this step, get ready to:
- Create application manifests defining a namespace, services, and an Ingress.
- Deploy and verify pods, services, and persistent volumes.
- Access the application from any device on your LAN through Traefik.
Create the application manifests
The demo application has two services. A stateless traefik/whoami web service returns request details (hostname, IP, headers) so you can confirm routing works. A stateful Redis instance stores data on a Longhorn-backed persistent volume.
- Create a file called demo-app.yaml in your home directory.
- Add the namespace, persistent volume claim, Redis deployment, and Redis service by pasting this content:
apiVersion: v1
kind: Namespace
metadata:
name: demo
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: demo
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: demo
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: demo
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
What does this YAML define?
- Namespace demo isolates all resources for this application from system workloads.
- PersistentVolumeClaim redis-data requests 1Gi of storage from Longhorn. This is what proves your persistent storage works.
- The Redis Deployment runs one replica of redis:7-alpine and mounts the PVC at /data so data survives pod restarts.
- The Redis Service exposes port 6379 inside the cluster so other pods could connect to it.
You won't see any changes yet. The next chunk adds the stateless web layer.
- Below the Redis Service in demo-app.yaml, add the whoami deployment, whoami service, and Ingress resource:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
namespace: demo
spec:
replicas: 3
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- name: whoami
image: traefik/whoami:v1.10
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: whoami
namespace: demo
spec:
selector:
app: whoami
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
namespace: demo
spec:
rules:
- host: whoami.homelab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
number: 80
What does this section define?
- The whoami Deployment runs 3 replicas of traefik/whoami:v1.10. This lightweight HTTP service echoes back the request hostname, IP, and headers.
- The whoami Service exposes port 80 internally so Traefik can route to it.
- The Ingress tells Traefik to route any request with Host: whoami.homelab.local to the whoami Service. This is how you access the app from your LAN.
- Save demo-app.yaml.
You won't see changes on the cluster yet. The next substep applies the manifests.
✔️ Awesome, I've got everything!
Great. Double check you have saved demo-app.yaml with all seven resources (Namespace, PVC, two Deployments, two Services, one Ingress).
ⓧ I'd like to double check the full code
apiVersion: v1
kind: Namespace
metadata:
name: demo
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: demo
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: demo
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: demo
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
namespace: demo
spec:
replicas: 3
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- name: whoami
image: traefik/whoami:v1.10
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: whoami
namespace: demo
spec:
selector:
app: whoami
ports:
- port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: whoami
namespace: demo
spec:
rules:
- host: whoami.homelab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: whoami
port:
number: 80
Deploy and verify the application
With the manifest file ready, one command deploys all seven resources at once.
- Apply the manifests to your cluster by running:
kubectl apply -f demo-app.yaml
What does this command do?
kubectl apply -f reads every YAML document separated by --- and creates or updates each resource on the cluster. Because the Namespace is defined first, Kubernetes creates it before the resources that live inside it.
- Wait about 30 seconds for the images to pull, then check that all pods are running:
kubectl -n demo get pods
You should see 1 Redis pod and 3 whoami pods, all with STATUS: Running.
- Verify the PVC is bound to a Longhorn volume by running:
kubectl -n demo get pvc
You should see redis-data with STATUS: Bound and STORAGECLASS: longhorn. This confirms Longhorn provisioned a managed volume for your application.
Pods stuck in Pending or ContainerCreating?
- If the Redis pod is Pending, check that the PVC is Bound. Run kubectl -n demo describe pvc redis-data and look for events. Longhorn needs iscsid running on your server.
- If whoami pods are stuck in ContainerCreating, the image may still be pulling. Wait another minute and re-check.
- If you see ImagePullBackOff, confirm your server has internet access by running curl -I https://hub.docker.com.
Why are my pods stuck in Pending or ContainerCreating?
Access the application from your LAN
The final proof that your full stack works is accessing the application from any machine on your network. Traefik (the bundled Ingress controller) already has a MetalLB external IP from the previous step.
- Get the Traefik LoadBalancer IP by running:
kubectl get svc -n kube-system traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
What does this command do?
The -o jsonpath flag extracts a single value from the full JSON output. This pulls just the external IP that MetalLB assigned to Traefik, without the surrounding table formatting.
- Test the whoami service through the Ingress by running:
curl -H "Host: whoami.homelab.local" http://[[TRAEFIK_LB_IP="your Traefik LoadBalancer IP from the command above"]]
What does this prove?
This single curl request exercises every layer of your platform. MetalLB gave Traefik a real LAN IP. Traefik matched the Host header to your Ingress rule. The Ingress routed to the whoami Service, which load-balanced across your 3 whoami pods.
The response shows the pod's hostname and IP, proving traffic made it all the way through.
You should see output including Hostname:, IP:, and the request headers. This confirms the full stack is operational.
Add a DNS entry for browser access
To access the service from your browser without the -H flag, add an entry to /etc/hosts on your laptop (or your router's DNS) pointing whoami.homelab.local to the Traefik LB IP.
🙋♀️ Getting a 404 or connection refused?
- A 404 from Traefik means the Ingress rule did not match. Check that the Host header in your curl exactly matches whoami.homelab.local (case-sensitive).
- Connection refused means the IP is wrong or Traefik is not listening. Run kubectl get svc -n kube-system traefik and confirm the EXTERNAL-IP column has an IP from your MetalLB pool.
- If the IP is <pending>, MetalLB may not have started yet. Check MetalLB pods with kubectl -n metallb-system get pods.
Help me debug why my curl to Traefik is not reaching the whoami service.
Secret mission
Add Prometheus + Grafana Monitoring
Your cluster is running, but can you see what's happening inside it? Add a full monitoring stack with Prometheus and Grafana to observe CPU, memory, and pod metrics in real time, accessible from any device on your LAN.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local server, so there are no ongoing cloud costs. Decide whether to keep your cluster running, pause it to free up resources, or delete everything entirely.
Resources you used:
- k3s server service on your Ubuntu server.
- All cluster workloads (MetalLB in metallb-system, Longhorn in longhorn-system, demo app in demo, monitoring stack in monitoring).
- Helm binary at /usr/local/bin/helm.
- YAML manifest files (metallb-config.yaml, demo-app.yaml, grafana-ingress.yaml).
- Docker daemon disabled on your server.
Keep everything running
No action needed. Choose this if you are still actively building on your homelab cluster.
- The k3s service auto-starts on reboot via systemd. Your cluster comes back up automatically after a power cycle.
- Your homelab is ready for deploying your own containerized microservices with full load balancing, persistent storage, and ingress routing.
- Docker daemon remains disabled. If you need Docker for other work on this server, re-enable it separately, but be aware that running Docker alongside k3s can cause networking conflicts.
Pause - I'll come back to this later
Stop the k3s service to free up CPU and memory. All configuration and data remain on disk so you can restart the cluster when you are ready.
- Stop k3s by running:
sudo systemctl stop k3s
- To resume later, start the service again:
sudo systemctl start k3s
- After restarting, verify your node is Ready by running sudo kubectl get nodes.
Delete - I don't want to use this again
Remove all k3s components, cluster data, and workloads from your server. This is irreversible.
- Run the k3s uninstall script:
sudo /usr/local/bin/k3s-uninstall.sh
What does the uninstall script remove?
This script stops k3s, deletes the local cluster datastore, Local Storage Persistent Volume data, node configuration, and all k3s scripts and CLI tools. All your cluster workloads (MetalLB, Longhorn, the demo app, the monitoring stack) are removed as part of the cluster data.
- Remove the Helm binary:
sudo rm /usr/local/bin/helm
- Remove the YAML manifest files:
rm metallb-config.yaml demo-app.yaml grafana-ingress.yaml
- Re-enable the Docker daemon if you want Docker back:
sudo systemctl enable --now docker
Nice Work!
Nice Work!
You just built a production-grade homelab Kubernetes platform on your Ubuntu server. Your cluster is running real workloads with proper networking, persistent storage, and ingress routing accessible from any device on your LAN.
You've learned how to:
- Build a single-node k3s cluster on Ubuntu 24.04, including disabling Docker's daemon to avoid containerd conflicts.
- Configure MetalLB in L2 mode to provide real LoadBalancer IPs on your LAN, replacing k3s's built-in ServiceLB with bare-metal load balancing.
- Deploy Longhorn persistent storage for managed volumes with snapshot support, and verify the full stack with a microservices application using Ingress, LoadBalancer, and PVCs.
- Secret Mission: Added a Prometheus + Grafana monitoring stack with LAN-accessible dashboards showing CPU, memory, and pod metrics.
Ready to quiz yourself?