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
sudoprivileges. - One or more domain names with DNS
Arecords pointing to the load balancer's public IP. This guide usesyour_domainandapi.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:80and10.0.0.12:80for the website and10.0.0.21:8080for the API. Each backend should answerGET /healthwith200; 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.1enables HTTP/2 for clients that support it. Backends still receive HTTP/1.1.X-Forwarded-Protoandoption forwardfor(which addsX-Forwarded-For) tell the application the original scheme and client IP, since it now only sees plain HTTP from HAProxy.- The
host_apiACL matches theHostheader, anduse_backendsends matching requests to the API servers. Everything else goes toweb_servers. check inter 5s fall 3 rise 2probes 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 fileorNo certificate foundon 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.pemcontains both aCERTIFICATEand aPRIVATE KEYblock.- Certbot fails with a connection or timeout error: make sure the
http_infrontend still routes/.well-known/acme-challenge/to thecertbotbackend, 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 orHostheader does not match what your application expects. Test it from the load balancer withcurl -i -H "Host: your_domain" http://10.0.0.11/health. - Every request is logged with
503and<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.
