Web servers
How to set up SSL termination in HAProxy with Let's Encrypt
HAProxy terminates HTTPS with a Let's Encrypt certificate stored as one combined PEM file (chain plus key). Certbot answers challenges on a private port behind HAProxy, and a deploy hook rebuilds the PEM and reloads HAProxy after every renewal.
The configuration also redirects HTTP to HTTPS and forwards traffic to a backend application. It applies to HAProxy 2.8 and later (including 3.x LTS releases) with Certbot 4.1 or later.
Prerequisites
HAProxy owns ports 80 and 443; Certbot answers challenges on a private port behind it.
- A Linux server with HAProxy installed and sudo access; Certbot installed from snap (
sudo snap install --classic certbot). - DNS
A/AAAArecords forexample.comandwww.example.compointing at HAProxy, for example203.0.113.10. - Inbound TCP 80 and 443 open to all sources. Port 8888 must stay closed to the outside; it only listens on 127.0.0.1.
- A backend application reachable from HAProxy, for example
127.0.0.1:8080.
Step 1: Route ACME challenges on port 80
Start with an HTTP-only configuration that sends challenge requests to Certbot and everything else to the application:
# /etc/haproxy/haproxy.cfg (excerpt)
frontend web
bind :80
acl acme path_beg /.well-known/acme-challenge/
use_backend certbot if acme
default_backend app
backend certbot
server certbot 127.0.0.1:8888
backend app
server app1 127.0.0.1:8080 check
sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy
Step 2: Obtain the certificate with Certbot standalone
Certbot's standalone mode starts a temporary web server on 127.0.0.1:8888 that serves only the challenge:
sudo certbot certonly --standalone \
--http-01-address 127.0.0.1 --http-01-port 8888 \
-d example.com -d www.example.com
The certificate lands in /etc/letsencrypt/live/example.com/.
Step 3: Build the combined PEM with a deploy hook
Write a deploy hook that concatenates the chain and key and reloads HAProxy. Certbot runs every executable in this directory after each successful renewal:
sudo mkdir -p /etc/haproxy/certs
sudo chmod 700 /etc/haproxy/certs
sudo tee /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh > /dev/null <<'EOF'
#!/bin/sh
set -e
name=$(basename "$RENEWED_LINEAGE")
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" > "/etc/haproxy/certs/$name.pem.tmp"
chmod 600 "/etc/haproxy/certs/$name.pem.tmp"
mv "/etc/haproxy/certs/$name.pem.tmp" "/etc/haproxy/certs/$name.pem"
systemctl reload haproxy
EOF
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
Run it once by hand for the certificate from Step 2:
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/example.com /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
Step 4: Enable the TLS listener and the redirect
Now that /etc/haproxy/certs/example.com.pem exists, add the 443 bind, a TLS baseline and the redirect:
global
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
frontend web
bind :80
bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
acl acme path_beg /.well-known/acme-challenge/
http-request redirect scheme https code 301 unless { ssl_fc } || acme
http-request set-header X-Forwarded-Proto https if { ssl_fc }
use_backend certbot if acme
default_backend app
sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy
Do not add OCSP options for Let's Encrypt certificates; they no longer contain an OCSP URL.
Verify
The endpoint should present the Let's Encrypt certificate, and HTTP should redirect to HTTPS. Check it with the SSL checker, or from a terminal:
curl ssl.sh/example.com
Then confirm the chain HAProxy sends and the redirect:
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | grep -E '^ *[0-9] s:'
curl -sI http://example.com | head -n 3
You should see two certificates (depth 0 and 1) in the chain output.
Automatic renewal
The Certbot snap timer runs certbot renew twice a day and follows Let's Encrypt's ARI window (Certbot 4.1+). Because the certificate was obtained with --standalone on port 8888, renewal reuses those settings, and the deploy hook from Step 3 rebuilds the PEM and reloads HAProxy. Test the whole path:
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
A dry run does not execute deploy hooks, so after the first real renewal confirm the new expiry date on the live endpoint. The same setup keeps working when Let's Encrypt's default lifetime drops to 64 days in February 2027. Add the site to ssl.sh monitoring (in development) as a safety net.
Troubleshooting
- unable to load SSL private key or No Private Key found: the PEM file lacks the key or has it in the wrong file; rebuild it with the hook.
- unable to stat SSL certificate from file: the
crtpath is empty or wrong; HAProxy refuses to start with an empty certificate directory. - Certbot timeout during connect: the ACME ACL is not matching or port 80 is blocked; see certbot timeout during connect.
- Clients report an incomplete chain: the PEM was built from
cert.peminstead offullchain.pem; see missing intermediate certificate. - Redirect loop behind a CDN: the CDN connects over HTTP, so
ssl_fcis false; redirect at the CDN instead, see redirect HTTP to HTTPS.
FAQ
Why does HAProxy need the key and certificate in one file?
The crt keyword expects a PEM file containing the certificate, its intermediates and the private key. HAProxy can also load a separate .key file with the same base name, but one combined file per site is the simplest layout to automate.
Can HAProxy re-encrypt traffic to the backend?
Yes. Add ssl verify required ca-file /etc/ssl/certs/ca-certificates.crt (plus sni str(backend.example.com) if needed) to the server line. Without them, HAProxy terminates TLS and talks plain HTTP to a backend on localhost or a private network.
Should I use HAProxy's built-in ACME client instead of Certbot?
It removes the deploy hook, but it was introduced in HAProxy 3.2 as an experimental feature with limited challenge support. Certbot with a deploy hook works on every HAProxy version; check the configuration manual of your release before switching.