HAProxy TLS Configuration: SSL Termination, Passthrough, and Certificate Management

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

ModeWhat HAProxy doesUse case
TerminationDecrypts TLS, proxies plain HTTP to backendMost web apps, APIs
PassthroughForwards TCP without decryptingWhen backends must see the original TLS
BridgeDecrypts, re-encrypts to backendEnd-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

ProblemCauseFix
SSL handshake failureCipher mismatch or old TLS versionCheck ssl-default-bind-options and client TLS version
no certificate or key foundPEM file missing key or cert, or wrong orderEnsure key is first in the PEM file
unable to load SSL private keyKey does not match certificateRegenerate the combined PEM from matching files
backend server certificate verification failedWrong CA file for backendSet ca-file to the CA that signed backend certs
Old certificate served after renewalHAProxy not reloaded after cert copyRun 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.

Scroll to Top