DevOpsil
Nginx
88%
Needs Review

HAProxy Advanced: SSL Termination, SNI Routing, and mTLS

Muhammad HassanMuhammad Hassan5 min read

SSL in HAProxy: More Than Just Termination

HAProxy handles SSL/TLS at multiple points:

  • SSL termination: Client speaks HTTPS to HAProxy, HAProxy speaks HTTP to backends
  • SSL passthrough: HAProxy forwards encrypted traffic without decrypting (Layer 4)
  • SSL re-encryption: HAProxy terminates SSL from client, re-encrypts to backend
  • mTLS: Both client and server present certificates for mutual authentication

The right choice depends on your security requirements and whether backends can accept plain HTTP.


Certificate Preparation

HAProxy expects certificates in PEM format with the certificate chain and private key in a single file:

# Combine cert + chain + key
cat /etc/letsencrypt/live/example.com/fullchain.pem \
    /etc/letsencrypt/live/example.com/privkey.pem \
    > /etc/haproxy/certs/example.com.pem

chmod 600 /etc/haproxy/certs/example.com.pem
chown haproxy:haproxy /etc/haproxy/certs/example.com.pem

# Multiple domains: place all .pem files in a directory
ls /etc/haproxy/certs/
# example.com.pem
# api.example.com.pem
# admin.example.com.pem

SSL Termination: Single Domain

frontend https_in
    bind *:443 ssl crt /etc/haproxy/certs/example.com.pem \
                       alpn h2,http/1.1 \
                       ssl-min-ver TLSv1.2

    # 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 "SAMEORIGIN"
    http-response del-header Server
    http-response del-header X-Powered-By

    default_backend web_servers

SNI-Based Multi-Domain Routing

Route multiple domains from a single port 443 frontend using SNI (Server Name Indication):

frontend https_in
    bind *:443 ssl crt /etc/haproxy/certs/

    # Use SNI to route to different backends
    use_backend api_servers      if { ssl_fc_sni -i api.example.com }
    use_backend admin_servers    if { ssl_fc_sni -i admin.example.com }
    use_backend static_servers   if { ssl_fc_sni -i static.example.com }
    default_backend web_servers

HAProxy automatically selects the right certificate from the certs/ directory based on the SNI hostname. Name the files to match the domain (e.g., api.example.com.pem).

Wildcard Certificates

# Wildcard cert covers *.example.com
cp /etc/letsencrypt/live/example.com/fullchain.pem \
   /etc/haproxy/certs/example.com.pem

cat /etc/letsencrypt/live/example.com/privkey.pem >> /etc/haproxy/certs/example.com.pem
frontend https_in
    bind *:443 ssl crt /etc/haproxy/certs/example.com.pem

    # Route subdomains to different backends
    acl is_api    ssl_fc_sni -i api.example.com
    acl is_app    ssl_fc_sni -m end .example.com

    use_backend api_servers if is_api
    default_backend web_servers

SSL Passthrough (Layer 4)

When backends handle their own TLS (e.g., HTTPS backends, databases with TLS):

frontend tcp_in
    bind *:443
    mode tcp
    option tcplog

    # Route by SNI without decrypting
    use_backend api_tls      if { req_ssl_sni -i api.example.com }
    default_backend web_tls

backend api_tls
    mode tcp
    server api01 10.0.1.10:443 check

backend web_tls
    mode tcp
    balance roundrobin
    server web01 10.0.1.20:443 check
    server web02 10.0.1.21:443 check

Passthrough: HAProxy never sees the plaintext — useful for end-to-end encryption requirements.


SSL Re-encryption (End-to-End TLS)

Terminate SSL at HAProxy and re-encrypt to backends:

frontend https_in
    bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
    default_backend secure_backends

backend secure_backends
    option httpchk
    http-check connect ssl
    http-check send meth GET uri /health

    server web01 10.0.1.10:443 ssl verify required ca-file /etc/haproxy/certs/ca.pem check
    server web02 10.0.1.11:443 ssl verify required ca-file /etc/haproxy/certs/ca.pem check

