HAProxy is a fast, open source load balancer and reverse proxy. Terminating TLS in HAProxy means it holds the certificates, decrypts HTTPS traffic and forwards plain HTTP to your backend servers over a private network, so certificates are managed in one place. In this tutorial you will configure HAProxy on Ubuntu 24.04 to obtain and renew Let's Encrypt certificates, serve several domains from one IP address with SNI, route requests to different backends by hostname, and rate limit abusive clients.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS that will act as the load balancer, for example a CubePath VPS, with a non-root user with sudo privileges.
  • One or more domain names with DNS A records pointing to the load balancer's public IP. This guide uses your_domain and api.your_domain.
  • At least one backend web server reachable from the load balancer, ideally over a private network. The examples use 10.0.0.11:80 and 10.0.0.12:80 for the website and 10.0.0.21:8080 for the API. Each backend should answer GET /health with 200; if it does not, change the health check path in Step 5.
  • Ports 80 and 443 open to the internet.

Step 1 - Installing HAProxy

Ubuntu 24.04 includes HAProxy 2.8, a long-term support release that has every feature used in this guide. Install it:

sudo apt update
sudo apt install haproxy

Check the version and that the service is running:

haproxy -v
systemctl status haproxy --no-pager
HAProxy version 2.8.5-1ubuntu3 2024/04/01 - https://haproxy.org/
...
     Active: active (running) since ...

Open the web ports in UFW, together with SSH so you do not lock yourself out:

sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable

Step 2 - Writing an HTTP-only configuration

Let's Encrypt validates domain ownership over port 80, and HAProxy refuses to start an HTTPS listener without a certificate. You will therefore start with a configuration that only listens on port 80 and forwards the ACME challenge path to Certbot, which you will run on local port 8888.

Back up the default configuration and open it:

sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.orig
sudo nano /etc/haproxy/haproxy.cfg

Replace the contents with the following. The global and defaults sections keep Ubuntu's logging and chroot setup, and add TLS settings based on Mozilla's intermediate profile (TLS 1.2 and 1.3 only, strong ciphers):

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-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
    ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets

defaults
    log     global
    mode    http
    option  httplog
    option  dontlognull
    timeout connect 5s
    timeout client  30s
    timeout server  30s
    timeout http-request 10s

frontend http_in
    bind :80
    acl is_acme path_beg /.well-known/acme-challenge/
    use_backend certbot if is_acme

backend certbot
    server certbot 127.0.0.1:8888

timeout http-request closes connections that send headers too slowly, a basic protection against slowloris-style attacks.

Validate the file and reload HAProxy:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
Configuration file is valid

Step 3 - Obtaining certificates with Certbot

Install Certbot:

sudo apt install certbot

Request a certificate in standalone mode, listening on port 8888 behind HAProxy. Replace the domains and email address with your own:

sudo certbot certonly --standalone --http-01-port 8888 \
  -d your_domain -d www.your_domain \
  --email you@your_domain --agree-tos --no-eff-email
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/your_domain/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/your_domain/privkey.pem

Request a separate certificate for the API subdomain:

sudo certbot certonly --standalone --http-01-port 8888 -d api.your_domain

Certbot stores --http-01-port 8888 in each certificate's renewal settings, so automatic renewals use the same path through HAProxy.

Step 4 - Building the HAProxy certificate bundles

HAProxy expects the certificate chain and private key in a single PEM file. Create a directory for them that only root can read:

sudo install -d -m 700 /etc/haproxy/certs

Certbot runs every script in /etc/letsencrypt/renewal-hooks/deploy/ after a successful renewal, with the certificate directory in the RENEWED_LINEAGE variable. Create a hook that rebuilds the bundle and reloads HAProxy:

sudo nano /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
#!/usr/bin/env bash
set -euo pipefail

domain="$(basename "$RENEWED_LINEAGE")"
bundle="/etc/haproxy/certs/${domain}.pem"

umask 077
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" > "${bundle}.tmp"
mv "${bundle}.tmp" "$bundle"

systemctl reload haproxy

Writing to a temporary file and moving it into place means HAProxy never reads a half-written bundle. Make the hook executable and run it once for each certificate you already have:

sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/your_domain /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/api.your_domain /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
sudo ls -l /etc/haproxy/certs
-rw------- 1 root root 5372 Sep 25 10:14 api.your_domain.pem
-rw------- 1 root root 5380 Sep 25 10:14 your_domain.pem

HAProxy reads the certificates as root before it drops privileges, so root-only permissions are correct.

Step 5 - Enabling the HTTPS frontend

Now add the HTTPS listener. Open the configuration again:

sudo nano /etc/haproxy/haproxy.cfg

Replace the frontend http_in block with the version below, which redirects everything except ACME challenges to HTTPS, and add the new https_in frontend and backends after the existing backend certbot:

frontend http_in
    bind :80
    acl is_acme path_beg /.well-known/acme-challenge/
    http-request redirect scheme https code 301 unless is_acme
    use_backend certbot if is_acme

frontend https_in
    bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1

    http-request set-header X-Forwarded-Proto https
    option forwardfor
    http-response set-header Strict-Transport-Security "max-age=31536000"

    acl host_api req.hdr(host) -i api.your_domain
    use_backend api_servers if host_api
    default_backend web_servers

backend web_servers
    balance roundrobin
    option httpchk
    http-check send meth GET uri /health ver HTTP/1.1 hdr Host your_domain
    http-check expect status 200
    server web1 10.0.0.11:80 check inter 5s fall 3 rise 2
    server web2 10.0.0.12:80 check inter 5s fall 3 rise 2

