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
leastconnto 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
/healthto 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-Forheader 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.

