Putting a load balancer in front of your servers removes one single point of failure, and creates another: the load balancer itself. keepalived solves that with VRRP (Virtual Router Redundancy Protocol). Two servers share a virtual IP (VIP). The active one holds it, and if it fails, the standby takes over the IP within a second or two, so clients keep connecting to the same address.

How it works

                    VIP 10.0.0.100 (clients connect here)
                         │
          ┌──────────────┴──────────────┐
   lb1 (MASTER, prio 150)       lb2 (BACKUP, prio 100)
   Nginx/HAProxy                Nginx/HAProxy
          └──────── backends: app1, app2, app3 ────────┘
  • Both nodes run keepalived and exchange VRRP advertisements about once per second.
  • The node with the highest priority holds the VIP (adds it to its interface).
  • If the master stops advertising (crash, reboot, network failure) or its health check fails, the backup claims the VIP and sends gratuitous ARP, so the network learns the new location.

Important: the network must allow it

A floating IP needs both nodes on the same layer 2 network (or a provider feature for moving IPs), and the provider must allow the VIP to move between machines:

  • Private networks / VLANs between your servers, and dedicated servers in the same rack, usually work.
  • Public IPs on VPSes often can’t float freely, because the provider routes each IP to a specific server. Many providers offer failover IPs or floating IPs managed through their API or panel, and keepalived then calls that API in a notify script instead of relying on ARP.
  • Multicast is often blocked on cloud networks. Use unicast VRRP (below).

Check with your provider before designing around a VIP. Alternatives without a VIP: DNS failover with health checks, or two load balancer IPs in DNS. See load balancer types.

Install

On both nodes:

sudo apt install keepalived      # or: dnf install keepalived

Configuration

lb1 (/etc/keepalived/keepalived.conf):

global_defs {
    router_id lb1
    script_user root
    enable_script_security
}

vrrp_script chk_haproxy {
    script "/usr/bin/systemctl is-active --quiet haproxy"
    interval 2
    fall 2
    rise 2
}

vrrp_instance VI_1 {
    state MASTER
    interface eth1
    virtual_router_id 51
    priority 150
    advert_int 1

    unicast_src_ip 10.0.0.11
    unicast_peer {
        10.0.0.12
    }

    authentication {
        auth_type PASS
        auth_pass s3cr3tpw
    }

    virtual_ipaddress {
        10.0.0.100/24 dev eth1
    }

    track_script {
        chk_haproxy
    }
}

lb2 is identical, except for router_id lb2, state BACKUP, priority 100, and swapped unicast_src_ip / unicast_peer.

Notes:

  • virtual_router_id must match on both nodes, and be unique on that network segment.
  • track_script: if HAProxy (or Nginx: systemctl is-active --quiet nginx) dies, the node drops out of master, even though the server itself is up.
  • auth_pass is limited to 8 characters, and isn’t real security. Run VRRP on a private network.

Start on both nodes:

sudo systemctl enable --now keepalived
ip addr show eth1 | grep 10.0.0.100      # present only on the master

Allow VRRP between the nodes in the firewall. VRRP is IP protocol 112:

sudo iptables -A INPUT -p vrrp -s 10.0.0.12 -j ACCEPT
# UFW: add "-A ufw-before-input -p vrrp -s 10.0.0.12 -j ACCEPT" to /etc/ufw/before.rules

Let services bind to the VIP

The backup node doesn’t have the VIP, so HAProxy or Nginx configured to bind 10.0.0.100:80 fails to start there. Either bind to 0.0.0.0 (all addresses), or allow binding to non-local IPs:

echo 'net.ipv4.ip_nonlocal_bind=1' | sudo tee /etc/sysctl.d/99-nonlocal.conf && sudo sysctl --system

Provider floating IPs: notify scripts

When the IP must be moved through an API instead of ARP:

vrrp_instance VI_1 {
    …
    notify_master "/usr/local/bin/claim-floating-ip.sh"
}

The script calls the provider’s API to assign the floating IP to this server. Test it thoroughly. API latency adds to failover time.

Test failover

# on a client, keep requesting through the VIP
while true; do curl -s -o /dev/null -w '%{http_code} ' http://10.0.0.100/; sleep 0.5; done

# on lb1, simulate failure
sudo systemctl stop haproxy        # or: sudo systemctl stop keepalived / reboot

You should see a brief gap (a second or two), and then responses continue from lb2. Check the logs with journalctl -u keepalived. When lb1 recovers, it takes the VIP back, because it has the higher priority. Add nopreempt (with both nodes set to state BACKUP) if you prefer to avoid a second switch.

Keep both nodes identical

Failover only helps if the backup can actually serve. Sync configs (Ansible, rsync, or a Git-based deploy), certificates and HAProxy or Nginx settings. Test the backup regularly. See Ansible playbooks.

Quick reference

Item Setting
Shared VIP virtual_ipaddress { 10.0.0.100/24 dev eth1 }
Same on both nodes virtual_router_id, auth_pass, VIP
Master / backup state + priority
No multicast unicast_src_ip + unicast_peer
Service health vrrp_script + track_script
Bind before owning the VIP net.ipv4.ip_nonlocal_bind=1
Logs journalctl -u keepalived

Related: HAProxy load balancer and high availability on VPS.

  • High Availability
  • Load Balancing
  • Networking