How to Install MetalLB Load Balancer on Bare-Metal Kubernetes (Layer 2 Mode)

Written by: Bagus Facsi Aginsa
Published at: 16 Jun 2026


If you have ever created a LoadBalancer service on a self-hosted Kubernetes cluster, you have probably seen this:

$ kubectl get svc my-app
NAME     TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
my-app   LoadBalancer   10.96.142.31    <pending>     80:31840/TCP   5m

That <pending> never goes away. On cloud providers like AWS or GCP, the platform notices a LoadBalancer service and provisions a real load balancer with a public IP for you. On a bare-metal cluster, or a homelab, or a few VMs you spun up yourself, there is nobody to do that. So the service sits there forever, waiting for an IP that never comes.

MetalLB fixes exactly this. It is a load balancer implementation built for bare-metal clusters that hands out real IP addresses to your LoadBalancer services from a pool you define. In this tutorial you will install MetalLB, configure it in Layer 2 mode, and watch a pending service finally get an external IP you can actually reach.

This guide is for developers, sysadmins, and DevOps engineers running Kubernetes outside the big cloud providers. If you set up your cluster following my earlier guide on installing single-node Kubernetes or k3s with Calico, MetalLB is the natural next piece.


Conceptual Overview

Before touching any commands, let us be clear about what MetalLB does and does not do.

A Kubernetes Service of type LoadBalancer is a request: “please give this service a stable external IP that routes traffic to my pods.” Kubernetes itself does not know how to fulfil that request on bare metal. It relies on a controller to do it. MetalLB is that controller.

MetalLB has two main pieces:

  • The controller: a single deployment that watches for LoadBalancer services and assigns them an IP from your configured pool.
  • The speakers: a DaemonSet that runs one pod on every node and is responsible for actually announcing those IPs to the rest of the network so traffic finds its way in.

MetalLB can announce IPs in two modes:

  • Layer 2 mode (ARP/NDP): One node becomes the owner of each service IP and answers ARP requests for it. From the network’s point of view, that IP simply “lives” on that node’s network interface. This is simple, needs no special network hardware, and works on almost any flat network. The trade-off is that all traffic for a given service goes through one node, and failover takes a few seconds.
  • BGP mode: MetalLB speaks the BGP routing protocol to your routers, which gives true load balancing across nodes and faster failover. It is more powerful but requires a BGP-capable router and more network knowledge.

This tutorial uses Layer 2 mode because it works almost everywhere and is what most homelabs and small production clusters actually use.

One important rule: the IP range you give MetalLB must be free. These addresses must not be used by any other machine, and they must not overlap with the range your DHCP server hands out. MetalLB assumes it owns them completely.


Prerequisites

You will need the following before starting:

  • A running Kubernetes cluster (kubeadm, k3s, or similar) with at least one node. A multi-node cluster is fine too.
  • kubectl configured and able to reach your cluster.
  • A network where all nodes share the same Layer 2 segment (the common case for a homelab or a set of VMs on the same subnet).
  • A small block of free IP addresses on that subnet, outside your DHCP range. In this guide the cluster subnet is 192.168.1.0/24 and I will reserve 192.168.1.240 through 192.168.1.250 for MetalLB.
  • Basic comfort with the Linux command line and editing YAML files.

A quick way to confirm kubectl works:

kubectl get nodes

You should see your nodes listed as Ready. If not, fix that first before continuing.

A note on kube-proxy and IPVS

If your cluster runs kube-proxy in IPVS mode, you must enable strict ARP. Layer 2 mode relies on the speaker controlling ARP responses, and IPVS mode will interfere otherwise. Check and patch it:

kubectl get configmap kube-proxy -n kube-system -o yaml | grep -i strictARP

If strictARP is false or missing, enable it:

kubectl get configmap kube-proxy -n kube-system -o yaml | \
  sed -e "s/strictARP: false/strictARP: true/" | \
  kubectl apply -f - -n kube-system

Most kubeadm and k3s clusters default to iptables mode, where this is not required. It does no harm to set it anyway.


Step 1: Install MetalLB

The cleanest way to install MetalLB is with the official manifest. It creates a dedicated metallb-system namespace and deploys both the controller and the speaker.

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

This pulls in everything: the namespace, RBAC rules, the custom resource definitions (CRDs) that you will use to configure pools, and the workloads themselves.

Give the pods a moment to come up, then check on them:

kubectl get pods -n metallb-system

You should see output similar to this:

NAME                          READY   STATUS    RESTARTS   AGE
controller-7c6d9d... -1q2w3   1/1     Running   0          45s
speaker-4f8g2                 1/1     Running   0          45s
speaker-9k1m7                 1/1     Running   0          45s

There is one controller pod and one speaker pod per node. Wait until all of them report Running. If you want to block until they are ready instead of polling by hand:

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

At this point MetalLB is installed but idle. It does not have an IP pool yet, so it will not assign anything. That is the next step.


Step 2: Define an IP Address Pool

MetalLB is configured through Kubernetes custom resources. The two you need are:

  • IPAddressPool: the range of IPs MetalLB is allowed to hand out.
  • L2Advertisement: tells MetalLB to announce those IPs using Layer 2 mode.

Create a file named metallb-config.yaml:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: first-pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.1.240-192.168.1.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: l2-advert
  namespace: metallb-system
spec:
  ipAddressPools:
  - first-pool

