Skip to content

HAProxy SSL termination

Terminating TLS at HAProxy — PEM bundles, ssl-default-bind options, ALPN for HTTP/2, OCSP stapling, and the production checks before you flip the switch.

HAProxy terminates TLS by binding a frontend to a port with the ssl keyword and a crt reference. The mechanics are simple; the surface area is wide. This article covers the production setup we use: PEM bundle layout, sane cipher defaults, ALPN for HTTP/2, OCSP stapling, and the verification steps before pointing real traffic at the new listener.

How to verify

Before you cut over, check the listener actually presents the certificate you intend, supports the protocols you expect, and chains correctly.

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
openssl s_client -connect example.com:443 -servername example.com -alpn h2,http/1.1 < /dev/null
openssl s_client -connect example.com:443 -servername example.com -status < /dev/null | grep -i 'ocsp response'
sudo journalctl -u haproxy -n 100 --no-pager | grep -iE 'ssl|crt'

The -alpn flag negotiates h2 first, then http/1.1; the response shows what HAProxy chose. -status requests OCSP stapling — “OCSP Response Status: successful” confirms HAProxy is stapling. If you see “no response sent” with stapling configured, the OCSP cache is empty or the responder is unreachable.

What’s happening

HAProxy’s TLS stack reads crt files as concatenated PEM bundles: certificate, intermediate chain, and private key in one file. (Optionally, an OCSP response in a sibling .ocsp file and a DH params block.) Per-bind options live on the bind line; defaults that apply to all binds live in global under ssl-default-bind-*.

The flow on a TLS frontend:

  1. Client connects to port 443.
  2. HAProxy reads the ClientHello, picks a certificate by SNI (matching crt-list entries or the file in crt).
  3. TLS handshake completes; ALPN negotiates h2 or http/1.1.
  4. HAProxy parses the HTTP request and applies ACLs / use_backend.

Two important separations:

  • Per-listener vs global: ssl-default-bind-options is the floor. Per-bind ssl-min-ver TLSv1.3 raises it for that listener only.
  • Front-side vs back-side: terminating at HAProxy and re-encrypting to the backend are two independent configs. Most clients terminate at HAProxy and forward over HTTP on a private VLAN; re-encryption is optional and configured in the server line with ssl verify required ca-file ....

The procedure

  1. Set sane global defaults. Put this in global so any new bind line inherits a safe baseline:

    global
        ssl-default-bind-ciphers 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
        tune.ssl.default-dh-param 2048

    ssl-default-bind-ciphers controls TLS 1.0-1.2 ciphers; ssl-default-bind-ciphersuites controls TLS 1.3. They are separate strings — setting only the first leaves TLS 1.3 on built-in defaults.

  2. Build the PEM bundle. From a Let’s Encrypt or commercial CA, concatenate end-entity certificate, intermediate chain, and private key:

    sudo mkdir -p /etc/haproxy/certs
    sudo cat fullchain.pem privkey.pem > /etc/haproxy/certs/example.com.pem
    sudo chmod 640 /etc/haproxy/certs/example.com.pem
    sudo chown root:haproxy /etc/haproxy/certs/example.com.pem

    Mode 640 with group haproxy keeps the key unreadable to other users. Owning the file as root and giving the haproxy group read-only is the production pattern.

  3. Bind the frontend with TLS. Single-certificate listener with ALPN:

    frontend fe_https
        bind *:443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1
        http-request redirect scheme https code 301 unless { ssl_fc }
        default_backend be_app

    The alpn h2,http/1.1 is what unlocks HTTP/2 on the front side. Without it, browsers fall back to HTTP/1.1.

  4. Add HSTS at the response layer. Once HTTPS is working end-to-end and you have committed to staying on HTTPS, add HSTS — but not before, and not with preload until you have lived with max-age for at least a month:

    frontend fe_https
        bind *:443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1
        http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
        default_backend be_app
  5. Multiple certificates via crt-list. When you serve many domains, use a crt-list so you can add certificates without editing the bind line:

    cat <<'EOF' | sudo tee /etc/haproxy/certs/crt-list.txt
    /etc/haproxy/certs/example.com.pem example.com www.example.com
    /etc/haproxy/certs/api.example.com.pem api.example.com
    EOF
    frontend fe_https
        bind *:443 ssl crt-list /etc/haproxy/certs/crt-list.txt alpn h2,http/1.1

    The hostnames at the end of each line are the SNI patterns HAProxy matches against; the certificate’s CN/SAN must also match for browsers to accept it.

  6. OCSP stapling. HAProxy staples an OCSP response if a .ocsp file exists next to the .pem. Generate it once and refresh on a timer:

    openssl ocsp -issuer chain.pem -cert cert.pem \
      -url $(openssl x509 -in cert.pem -noout -ocsp_uri) \
      -no_nonce -respout /etc/haproxy/certs/example.com.pem.ocsp
    echo "set ssl ocsp-response $(base64 -w0 /etc/haproxy/certs/example.com.pem.ocsp)" \
      | sudo socat /run/haproxy/admin.sock -

    Refresh the OCSP response every 24h via cron; runtime API load makes the new staple live without reload.

  7. Validate end-to-end. TLS labs (qualys, mozilla observatory) and a local openssl s_client confirm the handshake. Then load the site in a real browser and check the network panel for HTTP/2.

Common pitfalls

  • A wrong file order in the PEM bundle (key before cert, intermediate missing) fails at parse time with an unhelpful “unable to load certificate” — openssl x509 -in cert.pem -noout -text confirms the file is parseable as a cert.
  • Forgetting alpn h2,http/1.1 means modern clients get HTTP/1.1 even though both sides support h2. Test with openssl s_client -alpn h2.
  • HSTS with preload is a one-way door — once submitted to the preload list, you cannot remove it without browser delays. Add preload only after a month of stable HSTS.
  • tune.ssl.default-dh-param 2048 matters only for DH ciphersuites; on modern bind lines with ECDHE-only ciphers, DH params are unused. Setting them anyway is harmless and forward-compatible.
  • The PEM bundle file permission must be readable by the haproxy user. If you copied the file as root with mode 600, HAProxy starts but the bind silently fails to load the cert and falls back to a self-signed default.

Stack Harbor runs TLS rotation as a managed concern — Let’s Encrypt or commercial CA, automated renewal, OCSP refresh, hot-reload via runtime API, and a monthly “expiring in 30 days” report that surfaces certs we did not auto-renew. For multi-region clients with dozens of certificates, this is what we manage as part of Multi-region Environments.