Contents

MetalLB and Kubernetes Bare-Metal Networking: LoadBalancers Without a Cloud

Giving your on-prem cluster the one feature the cloud takes for granted

Contents

The first thing that goes wrong when you build a Kubernetes cluster on your own hardware is the most anticlimactic possible failure. You deploy something, set its Service type to LoadBalancer the way every tutorial told you to, and then… nothing. kubectl get svc shows EXTERNAL-IP stuck on <pending>, forever, with the quiet patience of a thing that is never going to happen.

This is not a bug. On a cloud provider, type: LoadBalancer is a request that the cloud’s controller fulfils by provisioning an actual load balancer and handing you a public IP. On bare metal there is no cloud controller listening, so the request just sits there. MetalLB is the missing piece: it implements that controller for your own network, so a homelab or on-prem cluster can finally do the thing the cloud does for free.

What MetalLB actually does

Advertisement

MetalLB watches for Services of type LoadBalancer, allocates each one an IP from a pool you define, and then makes the rest of your network actually route traffic to that IP. That second half is the clever bit, and it comes in two flavours.

In Layer 2 mode, one node becomes the “leader” for each service IP and answers ARP requests for it. To your router and switches, that IP looks like it lives on that node’s NIC. Traffic for the IP arrives at the leader, and kube-proxy takes it from there. It’s dead simple — no router configuration needed — but all traffic for a given IP funnels through a single node, and failover means waiting for ARP caches to update.

In BGP mode, MetalLB speaks BGP to your router and advertises service IPs as routes, with true per-session load balancing across nodes. It’s the grown-up option, but it assumes you have a router that speaks BGP and you’re comfortable configuring it. For most homelabs, Layer 2 is the honest, correct choice.

A note on versions, because MetalLB has been quietly reshuffling its internals: the BGP backend now defaults to FRR-K8s (a Kubernetes-native wrapper around the FRR routing daemon) rather than the older embedded FRR mode, which is deprecated and on its way out. If you don’t touch BGP this doesn’t affect you at all — Layer 2 mode is entirely unaffected — but it’s worth knowing before you copy a two-year-old blog post’s Helm values and wonder why the flags moved. I’m running MetalLB 0.15.x on current Kubernetes here, and everything below reflects that.

Installing it

There are two supported install paths and I’ll be blunt about which to pick. The plain manifest install works, but the Helm chart is what you want if you ever intend to touch BGP, because the chart is where the FRR-K8s wiring lives. For a Layer 2-only homelab, either is fine:

1
2
3
4
5
6
# Helm — the path that scales to BGP later
helm repo add metallb https://metallb.github.io/metallb
helm install metallb metallb/metallb -n metallb-system --create-namespace

# or the raw manifest, if you like your infrastructure declarative and flat
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.15.3/config/manifests/metallb-native.yaml

Either way you get a controller Deployment (the brain that allocates IPs) and a speaker DaemonSet (a pod on every node that actually announces the addresses). Wait for both to go Running before you configure anything — a common first mistake is applying an IPAddressPool against a controller that hasn’t finished starting, then blaming MetalLB for the resulting silence.

Setting it up in Layer 2 mode

Advertisement

Install the manifests, then give MetalLB an address pool. Pick a range on your LAN subnet that your DHCP server will never hand out — this is the part people botch, and a clash produces gloriously confusing intermittent failures.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: lan-pool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.1.240-192.168.1.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: lan-advert
  namespace: metallb-system
spec:
  ipAddressPools:
    - lan-pool

That’s it. The IPAddressPool says “these eleven addresses are mine to give out,” and the L2Advertisement says “announce them with ARP.” Now deploy any Service and watch it get a real, pingable IP:

1
2
3
$ kubectl get svc nginx
NAME    TYPE           CLUSTER-IP     EXTERNAL-IP      PORT(S)        AGE
nginx   LoadBalancer   10.96.14.7     192.168.1.241    80:31385/TCP   12s

That EXTERNAL-IP is now reachable from anywhere on your LAN. Point a browser at it, add a DNS record, put it behind your ingress — it just works, finally.

Pinning and sharing addresses

Two annotations turn MetalLB from “gives me some IP” into “gives me the IP I asked for”. If a service needs a stable, memorable address — the one your router forwards a port to, or the one baked into a config somewhere — request it explicitly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
  name: ingress
  annotations:
    metallb.io/loadBalancerIPs: 192.168.1.240
spec:
  type: LoadBalancer
  selector:
    app: ingress-nginx
  ports:
    - port: 443
      targetPort: 443

The requested address still has to fall inside a pool MetalLB owns, or the allocation quietly fails and you’re back to <pending>. The other useful annotation is metallb.io/allow-shared-ip: give two services the same string value and they’ll share a single external IP as long as their ports don’t collide. That matters in a homelab where you’re rationing a handful of LAN addresses and don’t want to burn one per service.

