The Problem With No Cloud Load Balancer
You’ve got k3s running on some old enterprise boxes in your basement, and you deploy a service with type: LoadBalancer. Nothing happens. The service sits there in PENDING forever, waiting for a load balancer that will never come.
In AWS, GCP, Azure? That’s handled. A controller watches for your service and automatically provisions an ELB or similar. But on bare metal, your home lab, a co-located server, a small office, there’s no magic. No cloud provider. Just you, your hardware, and a networking problem.
MetalLB solves this. It watches your Kubernetes cluster, and when you create a LoadBalancer service, it actually assigns it a real IP address and makes the traffic work. No cloud, no special hardware. Just BGP or Layer 2 ARP and a lot of common sense.
What MetalLB Does
You’re hiring a traffic cop for your services. Instead of hiring an expensive city like AWS, MetalLB is the cop you train and keep yourself. It stands at the intersection and waves traffic toward the right road.
When you do:
apiVersion: v1kind: Servicemetadata: name: my-apispec: type: LoadBalancer selector: app: my-api ports: - port: 80 targetPort: 8080MetalLB claims an IP address from a pool you configure, assigns it to the service, and announces it to your network (either via Layer 2 broadcast or BGP route advertisements). Traffic destined for that IP gets distributed across the pods running my-api. No NodePort ugliness, no port forwarding scripts, no port number in the URL.
Your service has a real, usable IP address. Load-balanced traffic. And it’s all running on hardware you own.
The Two Modes: Layer 2 vs BGP
MetalLB has two operating modes, and picking the right one matters.
Layer 2: The Simple Answer
Layer 2 mode is straightforward. MetalLB picks an IP from a pool and has one node respond to ARP requests for it. Traffic arrives, gets load-balanced across the cluster.
How it works:
- You assign a pool of IP addresses (e.g., 192.168.1.200-192.168.1.250)
- A service claims one (e.g., 192.168.1.205)
- One node in your cluster becomes the “owner” and replies “that’s me” to any ARP who-has requests
- Traffic flows to that node, gets kube-proxy load-balanced to the pods
- If that node dies, another node takes over within seconds
Pros:
- Dead simple. No BGP. No routing daemons. No networking config beyond the IP pool.
- Works on any network (your home LAN, a switched network, anywhere Layer 2 traffic flows)
- No external router changes needed
Cons:
- All traffic funnels through one node at a time (the ARP owner)
- Not true load balancing at the network level, you’re relying on kube-proxy to spread traffic inside the cluster
- If your ARP owner is a potato, that’s your bottleneck
- Doesn’t scale to multi-site deployments
When to use it: Your home lab. A small office. Anywhere Layer 2 is available and you’ve got maybe 5 to 10 nodes max.
BGP: The Scalable Answer
BGP is older than the web. It’s the routing protocol that runs the internet. MetalLB can speak BGP, advertise your service IPs to your network, and let your upstream router decide which node gets the traffic.
How it works:
- You configure MetalLB and your network router to speak BGP to each other
- When a service is created, MetalLB advertises the route (e.g., 192.168.1.205/32) via BGP
- Your router learns about it and directs traffic toward the cluster
- Usually multiple nodes advertise the same route, so traffic gets distributed at the network level
- If a node fails, its BGP session drops, and the router shifts traffic to another
Pros:
- True load balancing at the network edge
- Scales to dozens or hundreds of nodes
- Works across multiple sites (as long as they’re BGP-connected)
- If you’re already running BGP for other stuff, this is “free”
- Traffic doesn’t funnel through a single node
Cons:
- Requires BGP configuration on your upstream router (not all home routers support it)
- Assumes you have a router that can peer with BGP
- More moving parts, more to debug
- BGP convergence can take a few seconds (usually fine, but not instant)
When to use it: Larger clusters (10+ nodes), multi-site setups, or if your router already speaks BGP. Overkill for a home lab with 3 nodes in a closet.
Installing MetalLB
Here’s the quick install. MetalLB runs as a controller deployment and a speaker daemonset.
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.16.0/config/manifests/metallb-native.yamlPin the version. If you apply the main branch manifest instead, you get container images tagged :main, which is whatever landed upstream this morning. v0.16.0 is current as of September 2026.
Wait for it to be ready:
kubectl wait --for=condition=ready pod \ -l component=controller \ -n metallb-system \ --timeout=300sThe manifest labels its pods app=metallb plus component=controller or component=speaker. It does not use the app.kubernetes.io/* prefix, so a selector built on that returns nothing and kubectl wait sits there until it times out.
One more thing if you’re on k3s: k3s ships its own LoadBalancer controller, ServiceLB (formerly Klipper), and it is on by default. Two controllers fighting over the same service is not a good time. Start your servers with --disable=servicelb before you install MetalLB.
Configuring Your IP Pool (Layer 2)
Create an IPAddressPool with the IPs you want MetalLB to use:
apiVersion: metallb.io/v1beta1kind: IPAddressPoolmetadata: name: default namespace: metallb-systemspec: addresses: - 192.168.1.200-192.168.1.250Now tell MetalLB to use Layer 2 for this pool:
apiVersion: metallb.io/v1beta1kind: L2Advertisementmetadata: name: default namespace: metallb-systemspec: ipAddressPools: - defaultApply both:
kubectl apply -f ippool.yaml -f l2advert.yamlNow create a test service:
apiVersion: v1kind: Servicemetadata: name: test-lbspec: type: LoadBalancer selector: app: test ports: - port: 80 targetPort: 8080Check the status:
kubectl get svc test-lbYou should see an EXTERNAL-IP assigned from your pool (e.g., 192.168.1.200). Done.
Configuring BGP (Advanced)
If you’ve got a BGP-capable router (pfSense with the FRR package installed, VyOS, a real Juniper/Cisco device, or an EdgeRouter), here’s the idea.
First, create your IP pool:
apiVersion: metallb.io/v1beta1kind: IPAddressPoolmetadata: name: default namespace: metallb-systemspec: addresses: - 192.168.1.200-192.168.1.250Then configure MetalLB’s BGP settings:
apiVersion: metallb.io/v1beta2kind: BGPPeermetadata: name: my-router namespace: metallb-systemspec: myASN: 64512 peerASN: 64512 peerAddress: 192.168.1.1 peerPort: 179 holdTime: 120s keepaliveTime: 40s---apiVersion: metallb.io/v1beta1kind: BGPAdvertisementmetadata: name: default namespace: metallb-systemspec: ipAddressPools: - defaultYour router also needs BGP config. On pfSense, that’s roughly:
BGP AS Number: 64512Router ID: 192.168.1.1 (or some ID on your network)Then add a neighbor (MetalLB):
Peer AS: 64512Peer IP: (any node in your cluster)Note the API versions: BGPPeer moved to metallb.io/v1beta2 and the v1beta1 version of that resource is deprecated, so applying the older one gets you a warning from the API server. IPAddressPool, L2Advertisement and BGPAdvertisement are still v1beta1.
Once both sides are talking BGP, apply your MetalLB config and services get advertised. Your router learns the routes and directs traffic appropriately.
Reality check: This is complex if you’ve never touched BGP. There are timing issues, holdtime tuning, address families, and seven different ways to misconfigure it. For a home lab with 3 nodes? Layer 2. For a real cluster with 10+ nodes and a router you control? BGP buys you scale and network-level redundancy that Layer 2 mode cannot give you at all.
Common Gotchas
“My service has an IP but traffic doesn’t reach it.”
Layer 2: Are you on the same network segment as the IP pool? ARP doesn’t cross routers.
BGP: Is BGP actually up? Check the session from your router’s side first. MetalLB ships two BGP backends: the manifest above (metallb-native.yaml) uses MetalLB’s own Go implementation, so the speaker pod has no vtysh to poke at and you debug it from the speaker logs. If you installed metallb-frr.yaml or metallb-frr-k8s.yaml instead, the speaker pod runs FRR alongside it and vtysh -c "show bgp summary" works. Are address families correct?
“I’m getting flapping, the IP keeps moving between nodes.”
Probably Layer 2 with unstable node ARP. Make sure your Layer 2 Advertisement specifies which nodes can own IPs (use nodeSelectors). Or switch to BGP.
“The IP is assigned but doesn’t show in my network.”
BGP: Did you forget to advertise the route? Check kubectl logs -n metallb-system -l component=speaker.
Layer 2: Is the node actually announcing? arp -an | grep 192.168.1.200 on a client machine should show the node’s MAC.
“I assigned a pool but nothing happened.”
Did you apply an L2Advertisement or BGPAdvertisement pointing to the pool? The pool alone does nothing, it needs an advertisement to be useful.
When NOT to Use MetalLB
- You already have a real load balancer on your network (hardware, appliance). Just use
NodePortand point your LB at the nodes. - You’re on a cloud provider. Use the native load balancer (ELB, GCP LB, Azure LB). It costs more than MetalLB, which is free, but it is simpler and it integrates with the provider’s health checks, TLS termination and cross-zone routing instead of fighting them.
- You’re inside a network where Layer 2 is blocked and you can’t run BGP. This is rare but real in some corporate environments. You’re stuck with
NodePortorIngress.
The Real Talk
MetalLB is one of those projects that sounds complicated until you deploy it. Layer 2 is two config files and about five minutes. BGP is real network engineering, but if you’re running it, you probably already know BGP or have a reason to learn it.
What you get out of it: load-balanced services on bare metal, with an actual LoadBalancer type that behaves the way it does in the cloud. That’s worth the five minutes of setup.
Your home lab or small business cluster finally stops looking like it’s running on a toy. Services get real VIPs, clients don’t care about NodePorts, and traffic distribution actually works. No cloud bill required.
Pick Layer 2. Deploy it in an afternoon. Thank yourself at 2 AM when you’re not hand-rolling port forwards anymore.
Common Questions
Do I need to disable k3s ServiceLB before installing MetalLB?
Yes. k3s enables ServiceLB (formerly Klipper) by default, and it claims LoadBalancer services using node host ports. Restart every k3s server with --disable=servicelb, then install MetalLB. Skip this and your services get node IPs from ServiceLB instead of addresses from your MetalLB pool.
Does MetalLB work on a single-node cluster?
Yes. MetalLB on one node still gives you real service IPs from your pool in Layer 2 mode, which is worth it just to stop juggling NodePort numbers. You lose the failover benefit, since there is no second node to take over the ARP ownership when the first one dies.
Can I run MetalLB and an Ingress controller together?
Yes, and that is the normal setup. You give the Ingress controller one LoadBalancer service, MetalLB assigns it a single IP from the pool, and the Ingress controller routes by hostname behind it. You only need more pool addresses when a service needs a raw TCP or UDP port instead of HTTP routing.
How many IP addresses should the pool have?
One per LoadBalancer service, plus room to grow. A home lab typically wants 10 to 20. Pick a range outside your DHCP scope, or your router will eventually hand one of those addresses to a laptop and you’ll spend an evening chasing a duplicate-IP conflict.