backend api_servers
    balance leastconn
    option httpchk
    http-check send meth GET uri /health ver HTTP/1.1 hdr Host api.your_domain
    http-check expect status 200
    server api1 10.0.0.21:8080 check inter 5s fall 3 rise 2

The key lines are:

  • crt /etc/haproxy/certs/ loads every bundle in the directory. HAProxy reads the names in each certificate and picks the right one for each connection from the SNI hostname the client sends, so adding a domain only requires a new file.
  • alpn h2,http/1.1 enables HTTP/2 for clients that support it. Backends still receive HTTP/1.1.
  • X-Forwarded-Proto and option forwardfor (which adds X-Forwarded-For) tell the application the original scheme and client IP, since it now only sees plain HTTP from HAProxy.
  • The host_api ACL matches the Host header, and use_backend sends matching requests to the API servers. Everything else goes to web_servers.
  • check inter 5s fall 3 rise 2 probes each server every 5 seconds, removes it after 3 failures and restores it after 2 successes.

Validate and reload:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy

Test from your local machine. HTTP should redirect, and HTTPS should answer over HTTP/2:

curl -I http://your_domain
curl -I https://your_domain
HTTP/1.1 301 Moved Permanently
content-length: 0
location: https://your_domain/

HTTP/2 200
strict-transport-security: max-age=31536000
...

Confirm HAProxy serves the right certificate for each name through SNI:

openssl s_client -connect your_domain:443 -servername api.your_domain </dev/null 2>/dev/null | openssl x509 -noout -subject -dates
subject=CN = api.your_domain
notBefore=Sep 25 09:12:03 2026 GMT
notAfter=Dec 24 09:12:02 2026 GMT

Step 6 - Rate limiting clients with a stick table

A stick table is an in-memory table that HAProxy uses to track counters per key, such as a client IP. The following rules count HTTP requests per source address over a 10 second window and reject clients that exceed 100 requests with 429 Too Many Requests.

Add these lines to the frontend https_in section, just below the bind line:

    stick-table type ip size 100k expire 30s store http_req_rate(10s)
    http-request track-sc0 src
    http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }

Validate and reload:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy

Send a burst of requests from your local machine and count the status codes:

for i in $(seq 1 150); do curl -s -o /dev/null -w '%{http_code}\n' https://your_domain/; done | sort | uniq -c
    100 200
     50 429

You can inspect the table live through the admin socket. Install socat first:

sudo apt install socat
echo "show table https_in" | sudo socat stdio /run/haproxy/admin.sock
# table: https_in, type: ip, size:102400, used:1
0x55d1c8a3e2a0: key=203.0.113.25 use=0 exp=27845 http_req_rate(10000)=150

Adjust the threshold to your traffic. Clients behind the same NAT share an address, so a limit that is too low can block whole offices.

Step 7 - Checking backend health and logs

The admin socket also shows the state of every server:

echo "show servers state" | sudo socat stdio /run/haproxy/admin.sock

For a quicker overview, the show stat command returns CSV. This prints the proxy, server and status columns:

echo "show stat" | sudo socat stdio /run/haproxy/admin.sock | cut -d, -f1,2,18 | column -s, -t
# pxname     svname    status
http_in      FRONTEND  OPEN
https_in     FRONTEND  OPEN
certbot      certbot   no check
certbot      BACKEND   UP
web_servers  web1      UP
web_servers  web2      UP
web_servers  BACKEND   UP
api_servers  api1      UP
api_servers  BACKEND   UP

The Ubuntu package ships an rsyslog rule that writes HAProxy logs to /var/log/haproxy.log. Each line shows the frontend, the backend and server chosen, timings and the status code:

sudo tail -f /var/log/haproxy.log
Sep 25 10:21:44 lb haproxy[2211]: 203.0.113.25:51844 [25/Sep/2026:10:21:44.102] https_in~ web_servers/web1 0/0/1/12/13 200 1532 - - ---- 3/3/0/0/0 0/0 "GET / HTTP/2.0"

The ~ after the frontend name means the connection used TLS.

Step 8 - Testing certificate renewal

Certbot on Ubuntu installs a systemd timer that attempts renewal twice a day. Confirm the timer is active and simulate a renewal against the Let's Encrypt staging environment:

systemctl list-timers certbot.timer --no-pager
sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/api.your_domain/fullchain.pem (success)
  /etc/letsencrypt/live/your_domain/fullchain.pem (success)

A dry run does not execute deploy hooks. On a real renewal, the hook from Step 4 rebuilds the bundle and reloads HAProxy without dropping existing connections.

Troubleshooting

  • unable to stat SSL certificate from file or No certificate found on startup: the bundle directory is empty or a file is missing the private key. Run the deploy hook again for each domain and check that each .pem contains both a CERTIFICATE and a PRIVATE KEY block.
  • Certbot fails with a connection or timeout error: make sure the http_in frontend still routes /.well-known/acme-challenge/ to the certbot backend, that port 80 is open in UFW and in any upstream firewall, and that DNS points to this server.
  • All backend servers show DOWN: the health check path or Host header does not match what your application expects. Test it from the load balancer with curl -i -H "Host: your_domain" http://10.0.0.11/health.
  • Every request is logged with 503 and <NOSRV>: no server in the chosen backend is healthy. Check the server state as shown in Step 7.

Conclusion

HAProxy now terminates TLS for several domains with automatically renewed Let's Encrypt certificates, sends each hostname to its own backend, checks backend health and rejects clients that send too many requests. From here you can encrypt the traffic to the backends by adding ssl verify required ca-file to the server lines, enable the built-in statistics page on a private address, or put a second HAProxy node behind a floating IP with keepalived for high availability.