HAProxy terminates TLS by binding a frontend to a port with the ssl keyword and a crt reference. The mechanics are simple; the surface area is wide. This article covers the production setup we use: PEM bundle layout, sane cipher defaults, ALPN for HTTP/2, OCSP stapling, and the verification steps before pointing real traffic at the new listener.
How to verify
Before you cut over, check the listener actually presents the certificate you intend, supports the protocols you expect, and chains correctly.
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
openssl s_client -connect example.com:443 -servername example.com -alpn h2,http/1.1 < /dev/null
openssl s_client -connect example.com:443 -servername example.com -status < /dev/null | grep -i 'ocsp response'
sudo journalctl -u haproxy -n 100 --no-pager | grep -iE 'ssl|crt'
The -alpn flag negotiates h2 first, then http/1.1; the response shows what HAProxy chose. -status requests OCSP stapling — “OCSP Response Status: successful” confirms HAProxy is stapling. If you see “no response sent” with stapling configured, the OCSP cache is empty or the responder is unreachable.
What’s happening
HAProxy’s TLS stack reads crt files as concatenated PEM bundles: certificate, intermediate chain, and private key in one file. (Optionally, an OCSP response in a sibling .ocsp file and a DH params block.) Per-bind options live on the bind line; defaults that apply to all binds live in global under ssl-default-bind-*.
The flow on a TLS frontend:
- Client connects to port 443.
- HAProxy reads the ClientHello, picks a certificate by SNI (matching
crt-listentries or the file incrt). - TLS handshake completes; ALPN negotiates h2 or http/1.1.
- HAProxy parses the HTTP request and applies ACLs /
use_backend.
Two important separations:
- Per-listener vs global:
ssl-default-bind-optionsis the floor. Per-bindssl-min-ver TLSv1.3raises it for that listener only. - Front-side vs back-side: terminating at HAProxy and re-encrypting to the backend are two independent configs. Most clients terminate at HAProxy and forward over HTTP on a private VLAN; re-encryption is optional and configured in the
serverline withssl verify required ca-file ....
The procedure
-
Set sane global defaults. Put this in
globalso any newbindline inherits a safe baseline:global ssl-default-bind-ciphers 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 tune.ssl.default-dh-param 2048ssl-default-bind-cipherscontrols TLS 1.0-1.2 ciphers;ssl-default-bind-ciphersuitescontrols TLS 1.3. They are separate strings — setting only the first leaves TLS 1.3 on built-in defaults. -
Build the PEM bundle. From a Let’s Encrypt or commercial CA, concatenate end-entity certificate, intermediate chain, and private key:
sudo mkdir -p /etc/haproxy/certs sudo cat fullchain.pem privkey.pem > /etc/haproxy/certs/example.com.pem sudo chmod 640 /etc/haproxy/certs/example.com.pem sudo chown root:haproxy /etc/haproxy/certs/example.com.pemMode 640 with group
haproxykeeps the key unreadable to other users. Owning the file as root and giving the haproxy group read-only is the production pattern. -
Bind the frontend with TLS. Single-certificate listener with ALPN:
frontend fe_https bind *:443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1 http-request redirect scheme https code 301 unless { ssl_fc } default_backend be_appThe
alpn h2,http/1.1is what unlocks HTTP/2 on the front side. Without it, browsers fall back to HTTP/1.1. -
Add HSTS at the response layer. Once HTTPS is working end-to-end and you have committed to staying on HTTPS, add HSTS — but not before, and not with
preloaduntil you have lived withmax-agefor at least a month:frontend fe_https bind *:443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1 http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" default_backend be_app -
Multiple certificates via crt-list. When you serve many domains, use a
crt-listso you can add certificates without editing thebindline:cat <<'EOF' | sudo tee /etc/haproxy/certs/crt-list.txt /etc/haproxy/certs/example.com.pem example.com www.example.com /etc/haproxy/certs/api.example.com.pem api.example.com EOFfrontend fe_https bind *:443 ssl crt-list /etc/haproxy/certs/crt-list.txt alpn h2,http/1.1The hostnames at the end of each line are the SNI patterns HAProxy matches against; the certificate’s CN/SAN must also match for browsers to accept it.
-
OCSP stapling. HAProxy staples an OCSP response if a
.ocspfile exists next to the.pem. Generate it once and refresh on a timer:openssl ocsp -issuer chain.pem -cert cert.pem \ -url $(openssl x509 -in cert.pem -noout -ocsp_uri) \ -no_nonce -respout /etc/haproxy/certs/example.com.pem.ocsp echo "set ssl ocsp-response $(base64 -w0 /etc/haproxy/certs/example.com.pem.ocsp)" \ | sudo socat /run/haproxy/admin.sock -Refresh the OCSP response every 24h via cron; runtime API load makes the new staple live without reload.
-
Validate end-to-end. TLS labs (qualys, mozilla observatory) and a local
openssl s_clientconfirm the handshake. Then load the site in a real browser and check the network panel for HTTP/2.
Common pitfalls
- A wrong file order in the PEM bundle (key before cert, intermediate missing) fails at parse time with an unhelpful “unable to load certificate” —
openssl x509 -in cert.pem -noout -textconfirms the file is parseable as a cert. - Forgetting
alpn h2,http/1.1means modern clients get HTTP/1.1 even though both sides support h2. Test withopenssl s_client -alpn h2. - HSTS with
preloadis a one-way door — once submitted to the preload list, you cannot remove it without browser delays. Addpreloadonly after a month of stable HSTS. tune.ssl.default-dh-param 2048matters only for DH ciphersuites; on modern bind lines with ECDHE-only ciphers, DH params are unused. Setting them anyway is harmless and forward-compatible.- The PEM bundle file permission must be readable by the
haproxyuser. If you copied the file as root with mode 600, HAProxy starts but the bind silently fails to load the cert and falls back to a self-signed default.
Stack Harbor runs TLS rotation as a managed concern — Let’s Encrypt or commercial CA, automated renewal, OCSP refresh, hot-reload via runtime API, and a monthly “expiring in 30 days” report that surfaces certs we did not auto-renew. For multi-region clients with dozens of certificates, this is what we manage as part of Multi-region Environments.