Symptoms
SSL termination is one of the most operationally critical roles HAProxy performs, and it is also one of the most common sources of production incidents. When SSL termination breaks, the failure mode depends on where in the TLS handshake the problem occurs, which means symptoms can vary widely across client types, operating systems, and browser versions.
- Browsers display ERR_SSL_PROTOCOL_ERROR, ERR_SSL_VERSION_OR_CIPHER_MISMATCH, or SSL_ERROR_RX_RECORD_TOO_LONG
- curl returns SSL certificate problem: unable to get local issuer certificate or SSL handshake failed
- HAProxy logs show entries ending with SSL handshake failure and a backend of <NOSRV>
- Clients receive browser certificate warnings about an untrusted issuer or a hostname mismatch
- Specific browsers, TLS libraries, or device types fail the handshake while others succeed
- The wrong certificate is served for a given virtual host or domain name
- HAProxy refuses to start, logging SSL_CTX_use_PrivateKey_file failed or unable to load SSL private key
- Intermittent handshake failures affecting only a subset of incoming connections
- All traffic on port 443 drops immediately after a certificate renewal or HAProxy config change
Root Cause 1: Wrong Certificate Loaded
Why It Happens
HAProxy loads SSL certificates from PEM files named in the bind directive. When multiple certificates exist under
/etc/haproxy/certs/, it is straightforward to reference the wrong file — for example after a renewal that created a file with a new timestamp in its name, or when an automation pipeline wrote a staging certificate to the production path. HAProxy will load and serve whatever file you point it to without validating that the certificate CN or SANs match the intended domain.
How to Identify It
Inspect what certificate HAProxy is currently presenting to clients:
openssl s_client -connect 192.168.10.50:443 -servername solvethenetwork.com < /dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
Output when the wrong certificate is loaded:
subject=CN = staging.solvethenetwork.com
issuer=C = US, O = Let's Encrypt, CN = R3
notBefore=Jan 1 00:00:00 2026 GMT
notAfter=Apr 1 00:00:00 2026 GMT
The CN shows staging.solvethenetwork.com instead of solvethenetwork.com. Confirm which file HAProxy is referencing in the configuration:
grep -n "crt" /etc/haproxy/haproxy.cfg
12: bind 192.168.10.50:443 ssl crt /etc/haproxy/certs/staging-solvethenetwork.pem
How to Fix It
Verify the correct PEM file exists and contains the right certificate:
openssl x509 -in /etc/haproxy/certs/solvethenetwork.pem -noout -subject -dates
Update the bind directive to point to the correct file:
frontend https_front
bind 192.168.10.50:443 ssl crt /etc/haproxy/certs/solvethenetwork.pem
default_backend web_servers
Validate the configuration and reload without dropping connections:
haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl reload haproxy
For environments with many virtual hosts, use HAProxy's directory-based certificate loading combined with SNI selection so each domain is automatically matched to its certificate by filename:
frontend https_front
bind 192.168.10.50:443 ssl crt /etc/haproxy/certs/
default_backend web_servers
Root Cause 2: Incomplete Certificate Chain
Why It Happens
TLS requires the server to transmit not only its own leaf certificate but also every intermediate CA certificate in the chain up to (but not including) a trusted root. When the PEM file contains only the leaf certificate without intermediates, clients that do not have those intermediate certificates already cached will fail chain verification. This is a common regression after certificate renewals where automation copies only the
cert.pemfile rather than
fullchain.pem, or when a certificate is manually exported from a management platform that separates the chain into a different file.
How to Identify It
Initiate an SSL handshake and look at the chain depth and verification errors:
openssl s_client -connect 192.168.10.50:443 -servername solvethenetwork.com
A broken chain produces output similar to:
CONNECTED(00000003)
depth=0 CN = solvethenetwork.com
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 CN = solvethenetwork.com
verify error:num=21:unable to verify the first certificate
verify return:1
---
Certificate chain
0 s:CN = solvethenetwork.com
i:C = US, O = Let's Encrypt, CN = R3
---
Only depth=0 appears — the intermediate R3 is not being sent by the server. Count the certificates inside the PEM file to confirm:
grep -c "BEGIN CERTIFICATE" /etc/haproxy/certs/solvethenetwork.pem
1
A correctly chained PEM for a Let's Encrypt certificate should return 2. For a certificate with two intermediates, expect 3.
How to Fix It
Rebuild the PEM file by concatenating the leaf certificate with the intermediate bundle. HAProxy expects leaf first, then intermediates, then the private key:
cat /etc/letsencrypt/live/solvethenetwork.com/fullchain.pem \
/etc/letsencrypt/live/solvethenetwork.com/privkey.pem \
> /etc/haproxy/certs/solvethenetwork.pem
If you are not using Certbot, concatenate manually:
cat solvethenetwork.crt intermediate.crt > /tmp/chain.pem
cat /tmp/chain.pem solvethenetwork.key > /etc/haproxy/certs/solvethenetwork.pem
Verify the chain depth is now correct:
grep -c "BEGIN CERTIFICATE" /etc/haproxy/certs/solvethenetwork.pem
2
Reload HAProxy and re-run the s_client check — the output should now show the complete chain:
Certificate chain
0 s:CN = solvethenetwork.com
i:C = US, O = Let's Encrypt, CN = R3
1 s:C = US, O = Let's Encrypt, CN = R3
i:C = Digital Signature Trust Co., O = DST Root CA X3
---
Verify return code: 0 (ok)
Root Cause 3: Cipher Mismatch
Why It Happens
HAProxy allows operators to restrict the TLS cipher suite to a hardened subset using the ssl-default-bind-ciphers directive for TLS 1.2 and below, and ssl-default-bind-ciphersuites for TLS 1.3. When these are configured too aggressively — for example, restricting to only two ECDHE-GCM ciphers — older clients, embedded devices running legacy OpenSSL, or Java applications that have not been updated will fail the handshake because the ClientHello and ServerHello share no common cipher. The connection drops immediately after the ServerHello with a TLS handshake_failure alert.
How to Identify It
Attempt a connection forcing a cipher that is not in HAProxy's allowed list:
openssl s_client -connect 192.168.10.50:443 \
-cipher "AES256-SHA" \
-servername solvethenetwork.com
CONNECTED(00000003)
140362483714368:error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure
Check what ciphers are configured in haproxy.cfg:
grep -E "cipher|ciphersuites" /etc/haproxy/haproxy.cfg
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256
List the ciphers that are currently allowed to understand the scope of the restriction:
openssl ciphers -v 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256' | awk '{print $1}'
ECDHE-ECDSA-AES128-GCM-SHA256
ECDHE-RSA-AES128-GCM-SHA256
Only two ciphers are enabled, which will reject the majority of legacy clients.
How to Fix It
Adopt the Mozilla SSL Configuration Generator Intermediate profile, which balances strong security with broad client compatibility:
global
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
After reloading, confirm the negotiated cipher for a normal client:
openssl s_client -connect 192.168.10.50:443 \
-servername solvethenetwork.com 2>/dev/null | grep "Cipher is"
Cipher is ECDHE-RSA-AES256-GCM-SHA384
Root Cause 4: TLS Version Restricted
Why It Happens
Security hardening guides routinely recommend disabling TLS 1.0 and 1.1 in HAProxy using no-tlsv10 and no-tlsv11 in
ssl-default-bind-options. This is correct practice. However, operators sometimes go further and set ssl-min-ver TLSv1.3, which excludes all TLS 1.2 clients — including modern browsers that fall back to TLS 1.2 and many HTTP client libraries. The result is a connection drop for any client that does not support TLS 1.3, which in 2026 still includes a non-trivial portion of enterprise software, embedded systems, and IoT endpoints.
How to Identify It
Test connectivity using specific TLS versions to identify which are accepted:
openssl s_client -connect 192.168.10.50:443 -tls1_2 \
-servername solvethenetwork.com < /dev/null 2>&1 | grep -E "Protocol|alert|error"
139921406453568:error:1409442E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version
openssl s_client -connect 192.168.10.50:443 -tls1_3 \
-servername solvethenetwork.com < /dev/null 2>&1 | grep "Protocol"
Protocol : TLSv1.3
TLS 1.3 succeeds while TLS 1.2 is rejected. Confirm the configuration is the cause:
grep -E "ssl-min-ver|no-tlsv|no-sslv" /etc/haproxy/haproxy.cfg
ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets ssl-min-ver TLSv1.3
Check HAProxy logs for the volume of affected clients:
journalctl -u haproxy --since "30 minutes ago" | grep -i "handshake" | wc -l
847
That volume of handshake failures in 30 minutes confirms a systemic version mismatch, not an isolated client issue.
How to Fix It
Set the minimum TLS version to TLSv1.2 in the global block, which is the current industry-accepted baseline for security and compatibility:
global
ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
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
If you need TLS 1.3-only enforcement on a specific frontend while keeping TLS 1.2 available elsewhere, override at the bind level:
frontend https_internal
bind 192.168.10.51:443 ssl crt /etc/haproxy/certs/solvethenetwork.pem ssl-min-ver TLSv1.3
frontend https_public
bind 192.168.10.50:443 ssl crt /etc/haproxy/certs/solvethenetwork.pem ssl-min-ver TLSv1.2
Verify TLS 1.2 connections now succeed:
openssl s_client -connect 192.168.10.50:443 -tls1_2 \
-servername solvethenetwork.com < /dev/null 2>&1 | grep "Protocol"
Protocol : TLSv1.2
Root Cause 5: SNI Routing Wrong
Why It Happens
Server Name Indication allows a single HAProxy frontend to serve multiple SSL certificates on one IP address. HAProxy selects the certificate to present based on the SNI hostname value in the TLS ClientHello. When ACLs using req.ssl_sni contain typos in hostname strings, use incorrect matching flags, or are evaluated in the wrong order, the ACL never matches and HAProxy falls through to
default_backend, serving the default certificate. Clients connecting to a virtual host that needs its own certificate instead receive the default one, triggering a CN mismatch error. Additionally, when HAProxy is configured in TCP mode for pass-through routing,
req.ssl_sniis available during the TCP phase but some operators attempt to use HTTP-layer ACLs against it, which never fire.
How to Identify It
Test which certificate is served for each expected SNI value:
openssl s_client -connect 192.168.10.50:443 \
-servername api.solvethenetwork.com < /dev/null 2>/dev/null \
| openssl x509 -noout -subject
subject=CN = solvethenetwork.com
The apex domain certificate is being served for api.solvethenetwork.com — a clear mismatch. Inspect the ACL configuration:
grep -A 10 "frontend https_front" /etc/haproxy/haproxy.cfg
frontend https_front
bind 192.168.10.50:443 ssl crt /etc/haproxy/certs/
acl is_api req.ssl_sni -i api.solvethenework.com
use_backend api_servers if is_api
default_backend web_servers
The ACL contains a typo: api.solvethenework.com is missing the letter t. Because this ACL never matches, every request falls through to
default_backendand receives the default certificate. Also check that the PEM files exist for each expected domain when using directory-based loading:
ls -1 /etc/haproxy/certs/
solvethenetwork.com.pem
portal.solvethenetwork.com.pem
The file api.solvethenetwork.com.pem is missing entirely, so even fixing the typo would still result in the default certificate being served.
How to Fix It
Correct all ACL hostname values and verify the PEM files exist for every expected domain:
frontend https_front
bind 192.168.10.50:443 ssl crt /etc/haproxy/certs/
acl is_api req.ssl_sni -i api.solvethenetwork.com
acl is_portal req.ssl_sni -i portal.solvethenetwork.com
use_backend api_servers if is_api
use_backend portal_servers if is_portal
default_backend web_servers
Rebuild and place the missing PEM file:
cat /etc/letsencrypt/live/api.solvethenetwork.com/fullchain.pem \
/etc/letsencrypt/live/api.solvethenetwork.com/privkey.pem \
> /etc/haproxy/certs/api.solvethenetwork.com.pem
chmod 640 /etc/haproxy/certs/api.solvethenetwork.com.pem
chown infrarunbook-admin:infrarunbook-admin /etc/haproxy/certs/api.solvethenetwork.com.pem
After reloading, run a systematic verification across all expected SNI values:
for domain in solvethenetwork.com api.solvethenetwork.com portal.solvethenetwork.com; do
echo -n "$domain -> "
openssl s_client -connect 192.168.10.50:443 -servername $domain < /dev/null 2>/dev/null \
| openssl x509 -noout -subject
done
solvethenetwork.com -> subject=CN = solvethenetwork.com
api.solvethenetwork.com -> subject=CN = api.solvethenetwork.com
portal.solvethenetwork.com -> subject=CN = portal.solvethenetwork.com
Root Cause 6: Certificate and Private Key Mismatch
Why It Happens
HAProxy PEM files must contain both the certificate and its corresponding private key. A mismatch occurs when a certificate is renewed and the new certificate is inadvertently paired with the old private key, or when the PEM file is assembled by concatenating files in the wrong order or from different renewal cycles. HAProxy detects this at startup and refuses to load, but operators sometimes work around this by restoring a backup certificate to an older key, reintroducing the mismatch.
How to Identify It
HAProxy logs a clear error on startup:
systemctl status haproxy
haproxy[3421]: [ALERT] SSL_CTX_use_PrivateKey_file('/etc/haproxy/certs/solvethenetwork.pem') failed.
haproxy[3421]: [ALERT] unable to load SSL private key from PEM file '/etc/haproxy/certs/solvethenetwork.pem'
Verify the mismatch by comparing the modulus of the certificate and the key. For a matched pair, both MD5 hashes must be identical:
openssl x509 -noout -modulus -in /etc/haproxy/certs/solvethenetwork.pem | openssl md5
(stdin)= a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4
openssl rsa -noout -modulus -in /etc/haproxy/certs/solvethenetwork.pem | openssl md5
(stdin)= 9z8y7x6w5v4u3t2s1r0q9p8o7n6m5l4k
The hashes differ — the certificate and key are from different pairs.
How to Fix It
Rebuild the PEM file using the correct matching certificate and private key from the same issuance:
cat /etc/letsencrypt/live/solvethenetwork.com/fullchain.pem \
/etc/letsencrypt/live/solvethenetwork.com/privkey.pem \
> /etc/haproxy/certs/solvethenetwork.pem
chmod 640 /etc/haproxy/certs/solvethenetwork.pem
chown infrarunbook-admin:infrarunbook-admin /etc/haproxy/certs/solvethenetwork.pem
Confirm the moduli now match before starting HAProxy:
diff <(openssl x509 -noout -modulus -in /etc/haproxy/certs/solvethenetwork.pem | openssl md5) \
<(openssl rsa -noout -modulus -in /etc/haproxy/certs/solvethenetwork.pem | openssl md5)
No output from diff means the pair is valid. Start HAProxy:
haproxy -c -f /etc/haproxy/haproxy.cfg && systemctl start haproxy
Root Cause 7: Expired Certificate
Why It Happens
HAProxy serves an expired certificate without issuing any startup warning or log alert. This is a silent failure mode that becomes visible only when clients reject the connection. It is especially common with Let's Encrypt certificates, which have a 90-day validity period and depend on fully automated renewal. When the renewal cron job fails silently — due to DNS propagation issues, port 80 being blocked, or disk space exhaustion preventing file writes — the certificate expires unnoticed until clients start failing.
How to Identify It
openssl s_client -connect 192.168.10.50:443 -servername solvethenetwork.com \
< /dev/null 2>/dev/null | openssl x509 -noout -dates
notBefore=Jan 1 00:00:00 2026 GMT
notAfter=Mar 31 00:00:00 2026 GMT
The notAfter date is in the past. Perform a programmatic expiry check on the on-disk PEM file:
openssl x509 -in /etc/haproxy/certs/solvethenetwork.pem -noout -checkend 0
Certificate will expire
How to Fix It
Force a certificate renewal and rebuild the HAProxy PEM file:
certbot renew --cert-name solvethenetwork.com --force-renewal
cat /etc/letsencrypt/live/solvethenetwork.com/fullchain.pem \
/etc/letsencrypt/live/solvethenetwork.com/privkey.pem \
> /etc/haproxy/certs/solvethenetwork.pem
systemctl reload haproxy
Verify the new expiry date:
openssl x509 -in /etc/haproxy/certs/solvethenetwork.pem -noout -dates
notBefore=Apr 6 00:00:00 2026 GMT
notAfter=Jul 5 00:00:00 2026 GMT
Root Cause 8: Missing or Weak DH Parameters
Why It Happens
DHE cipher suites require Diffie-Hellman parameters to be available. On some systems and older HAProxy builds, the default DH parameters are 1024 bits, which modern browsers and security scanners reject as insufficiently secure. Clients enforcing a minimum DH key size of 2048 bits will abort the handshake with a weak key error. The PEM file can include custom DH parameters or HAProxy can be directed to a separate DH params file to resolve this.
How to Identify It
Run an SSL Labs-style check locally using testssl.sh or verify via openssl:
openssl s_client -connect 192.168.10.50:443 \
-cipher DHE-RSA-AES256-GCM-SHA384 \
-servername solvethenetwork.com < /dev/null 2>&1 | grep -E "Server Temp Key|dh"
Server Temp Key: DH, 1024 bits
A 1024-bit DH parameter will cause failures with browsers enforcing the RFC 7919 minimum. Check whether custom DH parameters are configured:
grep -E "dhparam|tune.ssl" /etc/haproxy/haproxy.cfg
(no output)
How to Fix It
Generate a 2048-bit DH parameter file and append it to the PEM file or reference it globally:
openssl dhparam -out /etc/haproxy/dhparam.pem 2048
cat /etc/haproxy/dhparam.pem >> /etc/haproxy/certs/solvethenetwork.pem
Alternatively, reference the DH params at the global level so they apply to all frontends:
global
tune.ssl.default-dh-param 2048
After reloading, verify the DH key size has increased:
openssl s_client -connect 192.168.10.50:443 \
-cipher DHE-RSA-AES256-GCM-SHA384 \
-servername solvethenetwork.com < /dev/null 2>&1 | grep "Server Temp Key"
Server Temp Key: DH, 2048 bits
Prevention
The majority of HAProxy SSL termination incidents are avoidable with consistent operational practices and automated verification.
- Automate certificate renewal end-to-end. Use Certbot deploy hooks to automatically rebuild the HAProxy PEM file and trigger a graceful reload immediately after every successful renewal. Place the hook script in
/etc/letsencrypt/renewal-hooks/deploy/rebuild-haproxy-pem.sh
and make it executable. Include a modulus comparison check inside the hook so mismatches are caught before the reload. - Monitor expiry proactively at multiple thresholds. Run a daily cron job on sw-infrarunbook-01 that uses
openssl x509 -checkend
against every PEM file in/etc/haproxy/certs/
and sends alerts at 30, 14, and 7 days before expiry. Also monitor the live certificate served by HAProxy, not just the on-disk file, to catch cases where the file was renewed but HAProxy was not reloaded. - Validate the configuration before every reload. Gate all HAProxy reload operations behind
haproxy -c -f /etc/haproxy/haproxy.cfg
. If the check returns a non-zero exit code, abort the reload and page the on-call operator. This catches key mismatches, missing PEM files, and syntax errors before they affect traffic. - Use a post-deploy SNI validation script. After any certificate or configuration change, run an automated script that issues
openssl s_client
calls for every expected SNI hostname and asserts that the returned certificate CN or SAN matches the domain. Run this script in your CI/CD pipeline as a deployment gate. - Standardize cipher and TLS version configuration. Define ciphers and TLS version options once in the global block so they apply consistently across all frontends. Use the Intermediate profile from the Mozilla SSL Configuration Generator as the baseline and review it annually.
- Use consistent PEM file naming. Name each PEM file after the domain it serves:
/etc/haproxy/certs/api.solvethenetwork.com.pem
. This makes the certificate-to-domain mapping obvious, prevents wrong-file mistakes, and enables HAProxy's automatic SNI-based certificate selection when loading from a directory. - Enable the HAProxy stats page and log SSL errors. Route HAProxy logs to syslog, enable the stats endpoint on a loopback address, and configure alerting on elevated SSL handshake failure rates.
listen stats
bind 127.0.0.1:8404
stats enable
stats uri /haproxy-stats
stats refresh 10s
stats auth infrarunbook-admin:Ch@ng3me!
Operational note: Always test a HAProxy reload against all SNI hostnames immediately after any certificate change. A successful reload does not guarantee that SNI routing is working — it only confirms the process accepted the configuration. The only reliable test is an actual TLS handshake from an external client or monitoring probe.
Related Articles
- [HAProxy] HAProxy SSL/TLS Termination: Let's Encrypt, Certificate Bundles, ALPN, HSTS, and TLS 1.3 Hardening
- [HAProxy] HAProxy ACLs and Routing: Path-Based Routing, Host-Based Routing, Header Inspection, and Advanced Traffic Steering
- [HAProxy] HAProxy Connection Limits Reached
- [HAProxy] HAProxy Rate Limiting and DDoS Protection Setup
