HAProxy SSL Termination with Let's Encrypt: Step-by-Step Setup (2025)
Set up free HTTPS on HAProxy using Let's Encrypt certificates with Certbot. Includes auto-renewal, TLS 1.3, HTTP/2, OCSP stapling, and HSTS. Complete setup in under 20 minutes.
How HAProxy SSL Termination Works
In SSL termination, HAProxy decrypts incoming HTTPS connections from clients, then forwards unencrypted (or re-encrypted) traffic to your backend servers. This means:
- Your application servers don't need to manage SSL certificates
- SSL processing is centralized and easier to audit
- HAProxy can inspect HTTP headers (impossible with SSL passthrough)
- You get significant CPU savings on your application servers
The difference from SSL passthrough (where HAProxy forwards the encrypted bytes without decrypting) is that with termination, HAProxy holds the private key and does the decryption work.
Step 1: Install HAProxy
# Ubuntu 22.04 / 24.04
apt-get install --no-install-recommends software-properties-common
add-apt-repository ppa:vbernat/haproxy-2.9
apt-get install haproxy=2.9.*
# Verify
haproxy -vStep 2: Install Certbot
# Ubuntu
apt-get install certbot
# RHEL / Amazon Linux 2
amazon-linux-extras install epel
yum install certbotStep 3: Obtain Your First Let's Encrypt Certificate
HAProxy needs to listen on port 80 for the ACME HTTP challenge. We'll use thestandalone mode first (before HAProxy is running), then switch to a renewal hook that stops/starts HAProxy:
# Stop HAProxy temporarily for initial cert issuance
systemctl stop haproxy
# Obtain certificate (replace with your domain)
certbot certonly --standalone \
-d example.com \
-d www.example.com \
--email you@example.com \
--agree-tos \
--non-interactive
# Certificates will be at:
# /etc/letsencrypt/live/example.com/fullchain.pem
# /etc/letsencrypt/live/example.com/privkey.pemStep 4: Combine Certificate Files for HAProxy
HAProxy requires a single .pem file containing both the certificate chain and private key:
# Create the combined PEM file
cat /etc/letsencrypt/live/example.com/fullchain.pem \
/etc/letsencrypt/live/example.com/privkey.pem \
> /etc/haproxy/certs/example.com.pem
# Secure it
chmod 600 /etc/haproxy/certs/example.com.pem
# Create the directory if needed
mkdir -p /etc/haproxy/certsStep 5: Configure HAProxy with SSL
global
log /dev/log local0
maxconn 50000
user haproxy
group haproxy
daemon
# TLS hardening
ssl-default-bind-options ssl-min-ver TLSv1.2 no-sslv3 no-tls-tickets
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384
# SSL cache for session resumption
tune.ssl.cachesize 100000
tune.ssl.lifetime 300
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
option http-server-close
timeout connect 5s
timeout client 30s
timeout server 30s
# ACME challenge frontend (HTTP only, for Certbot renewal)
frontend http_frontend
bind *:80
# Allow Let's Encrypt ACME challenge through
acl is_acme path_beg /.well-known/acme-challenge
use_backend acme_backend if is_acme
# Redirect everything else to HTTPS
http-request redirect scheme https code 301 unless is_acme
# HTTPS frontend
frontend https_frontend
bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
# HSTS - force browsers to always use HTTPS
http-response set-header Strict-Transport-Security \
"max-age=63072000; includeSubDomains; preload"
# Security headers
http-response set-header X-Frame-Options "SAMEORIGIN"
http-response set-header X-Content-Type-Options "nosniff"
http-response del-header Server
# Pass real IP to backends
http-request set-header X-Forwarded-Proto https
default_backend web_servers
# ACME challenge backend (simple HTTP server on port 8888)
backend acme_backend
server localhost 127.0.0.1:8888
# Your actual backend servers
backend web_servers
balance roundrobin
option httpchk GET /health
http-check expect status 200
server app1 10.0.0.1:8080 check inter 2s fall 3 rise 2
server app2 10.0.0.2:8080 check inter 2s fall 3 rise 2Step 6: Automate Certificate Renewal
Let's Encrypt certificates expire every 90 days. Set up a renewal hook to automatically rebuild the combined PEM file and reload HAProxy:
# Create the renewal hook script
cat > /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh << 'EOF'
#!/bin/bash
# Run after Certbot successfully renews a certificate
DOMAIN="example.com"
CERT_DIR="/etc/letsencrypt/live/$DOMAIN"
HAPROXY_CERT="/etc/haproxy/certs/$DOMAIN.pem"
# Combine fullchain + private key into single PEM
cat "$CERT_DIR/fullchain.pem" "$CERT_DIR/privkey.pem" > "$HAPROXY_CERT"
chmod 600 "$HAPROXY_CERT"
# Gracefully reload HAProxy (zero downtime)
systemctl reload haproxy
echo "HAProxy SSL certificate renewed: $(date)"
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/haproxy.shCertbot already runs daily via a systemd timer. Test your renewal setup:
# Test renewal in dry-run mode (won't actually renew)
certbot renew --dry-run
# Test the full renewal flow
certbot renew --force-renewal --deploy-hook /etc/letsencrypt/renewal-hooks/deploy/haproxy.shStep 7: Enable HTTP/2
HTTP/2 is enabled by adding alpn h2,http/1.1 to your bind directive (already included in the config above). Verify it's working:
# Check if HTTP/2 is active
curl -I --http2 https://example.com
# Should see: HTTP/2 200Step 8: Multiple Domains
HAProxy automatically serves the correct certificate for each domain when you put all PEM files in the same directory (used with crt /etc/haproxy/certs/):
# Get certs for a second domain
certbot certonly --standalone -d api.example.com --email you@example.com --agree-tos
# Combine for HAProxy
cat /etc/letsencrypt/live/api.example.com/fullchain.pem \
/etc/letsencrypt/live/api.example.com/privkey.pem \
> /etc/haproxy/certs/api.example.com.pem
# Reload HAProxy
systemctl reload haproxy
# Now /etc/haproxy/certs/ contains:
# example.com.pem
# api.example.com.pem
# HAProxy serves the right cert via SNI automaticallyTroubleshooting
Problem: SSL certificate not found
Solution: Ensure the .pem file contains both the full certificate chain AND the private key. Check with: openssl x509 -in /etc/haproxy/certs/example.com.pem -text -noout
Problem: Certbot renewal fails: port 80 in use
Solution: Use the 'webroot' plugin instead of standalone, or configure the ACME challenge frontend in HAProxy to proxy to a local certbot webroot. The config above handles this.
Problem: Browser shows 'not secure' after setup
Solution: Check that the fullchain.pem (not just cert.pem) is in your combined file. Run: openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /etc/haproxy/certs/example.com.pem
Problem: HAProxy reload fails after renewal
Solution: Check config syntax first: haproxy -c -f /etc/haproxy/haproxy.cfg. Then check permissions: the haproxy user must be able to read the .pem file.
SSL Configuration Checklist
- TLS 1.2 minimum enforced (TLS 1.3 preferred)
- SSLv3 and TLS 1.0/1.1 disabled
- ECDHE cipher suites only (forward secrecy)
- HTTP/2 enabled via ALPN
- HSTS header with preload enabled
- OCSP stapling enabled
- Auto-renewal configured and tested
- Combined PEM file secured (chmod 600)
For a complete security hardening guide beyond SSL, see: HAProxy Security Hardening: DDoS Protection, SSL & Rate Limiting
Need SSL configured correctly, not just running?
Our team sets up production-grade SSL with proper cipher suites, auto-renewal, HSTS preloading, and monitoring as part of our DevOps & Cloud service.
SSL Setup Done. What's Next?
PentaSynth handles SSL termination, load balancing, DDoS protection, and full infrastructure monitoring for production systems. Let our DevOps team set up your complete stack.
See Our DevOps Services