K3s ships its own; disable it first

K3s (see that tutorial) bundles "servicelb" (a tiny LB that uses host-port mapping). MetalLB does the job better; disable servicelb before installing MetalLB:

# Reinstall K3s with --disable=servicelb (and likely --disable=traefik if you're
# replacing the bundled ingress too)
curl -sfL https://get.k3s.io | sh -s - \
    --disable=servicelb \
    --flannel-backend=host-gw      # or use Cilium (see /tutorials/cilium-k3s-ebpf-networking.html)

# If K3s is already installed, edit /etc/systemd/system/k3s.service to add
# --disable=servicelb to the ExecStart line, then systemctl daemon-reload + restart k3s.

Install MetalLB

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

# Wait for the rollout
kubectl -n metallb-system get pods -w

Two pods come up: controller (assigns IPs to services) and a per-node speaker (announces those IPs via L2 or BGP).

Pick an address pool

Carve a small range out of your LAN that's NOT in the DHCP scope — MetalLB will claim these for services. Example: LAN is 192.168.1.0/24, DHCP hands out .100–.200, leave .220–.230 for MetalLB.

cat <<'EOF' | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: lan-pool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.1.220-192.168.1.230
  autoAssign: true

---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: lan-pool-l2
  namespace: metallb-system
spec:
  ipAddressPools:
    - lan-pool
EOF

L2 mode is the simplest: MetalLB picks a node per service, sends gratuitous ARP for the LB IP from that node's MAC, and traffic flows in. No router changes needed; the LB IPs live on the same L2 subnet as the cluster nodes.

Test it

cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  selector: { app: nginx }
  ports:
    - port: 80
  type: LoadBalancer

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  replicas: 2
  selector: { matchLabels: { app: nginx } }
  template:
    metadata: { labels: { app: nginx } }
    spec:
      containers:
        - name: nginx
          image: nginx:alpine
          ports: [ { containerPort: 80 } ]
EOF
kubectl get svc nginx
# NAME    TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)        AGE
# nginx   LoadBalancer   10.43.123.45    192.168.1.220   80:32145/TCP   3s

curl http://192.168.1.220
# Welcome to nginx!

The service got 192.168.1.220 from the pool; MetalLB's speaker is announcing it from one of the cluster nodes; nginx pods on any node serve the request.

L2 vs BGP mode

  • L2 mode (above) — ARP-based, single-active-node per IP. Simplest setup, no router cooperation needed. Failover is fast (~5s on ARP timeout) but only one node actively serves a given service IP at a time, so single-IP throughput is bounded by that node's capacity. Fine for <1 Gb/s service throughput on a single LAN.
  • BGP mode — speakers peer with a BGP-capable router and announce the IPs as /32 routes. Multiple nodes can advertise the same IP simultaneously; the router ECMP-load-balances. Throughput scales with node count; failover is BGP-fast. Requires a router that does BGP (OPNsense, see that tutorial; pfSense; FRRouting; Mikrotik; commercial routers).

For homelab + small office: L2 is fine. For "real production on bare metal" with multi-Gbps service throughput: BGP.

BGP example

# BGPPeer: peer with the router at 192.168.1.1
apiVersion: metallb.io/v1beta1
kind: BGPPeer
metadata:
  name: router
  namespace: metallb-system
spec:
  myASN: 64513
  peerASN: 64512
  peerAddress: 192.168.1.1

---
apiVersion: metallb.io/v1beta1
kind: BGPAdvertisement
metadata:
  name: lan-pool-bgp
  namespace: metallb-system
spec:
  ipAddressPools:
    - lan-pool
  aggregationLength: 32
  communities:
    - "no-export"

On the router side, configure a BGP neighbor with matching ASNs and accept the announced /32s. ECMP across the cluster nodes happens automatically once they all announce.

Per-service IP pinning

To pin a service to a specific IP from the pool (useful for predictable URLs, DNS A records):

apiVersion: v1
kind: Service
metadata:
  name: my-app
  annotations:
    metallb.universe.tf/loadBalancerIPs: "192.168.1.225"
spec:
  selector: { app: my-app }
  ports: [ { port: 443 } ]
  type: LoadBalancer

MetalLB respects the annotation if the IP is free in a pool MetalLB manages.

Sharing one IP across multiple services

For "I want HTTP/HTTPS to land on the same IP from different services":

annotations:
  metallb.universe.tf/allow-shared-ip: "shared-key"

On both services. They can then share an IP as long as they use different ports.

What MetalLB isn't

  • Not a replacement for an Ingress controller (Traefik / nginx / Cilium Gateway). MetalLB gives services IPs; Ingress controllers do L7 routing on top of those IPs.
  • Not a global LB — MetalLB operates within one L2 (in L2 mode) or one BGP fabric (in BGP mode). Cross-cluster / multi-region needs something else (Cilium ClusterMesh, see that tutorial; or external DNS-based load balancing).
  • Not WAN-facing — MetalLB assigns LAN IPs. Public exposure goes through Cloudflared tunnels (see that tutorial), a static port-forward on the router, or a public-IP LB.

Common patterns

  1. MetalLB + Traefik Ingress — MetalLB gives Traefik's Service a stable LAN IP (e.g. 192.168.1.220); Traefik does HTTP/HTTPS routing per hostname. DNS for all your subdomains points at .220. This is the homelab default.
  2. MetalLB + per-service IPs — one IP per service. Useful for services that aren't HTTP (Postgres, Redis, custom TCP).
  3. MetalLB BGP + external router — production-scale bare-metal cluster with ECMP load balancing across nodes.

For "I have a real Kubernetes cluster on my own hardware and want LoadBalancer services to actually work," MetalLB is essentially required, and after installation, it disappears — you stop thinking about LB at all and just use type: LoadBalancer as Kubernetes intended.