DevOpsil
HAProxy
93%
Needs Review

HAProxy SSL Termination: Offload TLS Without the Headache

Dev PatelDev Patel6 min read

SSL termination at the load balancer is the standard pattern for any non-trivial deployment. HAProxy handles the TLS handshake centrally — your backends speak plain HTTP internally, certificate renewal happens in one place, and you get visibility into the decrypted traffic for logging and routing decisions.

How SSL Termination Works

In a termination setup:

  1. Client connects to HAProxy over HTTPS (TLS)
  2. HAProxy decrypts the traffic
  3. HAProxy forwards plain HTTP to backends
  4. Response travels back the same path, re-encrypted for the client

The alternative — SSL passthrough — forwards encrypted traffic directly to backends without decryption. Useful when backends must see raw TLS, but you lose the ability to inspect headers or make routing decisions based on content.

Prerequisites

  • HAProxy 2.4+ (2.6+ recommended for better TLS 1.3 support)
  • OpenSSL 1.1.1+ on the server
  • A certificate bundle (fullchain + private key combined into one file)

Certificate Preparation

HAProxy requires the certificate and private key in a single .pem file:

# Combine fullchain + key for HAProxy
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

For Let's Encrypt renewals, add a deploy hook:

# /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh
#!/bin/bash
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

# Reload HAProxy gracefully (no connection drops)
systemctl reload haproxy
chmod +x /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh

Basic SSL Termination Configuration

global
    log /dev/log local0
    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 tuning
    tune.ssl.default-dh-param 2048
    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

defaults
    log global
    mode http
    option httplog
    option dontlognull
    timeout connect 5s
    timeout client  30s
    timeout server  30s
    timeout http-request 10s

frontend https_in
    bind *:443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1
    bind *:80

    # Redirect HTTP to HTTPS
    http-request redirect scheme https code 301 unless { ssl_fc }

    # Add forwarded headers
    http-request set-header X-Forwarded-Proto https if { ssl_fc }
    http-request set-header X-Forwarded-Proto http unless { ssl_fc }
    http-request set-header X-Real-IP %[src]

    # HSTS header
    http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"

    default_backend app_servers

backend app_servers
    balance roundrobin
    option httpchk GET /healthz

    server app1 10.0.1.10:8080 check
    server app2 10.0.1.11:8080 check
    server app3 10.0.1.12:8080 check

SNI-Based Routing (Multiple Domains)

HAProxy can route to different backends based on the SNI hostname in the TLS handshake — without terminating the connection first (for passthrough), or after termination (for content routing):

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

    # HAProxy loads all .pem files from the directory automatically
    # and uses SNI to select the correct certificate

    # Route based on hostname after SSL termination
    use_backend api_servers   if { hdr(host) -i api.example.com }
    use_backend admin_servers if { hdr(host) -i admin.example.com }
    default_backend app_servers

backend api_servers
    balance leastconn
    server api1 10.0.2.10:8080 check
    server api2 10.0.2.11:8080 check

backend admin_servers
    balance roundrobin
    server admin1 10.0.3.10:9000 check

backend app_servers
    balance roundrobin
    server app1 10.0.1.10:8080 check
    server app2 10.0.1.11:8080 check

The directory-based certificate loading (crt /etc/haproxy/certs/) is extremely convenient — add a new .pem file and reload HAProxy to serve a new domain without changing config.

SSL Passthrough (When Backends Handle TLS)

When backends must handle their own TLS (e.g., gRPC with mutual TLS, or certain compliance requirements):

frontend tls_passthrough
    bind *:443
    mode tcp
    option tcplog

    # Route based on SNI without decrypting
    use_backend grpc_backend   if { req.ssl_sni -i grpc.example.com }
    default_backend https_backend

backend grpc_backend
    mode tcp
    balance roundrobin
    server grpc1 10.0.4.10:443 check

backend https_backend
    mode tcp
    balance roundrobin
    server app1 10.0.1.10:443 check
    server app2 10.0.1.11:443 check

Note: in mode tcp, HAProxy can read the SNI from the TLS ClientHello without decrypting it. You lose access to HTTP headers for routing, but backends get unmodified TLS.

Re-Encryption (SSL to Backends)

Some compliance requirements demand encryption even on internal networks. HAProxy can terminate client TLS and establish a new TLS connection to backends:

backend secure_backend
    balance roundrobin
    option httpchk GET /healthz

    # ssl: connect to backends over HTTPS
    # verify none: skip backend cert verification (use verify required for strict)
    server app1 10.0.1.10:8443 check ssl verify none
    server app2 10.0.1.11:8443 check ssl verify none

For strict backend certificate verification:

# Export the CA cert your backends use
cp /path/to/internal-ca.crt /etc/haproxy/certs/internal-ca.crt
backend secure_backend
    balance roundrobin
    server app1 10.0.1.10:8443 check ssl verify required ca-file /etc/haproxy/certs/internal-ca.crt

Session Persistence with SSL

When using SSL termination with session stickiness, use cookie-based persistence (IP hash is unreliable behind NAT):

backend app_servers
    balance roundrobin
    cookie SERVERID insert indirect nocache

    server app1 10.0.1.10:8080 check cookie app1
    server app2 10.0.1.11:8080 check cookie app2
    server app3 10.0.1.12:8080 check cookie app3

HAProxy inserts a SERVERID cookie on the first response and routes subsequent requests from that client to the same backend.

Checking SSL Configuration

# Test config syntax
haproxy -c -f /etc/haproxy/haproxy.cfg

# Verify which ciphers are negotiated
openssl s_client -connect example.com:443 -servername example.com 2>&1 | \
  grep -E "Protocol|Cipher|Session-ID"

# Check TLS 1.1 is rejected
openssl s_client -connect example.com:443 -tls1_1 2>&1 | grep -E "error|alert"

# Full SSL test
docker run --rm drwetter/testssl.sh example.com

# HAProxy stats socket
echo "show info" | socat stdio /run/haproxy/admin.sock | grep -E "SSL|Uptime"

# Count current SSL sessions
echo "show info" | socat stdio /run/haproxy/admin.sock | grep "SslFrontend"

Performance Tuning for SSL

SSL handshakes are CPU-intensive. Tune for throughput:

global
    # Enable TLS session cache to avoid full handshakes on reconnect
    tune.ssl.cachesize 20000
    tune.ssl.lifetime 300

    # Number of SSL handshakes per second (tune based on CPU)
    # Default is unlimited; set if you need to protect backend
    # tune.ssl.maxrecord 16384

    # Use multiple threads for SSL (HAProxy 1.8+)
    nbthread 4  # Set to CPU core count

Tuning reference:

SettingPurposeSuggested Value
tune.ssl.cachesizeSSL session cache entries20000 (tune up under load)
tune.ssl.lifetimeSession cache TTL (seconds)300
tune.ssl.default-dh-paramDH key size2048 (4096 for high security)
nbthreadWorker threadsCPU core count

After any config change:

# Graceful reload — existing connections finish, new ones use new config
systemctl reload haproxy
Share:

Was this article helpful?

Dev Patel
Dev Patel

Cloud Cost Optimization Specialist

I find the money your cloud is wasting. FinOps practitioner, data-driven analyst, and the person your CFO wishes they'd hired sooner. Every dollar saved is a dollar earned.

Related Articles

More in HAProxy

View all →

Discussion