InfraRunBook
    Back to articles

    HAProxy SSL/TLS Termination: Let's Encrypt, Certificate Bundles, ALPN, HSTS, and TLS 1.3 Hardening

    HAProxy
    Published: Feb 16, 2026
    Updated: Feb 16, 2026

    Complete production guide to configuring SSL/TLS termination in HAProxy with Let's Encrypt automation, certificate bundle management, ALPN negotiation, HSTS enforcement, and TLS 1.3 hardening for maximum security and performance.

    HAProxy SSL/TLS Termination: Let's Encrypt, Certificate Bundles, ALPN, HSTS, and TLS 1.3 Hardening

    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

    crt
    at a directory, HAProxy loads every
    .pem
    file 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-list
    file 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

    .ocsp
    file next to the PEM file. If
    /etc/haproxy/certs/example.com.pem
    exists, HAProxy checks for
    /etc/haproxy/certs/example.com.pem.ocsp
    on 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.cfg
    combining 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-ciphers
    controls cipher suites for TLS 1.2 and below using OpenSSL cipher string format.
    ssl-default-bind-ciphersuites
    controls 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 tcp
    on 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
    ssl
    keyword) and
    tcp-request inspect-delay 5s
    with
    req.ssl_sni
    for 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-age
    of 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

    .pem
    files 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 cert
    followed by
    commit ssl cert
    through 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 h2
    on 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

    bind
    line:
    bind *:443 ssl crt /etc/haproxy/certs/ ssl-min-ver TLSv1.3 alpn h2,http/1.1
    . The
    ssl-min-ver TLSv1.3
    directive on the bind line overrides the global
    ssl-default-bind-options
    for that specific listener only.

    Q10: How can I log which TLS version each client is using?

    Use the

    %sslv
    log variable in your
    log-format
    directive to capture the TLS version (e.g., TLSv1.2, TLSv1.3). Use
    %sslc
    to 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 301
    without 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=1
    to
    https://example.com/path?q=1
    . This is the recommended approach over location-based redirects.

    Frequently Asked Questions

    What is the difference between ssl-default-bind-ciphers and ssl-default-bind-ciphersuites in HAProxy?

    ssl-default-bind-ciphers controls cipher suites for TLS 1.2 and below using OpenSSL cipher string format. ssl-default-bind-ciphersuites controls 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.

    Can HAProxy perform SSL passthrough without terminating TLS?

    Yes. Use mode tcp on 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 ssl keyword) and tcp-request inspect-delay 5s with req.ssl_sni for SNI-based routing.

    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-age of at least 6 months (15768000 seconds), proper certificate chain, OCSP stapling, and a 2048-bit or larger DH parameter file.

    How does HAProxy select which certificate to serve when using a directory?

    HAProxy reads all .pem files 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).

    Can I update certificates without restarting HAProxy?

    Yes, HAProxy 2.4+ supports hot certificate updates via the stats socket. Use 'set ssl cert' followed by 'commit ssl cert' through the admin socket. This allows zero-downtime certificate rotation without dropping any active connections.

    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 h2 on server lines only if your backend application is specifically optimised for HTTP/2 multiplexing, such as gRPC services.

    How do I handle Let's Encrypt certificate renewal without downtime?

    Configure the ACME HTTP-01 challenge backend so HAProxy forwards /.well-known/acme-challenge/ requests to certbot's webroot. Use a deploy hook script that rebuilds PEM bundles and runs systemctl reload haproxy. HAProxy's reload is graceful — it spawns new workers while old workers drain existing connections.

    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).

    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 bind line: bind *:443 ssl crt /etc/haproxy/certs/ ssl-min-ver TLSv1.3 alpn h2,http/1.1. The ssl-min-ver TLSv1.3 directive on the bind line overrides the global ssl-default-bind-options for that specific listener only.

    How can I log which TLS version each client is using in HAProxy?

    Use the %sslv log variable in your log-format directive to capture the TLS version (e.g., TLSv1.2, TLSv1.3). Use %sslc to 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.

    Related Articles