# HAProxy 配置 SSL 卸载：Let's Encrypt 证书部署

> 在 HAProxy 上终止 HTTPS：合并 PEM 文件、按 SNI 加载证书目录、转发 ACME 验证请求、HTTP 跳转 HTTPS，续期后自动重载。

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

HAProxy 卸载 SSL 要用一个合并了证书链和私钥的 PEM 文件：Certbot 在 HAProxy 后面的本地端口申请 Let's Encrypt 证书，部署钩子把证书合并成 PEM 并重载 HAProxy，443 前端加载这个文件，80 前端负责跳转。

适用于 HAProxy 2.8 及以上（含 3.x LTS），Certbot 4.1 及以上。

## 前提条件

HAProxy 占用 80 和 443，Certbot 在它后面的本地端口上应答验证。

- 已安装 HAProxy 的 Linux 服务器，有 sudo 权限；已用 snap 安装 Certbot（`sudo snap install --classic certbot`）。
- `example.com`、`www.example.com` 的 A/AAAA 记录指向 HAProxy，例如 `203.0.113.10`。
- 服务器在中国大陆的，域名需已 ICP 备案。
- 防火墙和安全组对所有来源放行 TCP 80、443；8888 端口只监听 127.0.0.1，不要对外开放。
- 后端应用可以访问，例如 `127.0.0.1:8080`。

## 第一步：在 80 端口转发 ACME 验证请求

先用只有 HTTP 的配置，把验证请求交给 Certbot，其他请求交给应用：

```haproxy
# /etc/haproxy/haproxy.cfg（节选）
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
```

## 第二步：用 Certbot standalone 申请证书

Certbot 只在验证期间临时监听 127.0.0.1:8888：

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

证书保存在 `/etc/letsencrypt/live/example.com/`。

## 第三步：用部署钩子生成合并 PEM

写一个部署钩子，合并证书链和私钥后重载 HAProxy。Certbot 每次续期成功后会执行这个目录里的脚本：

```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
```

对刚申请的证书手动执行一次：

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

## 第四步：开启 443 监听和跳转

`/etc/haproxy/certs/example.com.pem` 生成后，再加 443 监听、TLS 基线和跳转：

```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
```

不要给 Let's Encrypt 证书配置 OCSP 相关选项，证书里已经没有 OCSP 地址。

## 验证

配置正确时，`example.com` 返回 Let's Encrypt 证书，HTTP 请求跳转到 HTTPS。用 [SSL 证书检查](https://ssl.sh/zh/tools/ssl-checker/) 从外部检查，或在终端运行：

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

再确认 HAProxy 发送的证书链和跳转：

```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
```

证书链输出里应该有两张证书（深度 0 和 1）。

## 自动续期

snap 版 Certbot 的定时器每天运行两次 `certbot renew`，4.1 及以上按 ARI 建议时间续期。证书是用 8888 端口 standalone 申请的，续期会沿用这些参数，第三步的钩子负责重建 PEM 并重载 HAProxy。测试整个流程：

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

`--dry-run` 不会执行部署钩子，第一次续期后要到线上确认新的到期时间。2027 年 2 月 Let's Encrypt 默认证书改为 64 天后这套配置照常工作。建议加到 [ssl.sh 监控](https://ssl.sh/zh/monitor/)（开发中） 兜底。

## 常见问题排查

- **unable to load SSL private key 或 No Private Key found**：PEM 里没有私钥，用钩子重新生成。
- **unable to stat SSL certificate from file**：`crt` 路径不对或目录为空，证书目录为空时 HAProxy 无法启动。
- **Certbot 连接超时**：ACL 没匹配上或 80 端口不通，见 [certbot 连接超时](https://ssl.sh/zh/errors/certbot-timeout-during-connect/)。
- **客户端提示证书链不完整**：PEM 用的是 `cert.pem` 而不是 `fullchain.pem`，见 [缺少中间证书](https://ssl.sh/zh/errors/missing-intermediate-certificate/)。
- **套了 CDN 后无限跳转**：CDN 回源走 HTTP，`ssl_fc` 为假，改在 CDN 上做跳转，见 [HTTP 跳转 HTTPS](https://ssl.sh/zh/guides/redirect-http-to-https/)。