A few things worth explaining here:

  • The addresses field accepts a range (192.168.1.240-192.168.1.250) or CIDR notation (192.168.1.240/28). The range form is easier to reason about when you only want a handful of IPs.
  • The L2Advertisement references the pool by name. Without it, the pool exists but is never announced, so services still stay pending. This is one of the most common beginner mistakes.
  • Both resources live in the metallb-system namespace.

Apply the configuration:

kubectl apply -f metallb-config.yaml

Verify the pool was created:

kubectl get ipaddresspool -n metallb-system
NAME         AUTO ASSIGN   AVOID BUGGY IPS   ADDRESSES
first-pool   true          false             ["192.168.1.240-192.168.1.250"]

MetalLB is now ready to assign IPs.


Step 3: Deploy a Test Service

Let us prove it works end to end. We will deploy a simple Nginx deployment and expose it with a LoadBalancer service.

Create test-app.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx:stable
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 80

Apply it:

kubectl apply -f test-app.yaml

Now check the service:

kubectl get svc web

This time the external IP is real:

NAME   TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)        AGE
web    LoadBalancer   10.96.88.12     192.168.1.240   80:30512/TCP   8s

MetalLB picked 192.168.1.240, the first address in the pool. Test it from any machine on the same network:

curl http://192.168.1.240

You should get the default Nginx welcome page HTML back. That IP is now reachable from anywhere on your LAN, not just from inside the cluster. This is the whole point: a stable, routable IP for your service without any cloud provider.


Step 4: Request a Specific IP (Optional)

Sometimes you want a service to land on a predictable address, for example so you can point DNS at it. You can request one with an annotation:

apiVersion: v1
kind: Service
metadata:
  name: web
  annotations:
    metallb.io/loadBalancerIPs: 192.168.1.245
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
  - port: 80
    targetPort: 80

The requested IP must fall within a configured pool. If you ask for an address outside every pool, the service stays pending and MetalLB logs the reason.

You can also dedicate a whole pool to specific services and tell MetalLB not to auto-assign from it, which is handy when you want to reserve addresses for known workloads:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: reserved-pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.1.251-192.168.1.254
  autoAssign: false

With autoAssign: false, MetalLB only uses this pool when a service explicitly asks for an address from it.


Common Mistakes and Troubleshooting

The service is still pending. First check that you actually applied an L2Advertisement. A pool with no advertisement is invisible. Then look at the controller logs:

kubectl logs -n metallb-system -l component=controller

A message like no available IPs means your pool is exhausted or the requested IP is outside it.

The IP is assigned but I cannot reach it. This is almost always a network issue, not a MetalLB issue. Confirm the IP is on the same subnet as your nodes and is genuinely unused. Try pinging it from another machine. If you get intermittent replies, you probably have an IP conflict because the address is inside your DHCP range or already used by another device. Move the pool to a clean, reserved block.

Two devices answer for the same IP. This is the classic ARP conflict. Run arping 192.168.1.240 from a machine on the network and confirm only one MAC address replies. If you see two, something else owns that IP.

Speaker pods crash or stay pending. Check that they can run on every node. On clusters with tainted control-plane nodes, the speaker DaemonSet usually tolerates the taint by default, but custom taints can block it. Describe the pod to see the scheduling reason:

kubectl describe pod -n metallb-system -l component=speaker

IPVS mode and missing strict ARP. If you are on IPVS and skipped the strict ARP step earlier, Layer 2 announcements behave unpredictably. Go back and enable it.


Best Practices

A few things learned the hard way that will save you grief in production:

  • Reserve the IP range properly. The single most common cause of flaky MetalLB behavior is an address pool that overlaps with DHCP. Exclude the MetalLB range in your router or DHCP server configuration so nothing else ever claims those IPs.
  • Use separate pools for different purposes. A pool for internal tools and another for public-facing services keeps things organized and makes it easy to apply different policies later.
  • Pin the MetalLB version. Always install from a tagged release like v0.14.8 rather than main. CRDs change between versions, and an unpinned install can break on the next kubectl apply.
  • Plan your capacity. Each LoadBalancer service consumes one IP. If you expect many services, consider sharing a single IP across services on different ports, or front several services with an Ingress controller that itself uses one MetalLB IP. That way one address serves dozens of apps.
  • Remember Layer 2 is not true load balancing. All traffic for a service flows through one elected node. For most small clusters this is fine, but if you need to spread load across nodes or want sub-second failover, plan to move to BGP mode.
  • Pair it with a load balancer for the API. MetalLB handles service IPs, not the Kubernetes API server itself. For high availability of the control plane, you still want something like HAProxy in front of your API servers.

Conclusion

You have installed MetalLB, configured an IP address pool in Layer 2 mode, and watched a LoadBalancer service receive a real, reachable external IP, all on a bare-metal cluster with no cloud provider involved. That single capability unlocks a huge amount: you can now expose services the same way people do on managed Kubernetes, point DNS at stable addresses, and run an Ingress controller properly.

From here, a few natural next steps:

  • Deploy an Ingress controller such as ingress-nginx and give it a single MetalLB IP, then route many apps through it by hostname.
  • Experiment with multiple pools and the autoAssign: false pattern to reserve addresses for critical services.
  • If your network has a BGP-capable router, explore MetalLB’s BGP mode for real cross-node load balancing and faster failover.

MetalLB is one of those small tools that quietly makes a self-hosted cluster feel like a real platform. Once it is in place, you rarely think about it again, which is exactly what you want from infrastructure.