Updated for 2026: the original build ran on CentOS 7 / RHEL 7, both end-of-life (CentOS 7 died in June 2024). The architecture hasn’t changed at all — HAProxy for load balancing, keepalived for the floating IP — so this is the same two-node design rebuilt on AlmaLinux 9 / RHEL 9, with the firewall and SELinux steps the 2017 version skipped (and everyone then debugged for an afternoon).
What you’re building
- Two load-balancer nodes running HAProxy + keepalived
- Two Apache backends behind them
- One virtual IP (VIP) that floats to whichever balancer is alive — clients only ever talk to the VIP
Lab addressing (put it in /etc/hosts on all four machines):
192.168.0.100 vip 192.168.0.101 haproxy1 192.168.0.102 haproxy2 192.168.0.103 httpd1 192.168.0.104 httpd2
The balancers and the VIP must be in the same L2 segment — VRRP depends on it.
Install (both balancers)
[root@haproxy1 ~]# dnf install -y haproxy keepalived [root@haproxy1 ~]# haproxy -v # EL9 ships HAProxy 2.8 LTS [root@haproxy1 ~]# keepalived -v
HAProxy configuration (both balancers, identical)
Replace /etc/haproxy/haproxy.cfg with this. It’s short on purpose — a complete known-good file beats editing the stock one line by line:
global
log 127.0.0.1 local2
maxconn 4000
user haproxy
group haproxy
daemon
stats socket /var/lib/haproxy/stats mode 600 level admin
defaults
mode http
log global
option httplog
option dontlognull
timeout connect 5s
timeout client 50s
timeout server 50s
frontend web-in
bind *:80
default_backend web-backend
backend web-backend
balance roundrobin
option httpchk GET /
server httpd1 192.168.0.103:80 check
server httpd2 192.168.0.104:80 check
Two things to know about this config:
bind *:80listens on the VIP too, whenever the node holds it. That only works because of the sysctl in the next step.- The health check is an HTTP GET, not just a TCP connect — a backend returning 500s gets pulled out of rotation instead of eating traffic.
Validate before you start anything:
[root@haproxy1 ~]# haproxy -c -f /etc/haproxy/haproxy.cfg Configuration file is valid
The step everyone forgets: nonlocal bind
On the BACKUP node, the VIP doesn’t exist yet — so HAProxy refuses to start, complaining it can’t bind. Tell the kernel that binding to not-yet-local addresses is fine, on both balancers:
[root@haproxy1 ~]# echo 'net.ipv4.ip_nonlocal_bind = 1' > /etc/sysctl.d/10-haproxy.conf [root@haproxy1 ~]# sysctl -p /etc/sysctl.d/10-haproxy.conf
Firewall (both balancers)
Two openings: HTTP for the clients, and the VRRP protocol itself so the balancers can see each other. The second one was missing from the original tutorial — without it, both nodes conclude they’re alone and both take the VIP:
[root@haproxy1 ~]# firewall-cmd --permanent --add-service=http [root@haproxy1 ~]# firewall-cmd --permanent --add-rich-rule='rule protocol value="vrrp" accept' [root@haproxy1 ~]# firewall-cmd --reload [root@haproxy1 ~]# systemctl enable --now haproxy
One SELinux note: this build talks to backends on port 80, which HAProxy may do out of the box. Point it at backends on a non-standard port and you’ll need setsebool -P haproxy_connect_any 1.
keepalived configuration
/etc/keepalived/keepalived.conf on haproxy1 (MASTER). The health script watches the HAProxy process: if HAProxy dies, this node’s priority drops and the VIP moves even though the box itself is up.
global_defs {
router_id LB01
}
vrrp_script chk_haproxy {
script "/usr/bin/killall -0 haproxy"
interval 2
weight -20
}
vrrp_instance VI_1 {
state MASTER
interface eth0
virtual_router_id 51
priority 101
advert_int 1
authentication {
auth_type PASS
auth_pass secret99
}
virtual_ipaddress {
192.168.0.100/24
}
track_script {
chk_haproxy
}
}
On haproxy2, copy the same file and change exactly two lines: state BACKUP and priority 100 (and router_id LB02, for tidy logs). VRRP passwords are truncated to 8 characters and travel in cleartext — treat them as a sanity check, not security.
[root@haproxy1 ~]# systemctl enable --now keepalived [root@haproxy1 ~]# ip a | grep 192.168.0.100 # the VIP should be here, on the MASTER
The backends (httpd1 and httpd2)
[root@httpd1 ~]# dnf install -y httpd [root@httpd1 ~]# echo "Test httpd1" > /var/www/html/index.html [root@httpd1 ~]# firewall-cmd --permanent --add-service=http && firewall-cmd --reload [root@httpd1 ~]# systemctl enable --now httpd
Same on httpd2, with “Test httpd2” as the page body — the different text is how you’ll see balancing work.
Test it
Balance check — repeated requests to the VIP should alternate:
$ curl -s http://192.168.0.100 Test httpd1 $ curl -s http://192.168.0.100 Test httpd2
Failover check — stop HAProxy on the MASTER and watch the VIP move:
[root@haproxy1 ~]# systemctl stop haproxy [root@haproxy2 ~]# ip a | grep 192.168.0.100 # VIP arrives here within seconds $ curl -s http://192.168.0.100 # still answers
Restart HAProxy on haproxy1 and the VIP (and traffic) moves back.
Running this in the cloud?
VRRP assumes multicast and a shared L2 segment, which most clouds don’t give you. Two adjustments: keepalived supports unicast VRRP (unicast_src_ip / unicast_peer in the instance block), which works on many VPS networks — but a floating private VIP still won’t route from outside the subnet on AWS/Azure-style networks. There, the honest answer is the provider’s load balancer or an elastic-IP failover script. On a pair of bare-metal or KVM boxes (or a LAN), the build above works exactly as written.
Running your own server? These guides assume you have a Linux box somewhere — see our guide to choosing a VPS in 2026 for the 7 specs that actually matter before you spin one up.
