HAProxy is one of the most widely deployed load balancers and TLS terminators in production environments. It can terminate TLS (decrypt and proxy plain HTTP to backends), bridge TLS (re-encrypt to backends), or pass TLS through unchanged to backend servers. This article covers configuring SSL termination with modern cipher settings, managing multiple certificates for different domains, enabling OCSP stapling, and automating certificate renewal.
HAProxy TLS terminology
| Mode | What HAProxy does | Use case |
|---|---|---|
| Termination | Decrypts TLS, proxies plain HTTP to backend | Most web apps, APIs |
| Passthrough | Forwards TCP without decrypting | When backends must see the original TLS |
| Bridge | Decrypts, re-encrypts to backend | End-to-end encryption with backend TLS |
Installing HAProxy with TLS support
HAProxy must be compiled with OpenSSL to support TLS. Most distribution packages include this:
haproxy -vv | grep "OpenSSL"
# Should show: Built with OpenSSL version: ...
If not, install a newer version from the HAProxy PPA (Ubuntu/Debian):
sudo add-apt-repository ppa:vbernat/haproxy-2.8
sudo apt install haproxy=2.8.*
Step 1: Prepare the certificate bundle
HAProxy reads a single .pem file per domain that contains the private key, certificate, and certificate chain in that order:
DOMAIN=example.com
# Create the combined PEM file (key first, then fullchain)
cat /etc/letsencrypt/live/${DOMAIN}/privkey.pem \
/etc/letsencrypt/live/${DOMAIN}/fullchain.pem \
> /etc/haproxy/certs/${DOMAIN}.pem
# Secure the file
chmod 600 /etc/haproxy/certs/${DOMAIN}.pem
chown haproxy:haproxy /etc/haproxy/certs/${DOMAIN}.pem
For multiple domains with separate certificates:
mkdir -p /etc/haproxy/certs
for domain in example.com api.example.com app.example.com; do
cat /etc/letsencrypt/live/${domain}/privkey.pem \
/etc/letsencrypt/live/${domain}/fullchain.pem \
> /etc/haproxy/certs/${domain}.pem
chmod 600 /etc/haproxy/certs/${domain}.pem
done
Step 2: Basic HAProxy TLS termination configuration
/etc/haproxy/haproxy.cfg:
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
user haproxy
group haproxy
daemon
# TLS settings
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
ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-server-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5s
timeout client 50s
timeout server 50s
errorfile 400 /etc/haproxy/errors/400.http
errorfile 403 /etc/haproxy/errors/403.http
errorfile 408 /etc/haproxy/errors/408.http
errorfile 500 /etc/haproxy/errors/500.http
# HTTP frontend — redirect all to HTTPS
frontend http_frontend
bind *:80
mode http
option httplog
redirect scheme https code 301 if !{ ssl_fc }
# HTTPS frontend — TLS termination
frontend https_frontend
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1
# Add security headers
http-response add-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
http-response add-header X-Frame-Options "SAMEORIGIN"
http-response add-header X-Content-Type-Options "nosniff"
# Route by Host header (SNI-based routing)
use_backend api_backend if { hdr(host) -i api.example.com }
default_backend web_backend
backend web_backend
balance roundrobin
option httpchk GET /health
http-check expect status 200
server web1 10.0.0.1:8080 check
server web2 10.0.0.2:8080 check
backend api_backend
balance roundrobin
server api1 10.0.0.10:8080 check
server api2 10.0.0.11:8080 check
Multi-certificate support for multiple domains
HAProxy’s crt directive can reference a directory to load all certificates in it:
frontend https_frontend
bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
HAProxy selects the certificate matching the SNI hostname. The filename does not matter — HAProxy reads the CN and SAN from each certificate. Place all .pem files (key + fullchain) in /etc/haproxy/certs/.
To set a default certificate when SNI does not match:
bind *:443 ssl crt /etc/haproxy/certs/default.pem crt /etc/haproxy/certs/ alpn h2,http/1.1
TLS passthrough (TCP mode)
When backends handle their own TLS (or you cannot terminate at the load balancer):
frontend tcp_https
bind *:443
mode tcp
option tcplog
tcp-request inspect-delay 5s
tcp-request content accept if { req_ssl_hello_type 1 }
use_backend ssl_app if { req_ssl_sni -i app.example.com }
default_backend ssl_default
backend ssl_app
mode tcp
server app1 10.0.0.1:443 check
backend ssl_default
mode tcp
server default1 10.0.0.2:443 check
Note: In TCP mode, HAProxy cannot add HTTP headers, apply ACLs based on URL paths, or terminate TLS. It routes based on the SNI name visible in the TLS ClientHello.
TLS bridging (re-encryption to backends)
For end-to-end encryption where HAProxy decrypts at the frontend and re-encrypts to backends:
backend secure_backend
balance roundrobin
option ssl-hello-chk
server backend1 10.0.0.1:443 ssl verify required ca-file /etc/haproxy/certs/backend-ca.pem check
server backend2 10.0.0.2:443 ssl verify required ca-file /etc/haproxy/certs/backend-ca.pem check
verify required tells HAProxy to verify the backend’s certificate. ca-file is the CA that signed the backend certificates.
OCSP stapling
OCSP stapling embeds the certificate revocation status in the TLS handshake, reducing client latency and improving privacy:
# Download the OCSP response
openssl ocsp \
-issuer /etc/letsencrypt/live/example.com/chain.pem \
-cert /etc/letsencrypt/live/example.com/cert.pem \
-url http://r3.o.lencr.org \
-respout /etc/haproxy/certs/example.com.ocsp \
-noverify
Include the OCSP file alongside the certificate:
frontend https_frontend
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem crt-ignore-err all alpn h2,http/1.1
HAProxy looks for a .ocsp file with the same basename as the .pem certificate file automatically.
Automate OCSP updates in a cron job:
# /etc/cron.daily/haproxy-ocsp
#!/bin/bash
for pem in /etc/haproxy/certs/*.pem; do
domain=$(basename ${pem} .pem)
cert_dir=/etc/letsencrypt/live/${domain}
if [ -d "${cert_dir}" ]; then
openssl ocsp \
-issuer ${cert_dir}/chain.pem \
-cert ${cert_dir}/cert.pem \
-url http://r3.o.lencr.org \
-respout /etc/haproxy/certs/${domain}.ocsp \
-noverify 2>/dev/null
fi
done
echo "show ssl ocsp-response" | socat stdio /run/haproxy/admin.sock
Certificate renewal automation
sudo nano /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
#!/bin/bash
# Rebuild the combined PEM files and reload HAProxy
CERTS_DIR=/etc/haproxy/certs
for domain_dir in /etc/letsencrypt/live/*/; do
domain=$(basename ${domain_dir})
cat ${domain_dir}/privkey.pem ${domain_dir}/fullchain.pem \
> ${CERTS_DIR}/${domain}.pem
chmod 600 ${CERTS_DIR}/${domain}.pem
chown haproxy:haproxy ${CERTS_DIR}/${domain}.pem
done
# Reload without dropping connections
systemctl reload haproxy
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
systemctl reload haproxy performs a graceful reload — new connections use the new certificate while existing connections drain normally.
HAProxy runtime API for hot certificate updates
HAProxy 2.2+ supports updating certificates at runtime without a reload:
# Update a certificate at runtime via the admin socket
echo "set ssl cert /etc/haproxy/certs/example.com.pem" | socat stdio /run/haproxy/admin.sock
# Paste the new PEM content here, then press Ctrl-D
# Commit the update
echo "commit ssl cert /etc/haproxy/certs/example.com.pem" | socat stdio /run/haproxy/admin.sock
This is fully zero-downtime — no reload needed.
Verify TLS configuration
# Test cipher and protocol
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>&1 \
| grep -E "(Protocol|Cipher|subject|notAfter)"
# Test HTTP/2
curl -I --http2 https://example.com
# Check HAProxy stats (if enabled)
echo "show ssl cert /etc/haproxy/certs/example.com.pem" | socat stdio /run/haproxy/admin.sock
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
SSL handshake failure | Cipher mismatch or old TLS version | Check ssl-default-bind-options and client TLS version |
no certificate or key found | PEM file missing key or cert, or wrong order | Ensure key is first in the PEM file |
unable to load SSL private key | Key does not match certificate | Regenerate the combined PEM from matching files |
backend server certificate verification failed | Wrong CA file for backend | Set ca-file to the CA that signed backend certs |
| Old certificate served after renewal | HAProxy not reloaded after cert copy | Run systemctl reload haproxy in the deploy hook |
Summary
HAProxy TLS termination requires a combined PEM file containing the private key followed by the full certificate chain. Set global defaults for cipher suites and minimum TLS version in the global section to apply them to all frontends. Use a directory in the crt directive to serve multiple certificates, with SNI-based selection. HAProxy supports zero-downtime certificate updates via both the runtime API and graceful reload. Automate Let’s Encrypt renewal with a deploy hook that rebuilds the combined PEM and reloads HAProxy.