Introduction
SSL/TLS termination at the load balancer is the most common and efficient way to handle encrypted traffic in production. By offloading TLS processing to HAProxy, you free your backend servers from expensive cryptographic operations, centralise certificate management, and gain full Layer 7 visibility for routing, logging, and security inspection.
This guide covers every aspect of production-grade SSL/TLS termination in HAProxy 2.8+ — from initial certificate setup with Let's Encrypt, through certificate bundle management, ALPN and HTTP/2 negotiation, HSTS enforcement, TLS 1.3 hardening, OCSP stapling, and performance tuning. Every command and configuration block is real and tested.
Prerequisites
- HAProxy 2.8 or later compiled with OpenSSL 1.1.1+ (for TLS 1.3 support)
- Root or sudo access on a Linux server (Debian 12 / Ubuntu 22.04 / RHEL 9)
- A registered domain with DNS pointing to your HAProxy server's public IP
- Certbot installed for Let's Encrypt automation
- Backend servers running on private IPs (HTTP only behind the load balancer)
Verify HAProxy and OpenSSL Versions
haproxy -vv | head -5
# HA-Proxy version 2.8.5-1 2024/02/15 - https://haproxy.org/
# Built with OpenSSL 3.0.11
openssl version
# OpenSSL 3.0.11 19 Sep 2023
Section 1: Obtaining Certificates with Let's Encrypt
1.1 Install Certbot
# Debian/Ubuntu
sudo apt update && sudo apt install -y certbot
# RHEL/Rocky
sudo dnf install -y epel-release && sudo dnf install -y certbot
1.2 Standalone Mode (Initial Certificate)
If HAProxy is not yet running or you can briefly stop it, use standalone mode:
# Stop HAProxy temporarily
sudo systemctl stop haproxy
# Obtain certificate
sudo certbot certonly --standalone \
-d example.com \
-d www.example.com \
--preferred-challenges http \
--agree-tos \
--email admin@example.com \
--non-interactive
# Certificate files are stored at:
# /etc/letsencrypt/live/example.com/fullchain.pem
# /etc/letsencrypt/live/example.com/privkey.pem
1.3 HTTP-01 Challenge Through HAProxy (Zero Downtime)
For production environments where you cannot stop HAProxy, configure an ACME challenge backend:
# Add to haproxy.cfg — frontend section
frontend ft_http
bind *:80
mode http
# Let's Encrypt ACME challenge
acl is_acme path_beg /.well-known/acme-challenge/
use_backend bk_acme if is_acme
# Redirect everything else to HTTPS
http-request redirect scheme https code 301 unless is_acme
backend bk_acme
mode http
server acme 127.0.0.1:8888
Then run certbot in webroot mode with a simple HTTP server:
# Create webroot directory
sudo mkdir -p /var/www/acme/.well-known/acme-challenge
# Start a lightweight Python HTTP server on port 8888
cd /var/www/acme
python3 -m http.server 8888 &
# Obtain certificate using webroot
sudo certbot certonly --webroot \
-w /var/www/acme \
-d example.com \
-d www.example.com \
--agree-tos \
--email admin@example.com \
--non-interactive
1.4 Build HAProxy Combined PEM Bundle
HAProxy requires the certificate chain and private key in a single PEM file:
# Create certificate directory
sudo mkdir -p /etc/haproxy/certs
# Combine fullchain + private key into one file
DOMAIN="example.com"
sudo bash -c "cat /etc/letsencrypt/live/${DOMAIN}/fullchain.pem \
/etc/letsencrypt/live/${DOMAIN}/privkey.pem \
> /etc/haproxy/certs/${DOMAIN}.pem"
# Set strict permissions
sudo chmod 600 /etc/haproxy/certs/${DOMAIN}.pem
sudo chown haproxy:haproxy /etc/haproxy/certs/${DOMAIN}.pem
1.5 Automated Renewal with Deploy Hook
# Create deploy hook script
sudo tee /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh > /dev/null << 'EOF'
#!/bin/bash
# Rebuild HAProxy PEM bundles after renewal
CERT_DIR="/etc/haproxy/certs"
for domain_dir in /etc/letsencrypt/live/*/; do
domain=$(basename "$domain_dir")
cat "${domain_dir}fullchain.pem" "${domain_dir}privkey.pem" \
> "${CERT_DIR}/${domain}.pem"
chmod 600 "${CERT_DIR}/${domain}.pem"
chown haproxy:haproxy "${CERT_DIR}/${domain}.pem"
done
# Reload HAProxy without dropping connections
systemctl reload haproxy
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
# Test renewal dry-run
sudo certbot renew --dry-run
Certbot's systemd timer handles renewal automatically. Verify it's active:
systemctl list-timers | grep certbot
# certbot.timer loaded active waiting Run certbot twice daily
Section 2: Full HAProxy SSL/TLS Configuration
2.1 Global SSL Defaults
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/TLS Global Tuning ----
# Modern TLS 1.2 + 1.3 cipher configuration
ssl-default-bind-ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
ssl-default-bind-ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11
ssl-default-server-ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11
# DH parameter file (generate with: openssl dhparam -out /etc/haproxy/dhparam.pem 2048)
ssl-dh-param-file /etc/haproxy/dhparam.pem
# SSL session cache — reduces TLS handshake overhead
tune.ssl.default-dh-param 2048
tune.ssl.cachesize 100000
tune.ssl.lifetime 600
tune.ssl.maxrecord 16384
2.2 Generate DH Parameters
sudo openssl dhparam -out /etc/haproxy/dhparam.pem 2048
sudo chmod 600 /etc/haproxy/dhparam.pem
sudo chown haproxy:haproxy /etc/haproxy/dhparam.pem
2.3 Frontend: HTTPS with TLS 1.3, ALPN, and HSTS
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
option http-server-close
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
timeout http-keep-alive 5s
errorfile 400 /etc/haproxy/errors/400.http
errorfile 403 /etc/haproxy/errors/403.http
errorfile 503 /etc/haproxy/errors/503.http
frontend ft_https
# --- Bind with SSL, certificate directory, ALPN for HTTP/2 ---
bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
# --- HTTP to HTTPS redirect frontend ---
bind *:80
acl is_acme path_beg /.well-known/acme-challenge/
http-request redirect scheme https code 301 unless { ssl_fc } || is_acme
use_backend bk_acme if is_acme !{ ssl_fc }
# --- HSTS Header (2 years, includeSubDomains, preload) ---
http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
# --- Security Headers ---
http-response set-header X-Content-Type-Options nosniff
http-response set-header X-Frame-Options DENY
http-response set-header X-XSS-Protection "1; mode=block"
http-response set-header Referrer-Policy strict-origin-when-cross-origin
# --- Log the TLS version and cipher used ---
http-request set-header X-SSL-Protocol %[ssl_fc_protocol]
http-request set-header X-SSL-Cipher %[ssl_fc_cipher]
# --- Forwarded headers for backends ---
http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Port %[dst_port]
http-request set-header X-Real-IP %[src]
# --- Host-based routing ---
acl host_app1 hdr(host) -i app1.example.com
acl host_app2 hdr(host) -i app2.example.com
use_backend bk_app1 if host_app1
use_backend bk_app2 if host_app2
default_backend bk_app1
2.4 Backend Definitions
backend bk_app1
mode http
balance roundrobin
option httpchk GET /health HTTP/1.1\r\nHost:\ app1.example.com
http-check expect status 200
server app1-web1 10.0.1.10:8080 check inter 3s fall 3 rise 2
server app1-web2 10.0.1.11:8080 check inter 3s fall 3 rise 2
backend bk_app2
mode http
balance leastconn
option httpchk GET /ping HTTP/1.1\r\nHost:\ app2.example.com
http-check expect status 200
server app2-web1 10.0.2.10:8080 check inter 3s fall 3 rise 2
server app2-web2 10.0.2.11:8080 check inter 3s fall 3 rise 2
backend bk_acme
mode http
server acme 127.0.0.1:8888
Section 3: Multi-Domain Certificate Management
3.1 Directory-Based SNI (Server Name Indication)
When you point
crtat a directory, HAProxy loads every
.pemfile in that directory and automatically selects the correct certificate based on the SNI sent by the client:
# Directory structure
/etc/haproxy/certs/
├── example.com.pem # fullchain + privkey for example.com
├── app2.example.com.pem # fullchain + privkey for app2.example.com
├── staging.example.com.pem # fullchain + privkey for staging
└── wildcard.example.com.pem # wildcard cert *.example.com
3.2 Wildcard Certificates with DNS-01 Challenge
# Install Cloudflare DNS plugin (example)
sudo apt install -y python3-certbot-dns-cloudflare
# Create credentials file
sudo mkdir -p /etc/letsencrypt/credentials
sudo tee /etc/letsencrypt/credentials/cloudflare.ini > /dev/null << 'EOF'
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN_HERE
EOF
sudo chmod 600 /etc/letsencrypt/credentials/cloudflare.ini
# Obtain wildcard certificate
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/credentials/cloudflare.ini \
-d "*.example.com" \
-d example.com \
--agree-tos \
--email admin@example.com \
--non-interactive
# Build the PEM bundle
sudo bash -c "cat /etc/letsencrypt/live/example.com/fullchain.pem \
/etc/letsencrypt/live/example.com/privkey.pem \
> /etc/haproxy/certs/wildcard.example.com.pem"
sudo chmod 600 /etc/haproxy/certs/wildcard.example.com.pem
3.3 Certificate Priority and Explicit SNI Mapping
# Explicit per-certificate binding (overrides directory scan)
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem crt /etc/haproxy/certs/wildcard.example.com.pem alpn h2,http/1.1
# Or use a crt-list for fine-grained control
bind *:443 ssl crt-list /etc/haproxy/certs/crt-list.txt alpn h2,http/1.1
The
crt-listfile format:
# /etc/haproxy/certs/crt-list.txt
# PEM-file [SNI-filter] [options]
/etc/haproxy/certs/example.com.pem example.com www.example.com
/etc/haproxy/certs/app2.example.com.pem app2.example.com
/etc/haproxy/certs/wildcard.example.com.pem *.example.com
Section 4: ALPN and HTTP/2 Configuration
4.1 Understanding ALPN Negotiation
Application-Layer Protocol Negotiation (ALPN) allows the client and server to agree on the application protocol during the TLS handshake. This is required for HTTP/2 over TLS.
4.2 Frontend ALPN Configuration
# Enable HTTP/2 and HTTP/1.1 negotiation
bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
# To log the negotiated protocol:
http-request set-header X-Forwarded-Proto-Version %[ssl_fc_alpn]
4.3 Backend HTTP/2 (Optional — h2 to backends)
# If your backends support HTTP/2 cleartext (h2c)
backend bk_app1_h2
mode http
balance roundrobin
# Use HTTP/2 to backend over cleartext
server app1-web1 10.0.1.10:8080 check proto h2
server app1-web2 10.0.1.11:8080 check proto h2
4.4 Verify HTTP/2 Negotiation
# Test with curl
curl -vso /dev/null --http2 https://example.com 2>&1 | grep -i alpn
# * ALPN: server accepted h2
# Test with openssl
openssl s_client -connect example.com:443 -alpn h2 < /dev/null 2>&1 | grep "ALPN"
# ALPN protocol: h2
Section 5: HSTS — HTTP Strict Transport Security
5.1 Basic HSTS Header
# In the frontend section — applied to all HTTPS responses
http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
5.2 HSTS Parameter Breakdown
- max-age=63072000 — Browser remembers HTTPS-only for 2 years (in seconds)
- includeSubDomains — Applies to all subdomains (critical for preload eligibility)
- preload — Signals eligibility for the HSTS preload list (submit at
hstspreload.org
)
5.3 Gradual HSTS Rollout (Recommended)
Don't jump straight to 2 years. Roll out gradually:
# Week 1: 5 minutes
http-response set-header Strict-Transport-Security "max-age=300"
# Week 2: 1 week
http-response set-header Strict-Transport-Security "max-age=604800"
# Week 3: 1 month
http-response set-header Strict-Transport-Security "max-age=2592000; includeSubDomains"
# Final: 2 years with preload
http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Section 6: TLS 1.3 Hardening
6.1 Enforce TLS 1.2 Minimum
# In global section — disable SSLv3, TLS 1.0, TLS 1.1
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11
6.2 TLS 1.3 Only (Strict Mode)
For environments where all clients support TLS 1.3:
# Global — TLS 1.3 only
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tlsv12
ssl-default-bind-ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256
Warning: TLS 1.3-only mode will break connections from older clients (Windows 7, IE 11, Android 4.x, Java 8 without patches). Use this only when your audience exclusively uses modern browsers.
6.3 Hybrid Mode (TLS 1.2 + 1.3 — Recommended)
# Global — allow both TLS 1.2 and 1.3
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11
# TLS 1.2 ciphers (AEAD only)
ssl-default-bind-ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
# TLS 1.3 cipher suites
ssl-default-bind-ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256
6.4 Verify TLS Versions and Ciphers
# Test TLS 1.3
openssl s_client -connect example.com:443 -tls1_3 < /dev/null 2>&1 | grep -E "Protocol|Cipher"
# Protocol : TLSv1.3
# Cipher : TLS_AES_256_GCM_SHA384
# Confirm TLS 1.1 is rejected
openssl s_client -connect example.com:443 -tls1_1 < /dev/null 2>&1 | grep -i alert
# alert protocol version
# Full scan with nmap
nmap --script ssl-enum-ciphers -p 443 example.com
Section 7: OCSP Stapling
7.1 Enable OCSP Stapling in HAProxy
OCSP stapling improves TLS handshake speed by bundling the certificate's revocation status:
# Generate the OCSP response file
DOMAIN="example.com"
ISSUER_CERT="/etc/letsencrypt/live/${DOMAIN}/chain.pem"
SERVER_CERT="/etc/letsencrypt/live/${DOMAIN}/cert.pem"
# Get the OCSP responder URL
OCSP_URL=$(openssl x509 -in ${SERVER_CERT} -noout -ocsp_uri)
# Fetch the OCSP response
openssl ocsp \
-issuer ${ISSUER_CERT} \
-cert ${SERVER_CERT} \
-url ${OCSP_URL} \
-respout /etc/haproxy/certs/${DOMAIN}.pem.ocsp \
-noverify
chmod 600 /etc/haproxy/certs/${DOMAIN}.pem.ocsp
HAProxy automatically looks for a
.ocspfile next to the PEM file. If
/etc/haproxy/certs/example.com.pemexists, HAProxy checks for
/etc/haproxy/certs/example.com.pem.ocspon startup.
7.2 Automate OCSP Response Updates
# /etc/cron.d/haproxy-ocsp
0 */6 * * * root /usr/local/bin/update-ocsp.sh && systemctl reload haproxy
#!/bin/bash
# /usr/local/bin/update-ocsp.sh
set -euo pipefail
CERT_DIR="/etc/haproxy/certs"
for pem in ${CERT_DIR}/*.pem; do
[ -f "$pem" ] || continue
domain=$(basename "$pem" .pem)
le_dir="/etc/letsencrypt/live/${domain}"
[ -d "$le_dir" ] || continue
ocsp_url=$(openssl x509 -in "${le_dir}/cert.pem" -noout -ocsp_uri 2>/dev/null || true)
[ -n "$ocsp_url" ] || continue
openssl ocsp \
-issuer "${le_dir}/chain.pem" \
-cert "${le_dir}/cert.pem" \
-url "$ocsp_url" \
-respout "${pem}.ocsp" \
-noverify 2>/dev/null || true
done
7.3 Verify OCSP Stapling
openssl s_client -connect example.com:443 -status < /dev/null 2>&1 | grep -A 5 "OCSP Response"
# OCSP Response Status: successful (0x0)
# Response Type: Basic OCSP Response
Section 8: SSL/TLS Performance Tuning
8.1 Session Resumption and Caching
# In global section
tune.ssl.cachesize 100000 # Number of SSL sessions cached
tune.ssl.lifetime 600 # Session cache lifetime in seconds (10 min)
tune.ssl.capture-buffer-size 96 # Capture buffer for logging cipher info
8.2 Multi-Process SSL (nbthread)
global
# Use all available CPU cores for SSL processing
nbthread 4
cpu-map auto:1/1-4 0-3
8.3 SSL Buffer Tuning
global
tune.ssl.maxrecord 16384 # Maximum SSL record size
tune.bufsize 32768 # Buffer size for request/response (default 16384)
tune.maxrewrite 8192 # Maximum header rewrite space
8.4 Connection Reuse
defaults
option http-server-close # Close server-side after response
option forwardfor # Add X-Forwarded-For
http-reuse safe # Reuse idle backend connections safely
Section 9: SSL/TLS Logging and Monitoring
9.1 Custom Log Format with SSL Details
frontend ft_https
log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %sslv/%sslc %{+Q}r"
# Field reference:
# %sslv = SSL version (TLSv1.3)
# %sslc = SSL cipher (TLS_AES_256_GCM_SHA384)
# %CC = captured request cookie
# %CS = captured response cookie
9.2 Runtime SSL Information via Stats Socket
# Show all loaded certificates
echo "show ssl cert" | socat stdio /run/haproxy/admin.sock
# Show details of a specific certificate
echo "show ssl cert /etc/haproxy/certs/example.com.pem" | socat stdio /run/haproxy/admin.sock
# Show SSL sessions
echo "show ssl sess" | socat stdio /run/haproxy/admin.sock
# Show TLS ticket keys
echo "show tls-keys" | socat stdio /run/haproxy/admin.sock
9.3 Hot-Update Certificates Without Reload
# Update certificate at runtime (HAProxy 2.4+)
echo -e "set ssl cert /etc/haproxy/certs/example.com.pem <<\n$(cat /etc/haproxy/certs/example.com.pem)\n" | socat stdio /run/haproxy/admin.sock
# Commit the update
echo "commit ssl cert /etc/haproxy/certs/example.com.pem" | socat stdio /run/haproxy/admin.sock
# Verify
echo "show ssl cert /etc/haproxy/certs/example.com.pem" | socat stdio /run/haproxy/admin.sock
Section 10: Complete Production Configuration
Here is a complete, production-ready
haproxy.cfgcombining all sections:
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
nbthread 4
# SSL/TLS Configuration
ssl-default-bind-ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256
ssl-default-bind-ciphersuites TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11
ssl-dh-param-file /etc/haproxy/dhparam.pem
tune.ssl.default-dh-param 2048
tune.ssl.cachesize 100000
tune.ssl.lifetime 600
tune.ssl.maxrecord 16384
tune.ssl.capture-buffer-size 96
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
option http-server-close
http-reuse safe
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
timeout http-keep-alive 5s
timeout queue 30s
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
errorfile 502 /etc/haproxy/errors/502.http
errorfile 503 /etc/haproxy/errors/503.http
errorfile 504 /etc/haproxy/errors/504.http
frontend ft_https
bind *:80
bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
# ACME challenge passthrough
acl is_acme path_beg /.well-known/acme-challenge/
use_backend bk_acme if is_acme !{ ssl_fc }
# HTTP to HTTPS redirect
http-request redirect scheme https code 301 unless { ssl_fc } || is_acme
# Security headers
http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
http-response set-header X-Content-Type-Options nosniff
http-response set-header X-Frame-Options DENY
http-response set-header X-XSS-Protection "1; mode=block"
http-response set-header Referrer-Policy strict-origin-when-cross-origin
http-response del-header Server
# Forwarding headers
http-request set-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Port %[dst_port]
http-request set-header X-Real-IP %[src]
# Custom SSL logging
log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %sslv/%sslc %{+Q}r"
# Routing
acl host_api hdr(host) -i api.example.com
acl host_www hdr(host) -i www.example.com example.com
use_backend bk_api if host_api
use_backend bk_www if host_www
default_backend bk_www
backend bk_www
mode http
balance roundrobin
option httpchk GET /health HTTP/1.1\r\nHost:\ www.example.com
http-check expect status 200
server www1 10.0.1.10:8080 check inter 3s fall 3 rise 2
server www2 10.0.1.11:8080 check inter 3s fall 3 rise 2
backend bk_api
mode http
balance leastconn
option httpchk GET /v1/health HTTP/1.1\r\nHost:\ api.example.com
http-check expect status 200
server api1 10.0.2.10:8080 check inter 3s fall 3 rise 2
server api2 10.0.2.11:8080 check inter 3s fall 3 rise 2
backend bk_acme
mode http
server acme 127.0.0.1:8888
listen stats
bind 127.0.0.1:9000
mode http
stats enable
stats uri /haproxy-stats
stats auth admin:SecureP@ssw0rd!
stats refresh 10s
Section 11: Testing and Validation
11.1 Validate Configuration Syntax
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
# Configuration file is valid
11.2 SSL Labs Test
# Submit your domain to SSL Labs
# https://www.ssllabs.com/ssltest/analyze.html?d=example.com
# Target: A+ rating
# Or use the CLI tool
sudo apt install -y testssl.sh
testssl --fast example.com
11.3 Verify Full Chain
# Check certificate chain completeness
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 | openssl x509 -noout -dates -subject -issuer
# subject= /CN=example.com
# issuer= /C=US/O=Let's Encrypt/CN=R3
# notBefore=Feb 16 00:00:00 2026 GMT
# notAfter=May 17 00:00:00 2026 GMT
# Verify no intermediate certificate issues
curl -svI https://example.com 2>&1 | grep -E "SSL|subject|issuer|expire"
11.4 Benchmark TLS Performance
# Benchmark TLS handshakes per second
openssl s_time -connect example.com:443 -new -time 10
# 850 connections in 10.00s; 85.00 connections/user sec
# HTTP/2 multiplexing test with h2load
h2load -n 10000 -c 100 -m 10 https://example.com/
# finished in 2.50s, 4000.00 req/s
Section 12: Troubleshooting Common Issues
12.1 "unable to load SSL certificate" Error
# Problem: PEM file format or permissions
# Solution 1: Check PEM order (cert → chain → key)
openssl x509 -in /etc/haproxy/certs/example.com.pem -noout -text | head -5
# Solution 2: Fix permissions
ls -la /etc/haproxy/certs/
sudo chown haproxy:haproxy /etc/haproxy/certs/*.pem
sudo chmod 600 /etc/haproxy/certs/*.pem
12.2 Mixed Content After HTTPS Redirect
# Ensure backends receive the correct forwarded protocol
http-request set-header X-Forwarded-Proto https if { ssl_fc }
# Application must use this header to generate HTTPS URLs
12.3 TLS Handshake Timeout
# Increase client timeout for slow TLS handshakes
defaults
timeout client 30s
# Or set a specific SSL handshake timeout on the bind line
bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 ssl-min-ver TLSv1.2
12.4 Certificate Not Matching SNI
# Debug SNI matching
# Enable debug logging temporarily
global
log /dev/log local0 debug
# Check loaded certificates via socket
echo "show ssl cert" | socat stdio /run/haproxy/admin.sock
# Verify the correct cert is served
openssl s_client -connect example.com:443 -servername app2.example.com < /dev/null 2>&1 | grep "subject="
Frequently Asked Questions
Q1: What is the difference between ssl-default-bind-ciphers and ssl-default-bind-ciphersuites?
ssl-default-bind-cipherscontrols cipher suites for TLS 1.2 and below using OpenSSL cipher string format.
ssl-default-bind-ciphersuitescontrols TLS 1.3 cipher suites specifically, which use a different naming convention (e.g.,
TLS_AES_256_GCM_SHA384). You need both directives to properly control ciphers across all supported TLS versions.
Q2: Can HAProxy perform SSL passthrough without terminating TLS?
Yes. Use
mode tcpon the frontend and pass the encrypted traffic directly to backends. You lose Layer 7 features (header inspection, routing by Host, HSTS injection) but the backend handles TLS. Configure it with
bind *:443(no
sslkeyword) and
tcp-request inspect-delay 5swith
req.ssl_snifor SNI-based routing.
Q3: How do I get an A+ rating on SSL Labs with HAProxy?
You need: TLS 1.2+ only (disable SSLv3, TLS 1.0, TLS 1.1), strong AEAD ciphers only, HSTS header with
max-ageof at least 6 months (15768000 seconds), proper certificate chain, OCSP stapling, and a 2048-bit or larger DH parameter file. The configuration in this guide achieves an A+ rating.
Q4: How does HAProxy select which certificate to serve when using a directory?
HAProxy reads all
.pemfiles from the specified directory at startup. When a TLS connection arrives, HAProxy matches the SNI (Server Name Indication) extension from the ClientHello against the CN and SAN fields of all loaded certificates. The most specific match wins. If no SNI matches, HAProxy serves the first certificate loaded (alphabetical order).
Q5: Can I update certificates without restarting HAProxy?
Yes, HAProxy 2.4+ supports hot certificate updates via the stats socket. Use
set ssl certfollowed by
commit ssl certthrough the admin socket. This allows zero-downtime certificate rotation without dropping any active connections. See Section 9.3 for exact commands.
Q6: Should I use HTTP/2 between HAProxy and my backend servers?
Generally, no. HTTP/1.1 with connection reuse (
http-reuse safe) is sufficient for most backends. HTTP/2 to backends adds complexity with minimal benefit because the LAN latency is negligible. Use
proto h2on server lines only if your backend application is specifically optimised for HTTP/2 multiplexing, such as gRPC services.
Q7: How do I handle Let's Encrypt certificate renewal without downtime?
Configure the ACME HTTP-01 challenge backend as shown in Section 1.3 so HAProxy forwards
/.well-known/acme-challenge/requests to certbot's webroot. Use a deploy hook script (Section 1.5) that rebuilds PEM bundles and runs
systemctl reload haproxy. HAProxy's reload is graceful — it spawns new workers while old workers drain existing connections.
Q8: What is the purpose of ssl-dh-param-file and is it still needed with TLS 1.3?
The DH parameter file provides custom Diffie-Hellman parameters for DHE key exchange in TLS 1.2. TLS 1.3 only uses ECDHE (elliptic curve) key exchange and does not use DH parameters at all. However, if you support TLS 1.2 connections, you should still generate and configure a 2048-bit DH parameter file to prevent weak DH attacks (Logjam).
Q9: How do I enforce TLS 1.3 only for a specific frontend while allowing TLS 1.2 elsewhere?
Override the global defaults on the specific
bindline:
bind *:443 ssl crt /etc/haproxy/certs/ ssl-min-ver TLSv1.3 alpn h2,http/1.1. The
ssl-min-ver TLSv1.3directive on the bind line overrides the global
ssl-default-bind-optionsfor that specific listener only.
Q10: How can I log which TLS version each client is using?
Use the
%sslvlog variable in your
log-formatdirective to capture the TLS version (e.g., TLSv1.2, TLSv1.3). Use
%sslcto capture the negotiated cipher. You can also set request headers for downstream logging:
http-request set-header X-SSL-Protocol %[ssl_fc_protocol]. This data helps you track when it's safe to deprecate TLS 1.2.
Q11: What happens if the OCSP response file is missing or expired?
HAProxy will still serve the certificate without OCSP stapling. The client's browser will then need to contact the CA's OCSP responder directly, which adds latency and can fail if the responder is slow or unreachable. It is not a fatal error — TLS handshakes succeed, but without the stapled response. Monitor your OCSP update cron job to keep responses fresh (they typically expire after 7 days for Let's Encrypt).
Q12: How do I redirect bare HTTP to HTTPS while keeping the original path and query string?
Use
http-request redirect scheme https code 301without specifying a location. HAProxy automatically preserves the original URI path, query string, and host header. The client receives a 301 redirect from
http://example.com/path?q=1to
https://example.com/path?q=1. This is the recommended approach over location-based redirects.
