HAProxy Layer 7 Load Balancer on CentOS Stream 9: Installation, Configuration, and Backend Health Checks

CentOS tutorial - IT technology blog
CentOS tutorial - IT technology blog

When one server is no longer enough

Back when CentOS 8 hit EOL, I had to migrate 5 servers to Rocky Linux in a single week — that was the first time I truly understood why a load balancer matters. Before that, I figured “a powerful server is enough.” Dead wrong. No matter how good the hardware is, it won’t save you when traffic spikes 5x in a few hours — or when you need to deploy without tolerating even 30 seconds of downtime.

HAProxy (High Availability Proxy) is the tool I keep coming back to. Lightweight, stable, free. Nginx and Apache can do load balancing too, but that’s not their primary job — HAProxy was designed from the ground up for this purpose, so it handles tens of thousands of requests per second on mid-range hardware while barely touching the CPU.

Layer 7 means HAProxy understands HTTP content — it routes traffic based on URL paths, headers, cookies, and more. Unlike Layer 4 which only looks at IP/port, Layer 7 lets you send /api/* to server A, /static/* to server B, or implement sticky sessions based on user cookies. In practice: your React frontend goes to one pool, your REST API goes to another — all behind a single public IP.

Installing HAProxy on CentOS Stream 9

Lab setup

  • Load Balancer: 192.168.1.10 — CentOS Stream 9 machine running HAProxy
  • Backend 1: 192.168.1.21 — Apache or Nginx running
  • Backend 2: 192.168.1.22 — Apache or Nginx running

Installing the package

CentOS Stream 9 ships HAProxy in the AppStream repo — install it directly with dnf:

sudo dnf install -y haproxy
haproxy -v

Run haproxy -v to confirm — AppStream typically ships HAProxy 2.4.x, which is more than sufficient for most production workloads.

Handling SELinux before configuring

This is the step many people skip, then wonder why they can’t reach their backends. By default, SELinux will block HAProxy from connecting to arbitrary ports. You need to enable the following boolean:

# Allow HAProxy to connect to backends
sudo setsebool -P haproxy_connect_any 1

# Verify it's enabled
getsebool haproxy_connect_any

If your backends use non-standard ports like 8080, you also need to declare them in the SELinux policy:

sudo semanage port -a -t http_port_t -p tcp 8080

Opening ports with firewalld

Port 80 for clients, port 8404 for the Stats monitoring page — open them once, reload, and you’re done:

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-port=8404/tcp
sudo firewall-cmd --reload

# Confirm
sudo firewall-cmd --list-all

Configuring HAProxy Layer 7

Writing the configuration file

Back up the original file, then create a new configuration:

sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak
sudo nano /etc/haproxy/haproxy.cfg
#---------------------------------------------------------------------
# Global settings
#---------------------------------------------------------------------
global
    log         /dev/log local0
    log         /dev/log local1 notice
    chroot      /var/lib/haproxy
    pidfile     /var/run/haproxy.pid
    maxconn     4000
    user        haproxy
    group       haproxy
    daemon
    stats socket /var/lib/haproxy/stats

#---------------------------------------------------------------------
# Defaults applied to all frontends/backends
#---------------------------------------------------------------------
defaults
    mode                    http
    log                     global
    option                  httplog
    option                  dontlognull
    option http-server-close
    option forwardfor       except 127.0.0.0/8
    option                  redispatch
    retries                 3
    timeout http-request    10s
    timeout queue           1m
    timeout connect         10s
    timeout client          1m
    timeout server          1m
    timeout http-keep-alive 10s
    timeout check           10s
    maxconn                 3000

#---------------------------------------------------------------------
# Stats page — http://192.168.1.10:8404/stats
#---------------------------------------------------------------------
frontend stats
    bind *:8404
    stats enable
    stats uri /stats
    stats refresh 10s
    stats auth admin:StrongPassword123
    stats admin if TRUE

#---------------------------------------------------------------------
# Main frontend — receives requests from clients
#---------------------------------------------------------------------
frontend http_front
    bind *:80
    default_backend web_servers

    # Layer 7 routing: /api/* goes to a dedicated backend
    acl is_api path_beg /api/
    use_backend api_servers if is_api

#---------------------------------------------------------------------
# Backend web servers
#---------------------------------------------------------------------
backend web_servers
    balance roundrobin
    option httpchk GET /health
    http-check expect status 200

    server web1 192.168.1.21:80 check inter 5s fall 3 rise 2
    server web2 192.168.1.22:80 check inter 5s fall 3 rise 2

#---------------------------------------------------------------------
# Backend API servers
#---------------------------------------------------------------------
backend api_servers
    balance leastconn
    option httpchk GET /api/health
    http-check expect status 200

    server api1 192.168.1.21:8080 check inter 5s fall 3 rise 2
    server api2 192.168.1.22:8080 check inter 5s fall 3 rise 2

Key parameter breakdown

  • balance roundrobin: Distributes requests evenly in rotation. Use leastconn to send to the server with the fewest active connections — better suited for APIs with long-running requests.
  • option httpchk GET /health: HAProxy sends an HTTP GET to /health to verify the backend is alive.
  • inter 5s: Health check interval every 5 seconds.
  • fall 3: Marks a server DOWN after 3 consecutive failures.
  • rise 2: Brings a server back UP after 2 consecutive successes.
  • option forwardfor: Adds the X-Forwarded-For header so backends can see the real client IP.

Creating the /health endpoint on backends

Backends need to return 200 OK for HAProxy to confirm they’re alive. With Apache, the quickest approach:

# Run on each backend server
echo "OK" | sudo tee /var/www/html/health

Starting HAProxy

# Validate the configuration first
sudo haproxy -c -f /etc/haproxy/haproxy.cfg

# Start and enable
sudo systemctl start haproxy
sudo systemctl enable haproxy

# Check status
sudo systemctl status haproxy

Testing and Monitoring

Accessing the Stats Page

Visit http://192.168.1.10:8404/stats in your browser and log in with admin / StrongPassword123. The dashboard is solid enough for day-to-day monitoring — it shows in real time:

  • Status of each backend (green = UP, red = DOWN)
  • Request count, bytes in/out, current sessions
  • Average response time and error rate

Testing load balancing with curl

Instead of guessing, just fire off 10 consecutive requests with curl and watch how HAProxy distributes them:

for i in {1..10}; do
    curl -s http://192.168.1.10/ -o /dev/null -w "%{http_code} from: %{url_effective}\n"
done

If each backend returns a different server name in the response headers, the distribution becomes even clearer:

for i in {1..6}; do
    curl -si http://192.168.1.10/ | grep -i 'x-served-by\|server:'
done

Checking backend state via socket

To inspect each server’s details without opening a browser:

echo "show servers state" | sudo socat stdio /var/lib/haproxy/stats

Tailing logs in real time

Open a separate terminal and run this alongside your tests — HAProxy’s logs are remarkably detailed:

sudo journalctl -u haproxy -f

Each log line captures everything: client IP, request path, response code, processing time, and which backend served the request.

Failover testing

The most realistic test: shut down one backend entirely and observe how HAProxy reacts:

# On backend server 192.168.1.21
sudo systemctl stop httpd

# On the load balancer, watch the logs
sudo journalctl -u haproxy -f

Within 15 seconds (3 checks × 5 seconds each), HAProxy marks web1 as DOWN and shifts all traffic to web2. The Stats page shows web1 in red. Once you restart httpd, after 10 seconds (2 successful checks), web1 automatically comes back UP.

A lesson from real-world experience

After that rushed CentOS 8 migration, I learned one thing the hard way: always validate the logic inside your health check endpoint, not just whether the web server is responding. Once HAProxy was showing web1 as UP with a green indicator, but the application was actually broken because the database had lost its connection — the web server was still returning 200 for /health. After that, I added real logic to the /health endpoint: check the database ping, cache connection… if anything fails, return 500 so HAProxy knows to route around that server.

Wrapping up

This is everything you need for a production-ready Layer 7 load balancer on CentOS Stream 9 — SELinux enabled, firewalld configured properly, nothing disabled just to make things easier. HAProxy automatically detects backend failures and fails over, the Stats page provides real-time monitoring, and Layer 7 ACLs handle URL-based traffic routing.

If you want to go further: add SSL/TLS termination at HAProxy with Let’s Encrypt, or configure sticky sessions for stateful applications. But right now, when a backend goes down, HAProxy automatically shifts traffic to the remaining one — and your users never know anything happened.

Share: