How to Install a Single-Node K3s Cluster on Ubuntu with MetalLB for Local LAN IPs

As development teams, homelab enthusiasts, and edge engineers outgrow standard Docker Compose architectures, Kubernetes naturally emerges as the next frontier for declarative orchestration. However, standard upstream Kubernetes (kubeadm) carries massive resource overhead and operational complexity that is ill-suited for single-node machines, edge gateways, or local test environments.

Kubernetes engineer configuring single-node K3s cluster with MetalLB on Ubuntu
How to Install a Single-Node K3s Cluster on Ubuntu with MetalLB for Local LAN IPs 3

To solve this, Rancher developed K3s: a lightweight, fully compliant, CNCF-certified Kubernetes distribution packaged as a single binary with a memory footprint under 512 MB. Yet, engineers deploying K3s on bare-metal servers or local hypervisors immediately run into a notorious networking roadblock: how do you expose Kubernetes LoadBalancer services with real, dedicated IP addresses from your local network?

In managed cloud environments (like AWS EKS or GCP GKE), creating a type: LoadBalancer service automatically provisions a cloud load balancer. In bare-metal K3s, services remain stuck in <pending> status unless you use a dedicated bare-metal load balancer. While K3s bundles a rudimentary proxy named ServiceLB (Klipper-LB), it binds host ports directly and cannot assign clean, dedicated subnet IPs.

In this comprehensive tutorial, you will learn how to deploy a production-grade single-node K3s cluster on Ubuntu with ServiceLB disabled, followed by installing and configuring MetalLB in Layer 2 mode to hand out true static LAN IP addresses to your containerized workloads.

Why Replace K3s ServiceLB with MetalLB?

Understanding the architectural difference between K3s’s default load balancer and MetalLB is vital for clean network design:

  • The Problem with ServiceLB (Klipper-LB): K3s’s built-in ServiceLB deploys daemonset pods that use hostPort bindings. This means if you expose an Nginx service on port 80, it monopolizes port 80 across all node IP addresses. You cannot assign distinct, dedicated IP addresses (e.g., 192.168.1.200 for your ingress and 192.168.1.201 for your monitoring stack).
  • The MetalLB Advantage: MetalLB acts as a genuine network load balancer. In Layer 2 mode, it monitors Kubernetes LoadBalancer services, allocates unused IPv4 addresses from a designated pool, and responds to ARP requests on your local network interface. Devices on your local network can reach your Kubernetes pods directly via dedicated LAN IPs without port collisions.

Technical Prerequisites

Before proceeding, ensure your environment meets the following requirements:

  1. Operating System: A fresh installation of Ubuntu 22.04 LTS or Ubuntu 24.04 LTS.
  2. Hardware Sizing: Minimum 2 vCPUs and 4 GB of RAM (8 GB+ recommended if deploying workloads like Ollama or local AI models).
  3. Network Planning: A contiguous block of unallocated static IPv4 addresses in your local subnet (outside your router’s DHCP scope). For example, if your home/office network is 192.168.1.0/24, reserve 192.168.1.200-192.168.1.220.

Step 1: Preparing Ubuntu for K3s

Update your system packages and install essential network utilities:

sudo apt update && sudo apt upgrade -y
sudo apt install -y curl iptables software-properties-common

If you have UFW enabled, ensure that Kubernetes internal traffic and API server ports are open, or temporarily disable it for initial validation:

sudo ufw allow 6443/tcp # K3s API server
sudo ufw allow 80/tcp   # HTTP Ingress
sudo ufw allow 443/tcp  # HTTPS Ingress
sudo ufw reload

Step 2: Installing K3s Without Default ServiceLB

As documented in the Rancher K3s Official Documentation, you can disable bundled components using the --disable flag during installation. We will explicitly disable servicelb so that MetalLB can take full control of all LoadBalancer service requests.

Execute the official K3s installation script with the required flags:

curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable servicelb --write-kubeconfig-mode 644" sh -

