HAProxy + Keepalived on AlmaLinux 9 and RHEL 9: A Two-Node HA Load Balancer

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 *:80 listens 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.

Scroll to Top