The gotchas that will catch you

A few things will bite you, so let me save you the evenings I lost.

  • The address pool must be on your LAN subnet and outside DHCP. MetalLB doesn’t create a subnet; it borrows free addresses from your existing one. Overlap with DHCP and you’ll get duplicate-IP chaos that comes and goes with the mood of your router.
  • Layer 2 is not load balancing. Despite the name, a single node handles all traffic for each IP. You get high availability (another node takes over on failure) but not horizontal throughput. For a home cluster that’s almost always fine.
  • Some consumer switches dislike rapid ARP changes. Failover in L2 mode can take a few seconds while caches expire, and the occasional cheap switch with aggressive ARP security will make this worse.
  • Annotate to pin or share IPs. You can request a specific address with metallb.io/loadBalancerIPs, or let multiple services share one IP with metallb.io/allow-shared-ip when ports don’t collide.

How it sits next to ingress

A common confusion: do you need MetalLB and an ingress controller? Usually yes, and they’re complementary. The typical pattern is one MetalLB-assigned IP feeding an ingress controller (Traefik, nginx, whatever you like), which then routes by hostname to all your services. MetalLB gives you the single stable entry IP; ingress does the layer-7 fan-out behind it. You’re not choosing between them — you’re stacking them. The mental model that helped me: MetalLB operates at layer 2/3 (it’s just deciding which node owns which IP), while ingress operates at layer 7 (it reads the HTTP host header and picks a backend). They solve different problems and clip together cleanly, which is why almost every real cluster runs both.

This is exactly the setup I use behind a k3s cluster on a Raspberry Pi: MetalLB hands one address to Traefik, Traefik does hostname routing, and everything else is a ClusterIP service that never touches the LAN directly. One wrinkle: k3s ships its own service-load-balancer (Klipper) by default, which uses a host-port trick rather than real address allocation. It’s fine for a single node, but the moment you want a floating IP that survives a node dying, you disable Klipper (--disable servicelb) and let MetalLB do the job properly. Forget that step and you get the worst kind of bug — two load balancers fighting over the same service, each half-configuring it, the address appearing and vanishing depending on which controller reconciled last.

Troubleshooting: when the IP still won’t show up

Even with everything installed, three failure modes account for nearly every “it’s not working” evening. Work through them in order.

EXTERNAL-IP still stuck on <pending>. The controller either has no pool to allocate from or can’t reach one. Check the controller logs first:

1
2
kubectl logs -n metallb-system -l component=controller
kubectl get ipaddresspools -n metallb-system

If the pool exists but the service still pends, the requested address (via loadBalancerIPs) probably sits outside every pool, or the pool is exhausted. MetalLB will say so in the logs if you actually read them — which, in my experience, is the single most-skipped debugging step in all of Kubernetes.

IP is assigned but nothing responds. This is almost always ARP not propagating. Confirm which node is currently the L2 leader:

1
kubectl logs -n metallb-system -l component=speaker | grep "serviceAnnounced"

Then check that the node can actually be reached at that address from another box: ping 192.168.1.241, and if that works but the app doesn’t, the problem is below MetalLB — a NetworkPolicy, a wrong targetPort, or the pod simply not listening. MetalLB’s job ends at getting packets to the node; kube-proxy and your pod own everything after.

Intermittent, maddening flapping. If a service works, then doesn’t, then does, you almost certainly have an IP clash: the address MetalLB is announcing is also being handed out by DHCP, or another device is statically configured to it. Shrink your DHCP range, move the pool, and the ghosts leave. I have lost more time to this one specific mistake than to any other single thing in my homelab, so I’ll say it a third time: keep the pool out of DHCP.

The verdict

If you run Kubernetes on hardware you own, MetalLB isn’t optional so much as inevitable. It’s the component that turns a cluster that technically works into one that behaves like every tutorial assumes, where type: LoadBalancer produces an IP instead of an awkward silence. Layer 2 mode takes about ten minutes to set up and asks almost nothing of your network. BGP mode is there when you outgrow that, but most people never will. For the home-cluster crowd, it’s a near-mandatory install that I reach for on every bare-metal cluster I build — and one of the rare bits of infrastructure I set up once and genuinely never think about again.

The one honest caveat: Layer 2 mode buys you high availability, not throughput. If you genuinely need multiple nodes actively sharing the traffic for one IP, you need BGP and a router that speaks it, and at that point you’re doing real networking, not homelab networking. There’s an alternative worth knowing about too — if all you actually want is a single floating IP that fails over between two machines, keepalived and a virtual IP does that with far less machinery and no Kubernetes controller involved. MetalLB earns its place when you have many services each wanting their own address; for one VIP shared by a pair of boxes, keepalived is the lighter tool. Pick the one that matches the problem in front of you, not the one with the most impressive architecture diagram.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.