Let’s dissect the installation parameters:

  • --disable servicelb: Prevents K3s from deploying Klipper-LB, avoiding ARP and IP allocation conflicts with MetalLB.
  • --write-kubeconfig-mode 644: Allows non-root users on the host machine to read /etc/rancher/k3s/k3s.yaml, eliminating the constant need for sudo kubectl.

Verify that K3s is healthy and the control plane node is in Ready status:

kubectl get nodes -o wide

Step 3: Installing MetalLB via Official Manifests

We will deploy MetalLB using its official, pre-validated Kubernetes manifests, following the MetalLB Official Layer 2 Documentation.

Apply the MetalLB release manifests:

kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.8/config/manifests/metallb-native.yaml

Monitor the rollout until both the MetalLB controller deployment and the speaker daemonset pods reach Running status:

kubectl wait --namespace metallb-system \
                --for=condition=ready pod \
                --selector=app=metallb \
                --timeout=120s

Step 4: Configuring MetalLB IP Address Pools and Layer 2 Mode

Modern MetalLB (v0.13+) uses Custom Resource Definitions (CRDs) rather than legacy ConfigMaps. We must define two declarative resources:

  1. IPAddressPool: Specifies the range of IP addresses MetalLB is authorized to assign.
  2. L2Advertisement: Tells MetalLB to announce these IP addresses over Layer 2 (ARP) on the local network.

Create a configuration file named metallb-config.yaml. Replace 192.168.1.200-192.168.1.220 with your subnet’s unallocated range:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: local-lan-pool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.1.200-192.168.1.220
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: lan-advertisement
  namespace: metallb-system
spec:
  ipAddressPools:
    - local-lan-pool

Apply the configuration to your cluster:

kubectl apply -f metallb-config.yaml

Step 5: Testing with a Sample LoadBalancer Service

To verify that MetalLB properly allocates IP addresses and responds to network traffic, deploy a lightweight Nginx web server deployment accompanied by a LoadBalancer service:

cat << 'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: test-webserver
spec:
  replicas: 2
  selector:
    matchLabels:
      app: test-webserver
  template:
    metadata:
      labels:
        app: test-webserver
    spec:
      containers:
        - name: nginx
          image: nginx:alpine
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: test-webserver-lb
spec:
  type: LoadBalancer
  selector:
    app: test-webserver
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
EOF

Check the service status:

kubectl get svc test-webserver-lb

Within seconds, you will observe that EXTERNAL-IP is no longer stuck in <pending>, but has been assigned the first available IP from your pool:

NAME                  TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)        AGE
test-webserver-lb     LoadBalancer   10.43.120.45    192.168.1.200   80:31250/TCP   15s

Now, test reachability from any computer or browser on your local network:

curl -I http://192.168.1.200

You will receive an instant HTTP/1.1 200 OK response directly from the Nginx pod, proving that Layer 2 ARP resolution is functioning flawlessly.

Requesting Static Specific IP Addresses for Services

In many homelab setups, you want a specific service (such as a Pi-hole DNS server or a centralized Caddy reverse proxy gateway) to always receive the exact same IP address. You can enforce this by specifying spec.loadBalancerIP in your service definition:

spec:
  type: LoadBalancer
  loadBalancerIP: 192.168.1.215
  ports:
    - port: 80

Troubleshooting Common K3s & MetalLB Errors

1. Service Stuck in <pending>

  • Cause: MetalLB pool exhausted or CRDs missing.
  • Fix: Run kubectl describe svc <service-name>. Look at the Events section. If it says no allocated IP, check your IPAddressPool definition and verify your pool range has available IPs.

2. IP Assigned but Cannot Ping or Connect

  • Cause: Linux host firewall blocking forwarded traffic or ARP requests.
  • Fix: Ensure bridge netfilter is enabled on the host:
    sudo modprobe br_netfilter
    echo 1 | sudo tee /proc/sys/net/bridge/bridge-nf-call-iptables

Conclusion

By pairing the lightweight resource efficiency of K3s with the robust Layer 2 ARP capabilities of MetalLB, you have turned a bare Ubuntu server into a production-ready Kubernetes environment. You can now deploy microservices, databases, and ingress controllers with real, predictable LAN IP addresses—combining enterprise cloud capabilities with complete local control.