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 web1 and web2 on the same private network, 10.0.0.11 and 10.0.0.12, each running a web server on port 80. Any web server works; the examples assume Nginx.
  • A non-root user with sudo privileges on all servers.
  • A domain name with an A record pointing to the public IP of the HAProxy server. This guide uses your_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)

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 (http or tcp) 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 forwardfor adds an X-Forwarded-For header with the client's IP, so the backends can log the real visitor instead of the load balancer.
  • balance roundrobin sends requests to each server in turn.
  • option httpchk with http-check send and http-check expect turns on HTTP health checks: every 3 seconds (inter 3s) HAProxy requests / and expects a 200. A server is marked down after 3 failed checks (fall 3) and up again after 2 successful ones (rise 2).
  • The acme ACL sends Let's Encrypt validation requests to a local Certbot process you will use in the next step. Everything else goes to web_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:

AlgorithmBehaviorGood for
roundrobinEach server in turn, respecting weightWeb applications with similar request costs
leastconnServer with the fewest active connectionsLong connections: WebSockets, databases, slow APIs
sourceHash of the client IP, same client lands on same serverSimple 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.