# HAProxy SSL termination with Let's Encrypt certificates

> Terminate TLS in HAProxy with Let's Encrypt: combined PEM files, SNI certificate directory, ACME challenge routing, HTTPS redirect and reload on renewal.

Source: https://ssl.sh/guides/haproxy-ssl-termination/
Last updated: 2026-11-10

HAProxy terminates HTTPS with a Let's Encrypt certificate stored as one combined PEM file (chain plus key). Certbot answers challenges on a private port behind HAProxy, and a deploy hook rebuilds the PEM and reloads HAProxy after every renewal.

The configuration also redirects HTTP to HTTPS and forwards traffic to a backend application. It applies to HAProxy 2.8 and later (including 3.x LTS releases) with Certbot 4.1 or later.

## Prerequisites

HAProxy owns ports 80 and 443; Certbot answers challenges on a private port behind it.

- A Linux server with HAProxy installed and sudo access; Certbot installed from snap (`sudo snap install --classic certbot`).
- DNS `A`/`AAAA` records for `example.com` and `www.example.com` pointing at HAProxy, for example `203.0.113.10`.
- Inbound TCP 80 and 443 open to all sources. Port 8888 must stay closed to the outside; it only listens on 127.0.0.1.
- A backend application reachable from HAProxy, for example `127.0.0.1:8080`.

## Step 1: Route ACME challenges on port 80

Start with an HTTP-only configuration that sends challenge requests to Certbot and everything else to the application:

```haproxy
# /etc/haproxy/haproxy.cfg (excerpt)
frontend web
    bind :80
    acl acme path_beg /.well-known/acme-challenge/
    use_backend certbot if acme
    default_backend app

backend certbot
    server certbot 127.0.0.1:8888

backend app
    server app1 127.0.0.1:8080 check
```

```bash
sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy
```

## Step 2: Obtain the certificate with Certbot standalone

Certbot's standalone mode starts a temporary web server on 127.0.0.1:8888 that serves only the challenge:

```bash
sudo certbot certonly --standalone \
  --http-01-address 127.0.0.1 --http-01-port 8888 \
  -d example.com -d www.example.com
```

The certificate lands in `/etc/letsencrypt/live/example.com/`.

## Step 3: Build the combined PEM with a deploy hook

Write a deploy hook that concatenates the chain and key and reloads HAProxy. Certbot runs every executable in this directory after each successful renewal:

```bash
sudo mkdir -p /etc/haproxy/certs
sudo chmod 700 /etc/haproxy/certs
sudo tee /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh > /dev/null <<'EOF'
#!/bin/sh
set -e
name=$(basename "$RENEWED_LINEAGE")
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" > "/etc/haproxy/certs/$name.pem.tmp"
chmod 600 "/etc/haproxy/certs/$name.pem.tmp"
mv "/etc/haproxy/certs/$name.pem.tmp" "/etc/haproxy/certs/$name.pem"
systemctl reload haproxy
EOF
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
```

Run it once by hand for the certificate from Step 2:

```bash
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/example.com /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
```

## Step 4: Enable the TLS listener and the redirect

Now that `/etc/haproxy/certs/example.com.pem` exists, add the 443 bind, a TLS baseline and the redirect:

```haproxy
global
    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets

frontend web
    bind :80
    bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
    acl acme path_beg /.well-known/acme-challenge/
    http-request redirect scheme https code 301 unless { ssl_fc } || acme
    http-request set-header X-Forwarded-Proto https if { ssl_fc }
    use_backend certbot if acme
    default_backend app
```

```bash
sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy
```

Do not add OCSP options for Let's Encrypt certificates; they no longer contain an OCSP URL.

## Verify

The endpoint should present the Let's Encrypt certificate, and HTTP should redirect to HTTPS. Check it with the [SSL checker](https://ssl.sh/tools/ssl-checker/), or from a terminal:

```bash
curl ssl.sh/example.com
```

Then confirm the chain HAProxy sends and the redirect:

```bash
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | grep -E '^ *[0-9] s:'
curl -sI http://example.com | head -n 3
```

You should see two certificates (depth 0 and 1) in the chain output.

## Automatic renewal

The Certbot snap timer runs `certbot renew` twice a day and follows Let's Encrypt's ARI window (Certbot 4.1+). Because the certificate was obtained with `--standalone` on port 8888, renewal reuses those settings, and the deploy hook from Step 3 rebuilds the PEM and reloads HAProxy. Test the whole path:

```bash
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
```

A dry run does not execute deploy hooks, so after the first real renewal confirm the new expiry date on the live endpoint. The same setup keeps working when Let's Encrypt's default lifetime drops to 64 days in February 2027. Add the site to [ssl.sh monitoring](https://ssl.sh/monitor/) (in development) as a safety net.

## Troubleshooting

- **unable to load SSL private key** or **No Private Key found**: the PEM file lacks the key or has it in the wrong file; rebuild it with the hook.
- **unable to stat SSL certificate from file**: the `crt` path is empty or wrong; HAProxy refuses to start with an empty certificate directory.
- **Certbot timeout during connect**: the ACME ACL is not matching or port 80 is blocked; see [certbot timeout during connect](https://ssl.sh/errors/certbot-timeout-during-connect/).
- **Clients report an incomplete chain**: the PEM was built from `cert.pem` instead of `fullchain.pem`; see [missing intermediate certificate](https://ssl.sh/errors/missing-intermediate-certificate/).
- **Redirect loop behind a CDN**: the CDN connects over HTTP, so `ssl_fc` is false; redirect at the CDN instead, see [redirect HTTP to HTTPS](https://ssl.sh/guides/redirect-http-to-https/).
