HAProxy is a fast, widely used open source load balancer and reverse proxy for TCP and HTTP traffic. It spreads requests across several backend servers, stops sending traffic to a server that fails its health checks, and can terminate TLS so the backends only deal with plain HTTP. In this tutorial you will install HAProxy on Ubuntu 24.04, balance traffic across two web servers, add HTTPS with a Let's Encrypt certificate that renews automatically, and use the stats page and runtime API to watch and drain backends.
Prerequisites
- One server running Ubuntu 24.04 LTS for HAProxy, for example a CubePath VPS. In this guide its private address is
10.0.0.10. - Two backend web servers named
web1andweb2on the same private network,10.0.0.11and10.0.0.12, each running a web server on port 80. Any web server works; the examples assume Nginx. - A non-root user with
sudoprivileges on all servers. - A domain name with an
Arecord pointing to the public IP of the HAProxy server. This guide usesyour_domain. - UFW enabled on the HAProxy server with SSH allowed.
To see which backend answers each request, give each web server a page that shows its hostname. On each backend, run:
hostname | sudo tee /var/www/html/index.html
On each backend, allow HTTP only from the load balancer:
sudo ufw allow from 10.0.0.10 to any port 80 proto tcp
Step 1 - Installing HAProxy
Ubuntu 24.04 ships HAProxy 2.8, a long-term support branch maintained until 2028. That is the right choice for most setups:
sudo apt update
sudo apt install haproxy
Check the version and that the service is running:
haproxy -v
sudo systemctl status haproxy --no-pager
HAProxy version 2.8.5-1ubuntu3 2024/04/01 - https://haproxy.org/
...
Active: active (running)
Noteif you need a newer branch, the HAProxy team maintains Ubuntu packages at haproxy.debian.net, which shows the exact repository to add for each version.
Open the web ports on the HAProxy server:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
Step 2 - Understanding the configuration file
HAProxy reads /etc/haproxy/haproxy.cfg. The file is split into sections:
global: process-wide settings such as logging, the user HAProxy runs as and the runtime API socket.defaults: settings inherited by every following section, such as the mode (httportcp) and timeouts.frontend: where HAProxy listens (bind) and how it chooses a backend for each request.backend: a pool of servers, the balancing algorithm and the health checks.
Keep a copy of the original file before replacing it:
sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.orig
Step 3 - Load balancing HTTP traffic
Start with a plain HTTP configuration so you can verify the balancing before adding TLS. Open the file:
sudo nano /etc/haproxy/haproxy.cfg
Replace its content with:
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin
stats timeout 30s
user haproxy
group haproxy
daemon
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
timeout connect 5s
timeout client 30s
timeout server 30s
frontend www
bind :80
acl acme path_beg /.well-known/acme-challenge/
use_backend letsencrypt if acme
default_backend web_servers
backend web_servers
balance roundrobin
option httpchk
http-check send meth GET uri / ver HTTP/1.1 hdr Host your_domain
http-check expect status 200
default-server inter 3s fall 3 rise 2
server web1 10.0.0.11:80 check
server web2 10.0.0.12:80 check
backend letsencrypt
server certbot 127.0.0.1:8888
What the important lines do:
option forwardforadds anX-Forwarded-Forheader with the client's IP, so the backends can log the real visitor instead of the load balancer.balance roundrobinsends requests to each server in turn.option httpchkwithhttp-check sendandhttp-check expectturns on HTTP health checks: every 3 seconds (inter 3s) HAProxy requests/and expects a200. A server is marked down after 3 failed checks (fall 3) and up again after 2 successful ones (rise 2).- The
acmeACL sends Let's Encrypt validation requests to a local Certbot process you will use in the next step. Everything else goes toweb_servers.
Check the syntax before applying it:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
Configuration file is valid
Reload HAProxy. A reload starts a new process with the new configuration and lets existing connections finish, so there is no downtime:
sudo systemctl reload haproxy
Send a few requests and watch them alternate between the backends:
for i in 1 2 3 4; do curl -s http://your_domain; done
web1
web2
web1
web2
Choosing a balancing algorithm
roundrobin is a good default for short HTTP requests. Change the balance line if your traffic looks different:
| Algorithm | Behavior | Good for |
|---|---|---|
roundrobin | Each server in turn, respecting weight | Web applications with similar request costs |
leastconn | Server with the fewest active connections | Long connections: WebSockets, databases, slow APIs |
source | Hash of the client IP, same client lands on same server | Simple persistence when you cannot use cookies |
If your application keeps session state in memory on each server, use cookie-based persistence instead of source. Add a cookie line to the backend and a cookie value to each server:
backend web_servers
balance roundrobin
cookie SERVERID insert indirect nocache
server web1 10.0.0.11:80 check cookie web1
server web2 10.0.0.12:80 check cookie web2
Step 4 - Adding HTTPS with Let's Encrypt
HAProxy will terminate TLS and forward plain HTTP to the backends over the private network. Install Certbot:
sudo apt install certbot
HAProxy needs the certificate and private key in a single PEM file. Create a directory for it and a Certbot deploy hook that builds that file and reloads HAProxy every time a certificate is issued or renewed:
sudo mkdir -p /etc/haproxy/certs
sudo chmod 700 /etc/haproxy/certs
sudo nano /usr/local/bin/haproxy-cert-deploy
#!/usr/bin/env bash
set -euo pipefail
umask 077
domain="$(basename "$RENEWED_LINEAGE")"
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" > "/etc/haproxy/certs/${domain}.pem"
systemctl reload haproxy
sudo chmod 755 /usr/local/bin/haproxy-cert-deploy
Request the certificate. Certbot runs its own temporary web server on 127.0.0.1:8888, and HAProxy forwards the validation request to it through the acme ACL, so HAProxy keeps serving traffic on port 80 the whole time:
sudo certbot certonly --standalone -d your_domain \
--http-01-address 127.0.0.1 --http-01-port 8888 \
--deploy-hook /usr/local/bin/haproxy-cert-deploy
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/your_domain/fullchain.pem
Confirm that the combined file exists:
sudo ls -l /etc/haproxy/certs/
-rw------- 1 root root 5342 Sep 25 10:12 your_domain.pem
Certbot saves these options, so the certbot.timer systemd timer renews the certificate with the same method and runs the hook automatically. Test renewal now:
sudo certbot renew --dry-run
Now add the HTTPS listener and redirect HTTP to HTTPS. Edit the frontend www section:
sudo nano /etc/haproxy/haproxy.cfg
frontend www
bind :80
bind :443 ssl crt /etc/haproxy/certs/your_domain.pem alpn h2,http/1.1
acl acme path_beg /.well-known/acme-challenge/
http-request redirect scheme https code 301 if !{ ssl_fc } !acme
http-request set-header X-Forwarded-Proto https if { ssl_fc }
use_backend letsencrypt if acme
default_backend web_servers
The redirect skips ACME requests so renewals keep working over HTTP. X-Forwarded-Proto tells the application that the original request used HTTPS, which frameworks need to build correct URLs and set secure cookies.
Validate and reload:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
Test both the redirect and HTTPS:
curl -sI http://your_domain | head -n 2
curl -s https://your_domain
HTTP/1.1 301 Moved Permanently
location: https://your_domain/
web1
Step 5 - Enabling the stats page
HAProxy includes a web dashboard with the state of every frontend, backend and server. Add a dedicated frontend at the end of haproxy.cfg, listening only on the private address:
frontend stats
bind 10.0.0.10:8404
stats enable
stats uri /stats
stats refresh 10s
stats auth admin:your_strong_password
Replace your_strong_password with a real password. Validate and reload:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
Allow the port only from the network or IP you administer from:
sudo ufw allow from 10.0.0.0/24 to any port 8404 proto tcp
Open http://10.0.0.10:8404/stats from a machine on that network (or through an SSH tunnel) and log in. Both servers in web_servers should be green with L7OK/200 in the check column.
Step 6 - Managing backends with the runtime API
The stats socket line in the global section exposes a runtime API on /run/haproxy/admin.sock. It lets you inspect and change server state without reloading. Install socat to talk to it:
sudo apt install socat
Show the name and status of each server:
echo "show stat" | sudo socat stdio /run/haproxy/admin.sock | cut -d, -f1,2,18 | column -s, -t
# pxname svname status
www FRONTEND OPEN
web_servers web1 UP
web_servers web2 UP
web_servers BACKEND UP
Before maintenance on a backend, put it in drain mode. HAProxy stops sending it new requests but lets current ones finish:
echo "set server web_servers/web1 state drain" | sudo socat stdio /run/haproxy/admin.sock
All new requests now go to web2. When the maintenance is done, put it back:
echo "set server web_servers/web1 state ready" | sudo socat stdio /run/haproxy/admin.sock
Changes made through the runtime API are lost on restart. Permanent changes belong in haproxy.cfg.
You can also watch the health checks work on their own. Stop Nginx on web2:
sudo systemctl stop nginx
After about 9 seconds (3 failed checks, 3 seconds apart) web2 shows as DOWN, and every request goes to web1. Start Nginx again and web2 returns after 2 successful checks.
Step 7 - Reading the logs
Ubuntu's HAProxy package includes an rsyslog rule that writes HAProxy logs to /var/log/haproxy.log:
sudo tail -f /var/log/haproxy.log
Sep 25 10:20:14 lb haproxy[2211]: 198.51.100.7:51234 [25/Sep/2026:10:20:14.201] www~ web_servers/web1 0/0/1/3/4 200 312 - - ---- 2/2/0/0/0 0/0 "GET / HTTP/2.0"
The 0/0/1/3/4 field breaks down the request time in milliseconds: time to receive the request, time in the queue, time to connect to the server, time for the server to respond, and total time. A high fourth value points to a slow backend; a high second value means requests are waiting because the servers are at their maxconn limit. Health check state changes are also logged here, for example Server web_servers/web2 is DOWN.
Troubleshooting
haproxy -c reports an error. The message includes the file and line number, for example parsing [/etc/haproxy/haproxy.cfg:32]. Fix that line and validate again before reloading. A failed reload leaves the old process running, but a failed restart leaves you with no load balancer.
All servers are DOWN in the stats page. Test from the HAProxy server exactly what the health check requests:
curl -sI -H "Host: your_domain" http://10.0.0.11/
A timeout usually means the backend's firewall does not allow the load balancer. A status other than 200 (for example a 301 to HTTPS or a 404) means the check path or http-check expect must be adjusted.
HAProxy fails to start after enabling HTTPS. Check sudo journalctl -u haproxy -n 50. If it cannot load the certificate, make sure /etc/haproxy/certs/your_domain.pem exists and contains both the certificate chain and the private key.
Certificate renewal fails. Run sudo certbot renew --dry-run and check that requests to /.well-known/acme-challenge/ reach the letsencrypt backend, and that the redirect line still excludes the acme ACL.
Backends see the load balancer IP instead of the visitor's. Configure the web server to trust X-Forwarded-For from 10.0.0.10. In Nginx this is set_real_ip_from 10.0.0.10; and real_ip_header X-Forwarded-For;.
Conclusion
You now have HAProxy balancing HTTP and HTTPS traffic across two web servers, with health checks that remove failed servers automatically, a certificate that renews itself, and the tools to monitor and drain backends. The load balancer itself is still a single point of failure: as a next step, run a second HAProxy node and move a floating IP between them with Keepalived or Pacemaker. You can also add rate limiting with stick tables, or put TCP services such as a database cluster behind HAProxy using mode tcp.