ssl verify required ensures HAProxy validates the backend certificate — prevents man-in-the-middle.

To skip backend certificate verification (development/internal):

server web01 10.0.1.10:443 ssl verify none check

Mutual TLS (mTLS)

mTLS requires clients to present a certificate, enabling service-to-service authentication:

Generate CA and Client Certificates

# Create CA
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/CN=Internal CA"

# Generate client certificate
openssl genrsa -out client.key 2048
openssl req -new -key client.key -out client.csr -subj "/CN=service-client"
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt

# Combine for HAProxy
cat ca.crt > /etc/haproxy/certs/ca-chain.pem

HAProxy mTLS Frontend

frontend mtls_api
    bind *:8443 ssl crt /etc/haproxy/certs/api.example.com.pem \
                        ca-file /etc/haproxy/certs/ca-chain.pem \
                        verify required

    # Reject connections without valid client cert
    http-request deny unless { ssl_c_verify 0 }

    # Pass client cert info to backend
    http-request set-header X-Client-CN   %{+Q}[ssl_c_s_dn(cn)]
    http-request set-header X-Client-Cert %{+Q}[ssl_c_der,base64]

    default_backend api_servers

Test with curl

# Client connects with certificate
curl --cert client.crt --key client.key --cacert ca.crt \
     https://api.example.com:8443/api/resource

# Without cert — should be rejected
curl --cacert ca.crt https://api.example.com:8443/api/resource
# curl: (56) OpenSSL SSL_read: error:... — connection refused

OCSP Stapling

Reduce TLS handshake latency by including the OCSP response in the handshake (avoids a separate client request to the CA):

# Fetch OCSP response
openssl ocsp \
    -issuer /etc/letsencrypt/live/example.com/chain.pem \
    -cert /etc/letsencrypt/live/example.com/cert.pem \
    -url "$(openssl x509 -noout -ocsp_uri -in /etc/letsencrypt/live/example.com/cert.pem)" \
    -respout /etc/haproxy/certs/example.com.ocsp \
    -noverify

# Refresh OCSP daily (Let's Encrypt responses are valid 7 days)
cat >> /etc/cron.daily/haproxy-ocsp << 'EOF'
#!/bin/bash
/usr/bin/openssl ocsp \
  -issuer /etc/letsencrypt/live/example.com/chain.pem \
  -cert /etc/letsencrypt/live/example.com/cert.pem \
  -url "$(openssl x509 -noout -ocsp_uri -in /etc/letsencrypt/live/example.com/cert.pem)" \
  -respout /etc/haproxy/certs/example.com.ocsp \
  -noverify && \
echo "set ssl ocsp-response $(base64 -w 0 /etc/haproxy/certs/example.com.ocsp)" | socat stdio /run/haproxy/admin.sock
EOF
chmod +x /etc/cron.daily/haproxy-ocsp

HAProxy automatically staples the OCSP response when the .ocsp file matches the certificate filename.


Runtime Certificate Updates (No Downtime)

HAProxy 2.2+ supports updating certificates without restarting:

# Update a certificate at runtime
echo "set ssl cert /etc/haproxy/certs/example.com.pem" | \
  socat stdio /run/haproxy/admin.sock

# Then push the new cert data
cat /path/to/new-example.com.pem | \
  socat stdio /run/haproxy/admin.sock

# Commit the change
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

This is essential for cert rotation with Let's Encrypt — no HAProxy restart, no dropped connections.

Share:

Was this article helpful?

Muhammad Hassan
Muhammad Hassan

Network & Traffic Engineer

Packets don't lie. I design and troubleshoot the network layer that everything else depends on — Nginx, Envoy, HAProxy, DNS, CDNs, and everything in between. If it touches a socket, it's my problem.

Related Articles

More in Nginx

View all →

Discussion