从零给 Nginx 站点配 HTTPS(Let's Encrypt)

这篇解决什么问题

你已经有一台 Linux 服务器、一个域名,http://你的域名/ 能打开,但浏览器地址栏写着“不安全”。 你想上 HTTPS,又不想每年花钱买证书、每年手动换一次。

读完你会得到:一个自动续期的 HTTPS 站点——装一次,之后不用管。

适合谁:会用 SSH 登录服务器、能编辑配置文件、知道 Nginx 大概是什么的人。 不需要懂密码学。

全文命令以 root 或 sudo 执行。发行版以 CentOS 系(含 Rocky / Alma / 阿里云 Linux)为主, Ubuntu / Debian 的差异会单独标出。


第 0 步:先搞懂三件事

不懂这三件,后面就是盲操作——出了错也不知道错在哪。

① 证书是什么

证书是一对密钥 + 一份“身份证”。它的作用只有一个:让浏览器和你的服务器之间的通信加密, 并且证明“这个域名确实是你的”。

没有它,浏览器会提示“不安全”;有了它,地址栏出现一把锁。

② ACME 是什么,为什么要“证明域名是你的”

证书颁发机构(CA)不能谁来申请就给谁签——否则任何人都能申请一张 你的域名 的证书, 然后冒充你的网站,浏览器还会显示“安全”。

所以 CA 必须先确认:申请的人确实控制着这个域名。

这套“证明”的流程叫 ACME。最常见的方式是 HTTP-01:

  1. CA 给你一个随机字符串
  2. 你把它放在 http://你的域名/.well-known/acme-challenge/<随机串>
  3. CA 自己从公网来访问这个网址
  4. 取到了、内容对得上 → 证明你控制着这个域名 → 签发证书

这一步决定了后面所有的配置细节:你必须让 CA 能通过 80 端口取到那个文件。 后面第 6 步里所有的“坑”,本质上都是这一步没走通。

③ 备案和证书的关系(大陆服务器必看)

这两件事完全无关,管的人都不一样:

谁管 看什么
ICP 备案 工信部(通过云厂商提交) 你的服务器在哪、站内是什么内容
SSL 证书 CA 机构 只验证“你能不能控制这个域名”,不问备案

所以:没备案也能签下证书。

但要注意另一件事:如果你的服务器在中国大陆,域名又没有备案, 云厂商的监测系统可能拦截对 80/443 的访问(阿里云官方文档明确写了会阻断)。 那样证书签得下来,网站却打不开。

结论:证书可以照本文做;但服务器在大陆的话,备案该办还是要办。


第 1 步:前置检查(不满足就别往下走)

做任何操作之前,先把这三条确认掉——否则后面每一步都会失败,而你不知道是哪一步的问题。

1.1 域名解析指向你的服务器了吗

nslookup www.example.com
  • 看到什么算成功:返回的 Address 是你的服务器公网 IP
  • 不对怎么办:
    • 报“找不到”/“Non-existent domain” → 解析没配或没生效。去域名控制台加一条 A 记录
    • 返回了别的 IP → 解析写错了,改过来

⚠️ 注意 www 和不带 www 是两条独立记录。很多人只加了 www, 然后发现裸域名打不开——那是正常的,需要分别添加。 本文统一以 www.example.com 为例;你想用裸域名,把命令里的域名换掉即可(两者不能混用)。

1.2 HTTP 能访问吗

curl -I http://www.example.com/
  • 看到什么算成功:HTTP/1.1 200 OK
  • 不对怎么办:
    • 连不上 → 先确认 Nginx 在跑:systemctl status nginx
    • 403 / 404 → Nginx 在跑,但配置的站点根目录不对,先解决这个

1.3 防火墙和云安全组放行了吗

两层都要放行,只放一层是最常见的坑:

层 怎么放行
云厂商安全组 控制台里加入方向规则:TCP 80 和 443,源 0.0.0.0/0
系统防火墙 firewall-cmd --add-port=80/tcp --add-port=443/tcp --permanent && firewall-cmd --reload<br>(Ubuntu:ufw allow 80,443/tcp)

为什么必须先做:证书验证走 80,HTTPS 访问走 443。 这两个端口不通,后面所有步骤都会以“超时”告终——而超时的报错信息不会告诉你是防火墙的问题。


第 2 步:装 certbot

certbot 是 Let’s Encrypt 官方的客户端,负责申请、安装、自动续期证书。

CentOS / Rocky / Alma / 阿里云 Linux

dnf install -y certbot python3-certbot-nginx
certbot --version
  • 看到什么算成功:打印出 certbot 2.x.x 之类的版本号
  • 出问题怎么办:
    • 报依赖冲突(conflicts with)→ 系统的软件源里有定制版的 EPEL, 可以试 dnf install -y certbot python3-certbot-nginx --allowerasing。 但 --allowerasing 会移除冲突的包——执行前先看清楚它要移除什么, 不认识就别硬来
    • No package certbot available → 不要自己去加通用 EPEL 源, 在云厂商定制系统上那很容易把依赖搞乱。先查厂商文档

Ubuntu / Debian

apt update && apt install -y certbot python3-certbot-nginx

为什么用 python3-certbot-nginx:它让 certbot 能自动读写 Nginx 配置。 本文不使用这个自动能力(见第 5 步的说明),但装上它无害。


第 3 步:先彩排,别直接签

这一步很多人跳过,然后付出代价。

Let’s Encrypt 有限流机制:同一域名验证失败次数太多,会被临时拒绝服务。 排错过程中很容易把额度用光,然后只能干等。

所以先跑彩排模式——它完整走一遍流程(真的写验证文件、CA 真的来取), 只是最后不签发证书:

certbot certonly --webroot -w /var/www/example/acme \
  -d www.example.com \
  --dry-run \
  --non-interactive --agree-tos --register-unsafely-without-email

逐段解释这条命令:

参数 作用
certonly 只申请证书,不改我的 Nginx 配置(改配置我自己来,见第 5 步)
--webroot -w /var/www/example/acme 把验证文件写到这个目录。它会自己往这个目录下建 .well-known/acme-challenge/
-d www.example.com 给哪个域名签(可写多个 -d)
--dry-run 用测试环境跑一遍,不签真证书
--non-interactive --agree-tos 不交互、同意条款
--register-unsafely-without-email 不填邮箱注册。彩排阶段用它就行,正式签的时候建议填邮箱(见第 4 步)
  • 看到什么算成功:末尾出现 The dry run was successful.
  • 它同时证明了三件事(这就是彩排的价值):
    1. 80 端口从公网可达
    2. 域名解析正确
    3. -w 指定的目录,Nginx 能对外提供访问

出问题怎么办:

报错 含义 怎么办
Timeout during connect CA 访问不到你的 80 端口 回到第 1 步查防火墙 / 安全组
Invalid response ... 404 能访问,但取不到验证文件 说明 -w 目录没被 Nginx 提供出去 —— 见第 6 步的 location 配置
unauthorized 被拦了 大陆未备案的域名可能是这个原因(见第 0 步③)

第 4 步:正式签证书

这次填你自己的邮箱(证书快过期时 CA 会发提醒):

certbot certonly --webroot -w /var/www/example/acme \
  -d www.example.com \
  --non-interactive --agree-tos -m you@example.com
  • 看到什么算成功:
Congratulations! Your certificate and chain have been saved at:
/etc/letsencrypt/live/www.example.com/fullchain.pem

记下这个路径,第 5 步要用。四份文件的分工:

文件 用途
fullchain.pem 证书链(给 Nginx 的 ssl_certificate)
privkey.pem 私钥(给 ssl_certificate_key)—— 绝不能泄露
cert.pem / chain.pem 单独看证书 / 中间证书时用,一般配置里不直接用

它们都是软链,指向 ../../archive/<域名>/<名字><序号>.pem。 续期时序号会加一,软链自动指过去——所以配置里永远写软链路径,别写 archive/ 里的真实文件名。


第 5 步:改 Nginx 配置

为什么不直接用 certbot --nginx

certbot --nginx 能自动改你的 Nginx 配置,看起来省事。但它会直接改你手上跑着的配置—— 而那份配置可能已经稳定运行很久,被工具改坏之后不好回退。

本文的做法:certonly 只签证书,配置自己写。多花五分钟,换来“每处改动都看得见”。

完整配置

改 /etc/nginx/conf.d/example.conf(改之前先备份):

# ---------- ① 域名 + 80:除 ACME 验证外,全部跳 HTTPS ----------
server {
    listen 80;
    server_name www.example.com;

    root /var/www/example/html;
    index index.html;

    charset utf-8;
    server_tokens off;

    # 默认不缓存
    add_header Cache-Control "no-cache";

    # ★ ACME 验证目录:必须真的能把文件读出来
    location ^~ /.well-known/acme-challenge/ {
        alias /var/www/example/acme/.well-known/acme-challenge/;
        try_files $uri =404;
        log_not_found off;
    }

    # 其余全部跳 HTTPS
    location / {
        return 301 https://$host$request_uri;
    }
}

# ---------- ② 域名 + 443:正式服务 ----------
server {
    listen 443 ssl http2;
    server_name www.example.com;

    ssl_certificate     /etc/letsencrypt/live/www.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;
    # 刻意不写 ssl_ciphers:用 Nginx 默认值即可,
    # 手写一串很容易把已被淘汰的算法带进来,反而更不安全。

    root /var/www/example/html;
    index index.html;

    charset utf-8;
    server_tokens off;
    add_header Cache-Control "no-cache";

    location / {
        try_files $uri $uri/ =404;
    }
}

逐段解释

段落 为什么这么写
location ^~ /.well-known/acme-challenge/ ^~ 的优先级高于普通 location /,这样验证请求不会被下面的 301 跳走
alias 而不是 root 验证文件的真实路径是 -w 目录下的 .well-known/acme-challenge/。用 alias 把 URL 前缀直接映射到那个目录,最不容易搞错
try_files $uri =404; 真的去读文件,读不到才 404
location / { return 301 ...; } 除验证路径外,所有 HTTP 请求跳到 HTTPS
443 块不写 ssl_ciphers Nginx 默认套件已经够用;手写容易带进弱算法
两个块都写 root 443 块是真正对外服务的那个,必须有根目录

生效(语法不过就别 reload)

nginx -t && nginx -s reload && echo "=== RELOAD OK ==="
  • 看到什么算成功:syntax is ok + test is successful + === RELOAD OK ===
  • nginx -t 报错的常见原因:
报错 原因
unknown directive "http2" 你写了 http2 on;,那是 Nginx 1.25.1+ 的写法。低版本要写成 listen 443 ssl http2;
duplicate default server 有两个 listen 80 default_server
conflicting server name(这是警告不是错误) 有别的 server 块用了同名或 _。不影响启动,但说明有冗余配置

第 6 步:验证(五个检查,一个都不能省)

① HTTPS 能打开吗

浏览器打开 https://www.example.com/:地址栏应该有锁形图标,没有“不安全”警告。

命令行版本:

curl -I https://www.example.com/

② HTTP 会跳到 HTTPS 吗

curl -I http://www.example.com/
  • 期望:HTTP/1.1 301 + Location: https://www.example.com/

③ 证书内容对不对

echo | openssl s_client -connect www.example.com:443 -servername www.example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
  • 期望:subject 里有你的域名、issuer 是 Let’s Encrypt、notAfter 是 90 天后

④ ★ 验证文件真的能被公网读到(最重要的一条)

前面几条都过了,也不代表续期通道是好的。 必须单独验这一条:

# 在服务器上:放一个真文件
mkdir -p /var/www/example/acme/.well-known/acme-challenge
echo "PROBE-OK" > /var/www/example/acme/.well-known/acme-challenge/probe.txt
# 在你自己的电脑上(不是服务器):把内容读回来
curl http://www.example.com/.well-known/acme-challenge/probe.txt
  • 看到什么算成功:打印出 PROBE-OK
  • 只看到 404 或 301 → 这段配置有问题,续期将来一定会失败
  • 验完删掉:rm -f /var/www/example/acme/.well-known/acme-challenge/probe.txt

⚠️ 为什么必须这样做:光看“没有跳转”是不够的。 必须确认“文件内容真能取回来”。 把 404 误当成“通道正常”, 是后面“证书到期才发现续期一直失败”的最常见原因。

⑤ 续期干跑能过吗

certbot renew --dry-run
  • 看到什么算成功:Congratulations, all simulated renewals succeeded
  • 它的意思:完整走了一遍续期流程,但不真续期
  • 失败了怎么办:多半是第 ④ 条没通过,回头查那段 location

第 7 步:自动续期(装完就不用管了)

Let’s Encrypt 证书只有 90 天,靠自动续期。certbot 通常会自己装好定时任务,你只需确认它真的在。

确认定时任务存在

systemctl list-timers --all | grep certbot
# 或者(老系统)
ls -l /etc/cron.d/ | grep certbot
  • 看到什么算成功:能看到 certbot-renew.timer(状态 enabled),或 /etc/cron.d/certbot

如果什么都没有

systemctl enable --now certbot-renew.timer
systemctl list-timers --all | grep certbot

或者用 cron 兜底(crontab -e 加一行,每天两次检查):

0 3,15 * * * root certbot renew --quiet --deploy-hook "systemctl reload nginx"

为什么是“每天两次”:certbot 只在“剩余有效期不足 30 天”时才真的续, 其余时候什么都不做。所以跑得频繁是无害的,反而能保证不错过窗口。

为什么带 --deploy-hook "systemctl reload nginx": 换了新证书必须让 Nginx 重新加载,否则它还在用旧证书。

随时可以自查

certbot renew --dry-run

这是“证书到期前唯一需要你主动做的事”——跑一次,看到成功就放心。


第 8 步:两个真实踩过的坑

这一节是本文最有价值的部分。 两个坑都花了实际时间才定位。

坑一:http2 on; 不是所有版本都认

症状:

nginx: [emerg] unknown directive "http2" in /etc/nginx/conf.d/example.conf:20

原因:http2 on; 是 Nginx 1.25.1 才引入的独立指令。 低版本要用旧写法——把 http2 加到 listen 后面:

# 1.25.1 及以上
listen 443 ssl;
http2 on;

# 1.25.1 以下(1.24 等)
listen 443 ssl http2;

先查版本:nginx -v。

别为了这个去升级 Nginx——生产服务器上升级影响面太大, 改一行写法就解决的事,不值得。

坑二:ACME 路径“不跳转” ≠ “能读到文件”

这个坑更隐蔽,而且我“修”它的过程本身又造出一个新问题。

背景:配好 HTTP→HTTPS 跳转后,验证路径也会被跳走,续期就废了。所以需要在跳转之前“截住”它。

我试过的三种写法:

# ❌ 写法一:只写 root —— 验证请求仍被 location / 跳走(301)
location ^~ /.well-known/acme-challenge/ {
    root /var/www/example/acme;
}

# ❌ 写法二:直接返回 404 —— 本意是"别跳转",结果真文件也读不到
if ($request_uri ~ ^/\.well-known/) { return 404; }

# ✅ 写法三:alias + try_files —— 真的把文件读出来
location ^~ /.well-known/acme-challenge/ {
    alias /var/www/example/acme/.well-known/acme-challenge/;
    try_files $uri =404;
    log_not_found off;
}

写法二的错在哪:

我把目标定成了”不要跳转“,而真正的目标是”能被读到”。 改完我用一个不存在的假路径测了一下,得到 404——我以为“不跳转 = 通过”, 实际上那个 404 恰恰说明“文件取不到”。

真正跑一遍续期时,报错就来了:

Detail: <服务器IP>: Invalid response from
http://www.example.com/.well-known/acme-challenge/<一串随机字符>: 404

教训(两条,都很通用):

  1. “没有明确错误”不等于“正确”。用假路径测出 404,看着不像错误, 但它和“文件读不到”是同一个现象——必须用真文件去测。
  2. 验证的目标要写对。这条通道的目标不是“不跳转”,而是“CA 能取到那个文件”。 目标写错,验证方法就跟着错。

所以第 6 步的 ④ 才是“放一个真文件、从公网把内容读回来”。 不能只看状态码。


第 9 步:出问题先看哪里

现象 先查
certbot 报 Timeout during connect 80 端口:云安全组 + 系统防火墙都要放行
certbot 报 Invalid response ... 404 ACME 的 location 配置(第 5 步),并按第 6 步 ④ 用真文件验
certbot 报 unauthorized 大陆服务器 + 未备案域名可能被拦截
浏览器报“证书无效/不安全” certbot certificates 看有效期;过期就 certbot renew && systemctl reload nginx
改了配置但网站没变 nginx -t && nginx -s reload 了吗?配置改完必须 reload
换域名后站内链接全断 站内链接要用站内绝对路径(/posts/…),不要写死域名或 IP
nginx -t 报 unknown directive 指令的版本不兼容,查该指令是哪个版本引入的

附:命令清单(可整段复制,按顺序)

把 www.example.com、/var/www/example、you@example.com 换成你自己的。

# ---- 前置检查 ----
nslookup www.example.com
curl -I http://www.example.com/

# ---- 装 certbot(CentOS 系)----
dnf install -y certbot python3-certbot-nginx
certbot --version

# ---- 彩排 ----
certbot certonly --webroot -w /var/www/example/acme \
  -d www.example.com --dry-run \
  --non-interactive --agree-tos --register-unsafely-without-email

# ---- 正式签 ----
certbot certonly --webroot -w /var/www/example/acme \
  -d www.example.com \
  --non-interactive --agree-tos -m you@example.com

# ---- 改 Nginx 配置(用第 5 步的全文)----
cp -a /etc/nginx/conf.d/example.conf /etc/nginx/conf.d/example.conf.bak
vi /etc/nginx/conf.d/example.conf
nginx -t && nginx -s reload && echo "=== RELOAD OK ==="

# ---- 验证:放真文件 ----
mkdir -p /var/www/example/acme/.well-known/acme-challenge
echo "PROBE-OK" > /var/www/example/acme/.well-known/acme-challenge/probe.txt

# ---- 验证:续期干跑 ----
certbot renew --dry-run

# ---- 清理测试文件 ----
rm -f /var/www/example/acme/.well-known/acme-challenge/probe.txt

# ---- 出问题时回退配置 ----
cp -a /etc/nginx/conf.d/example.conf.bak /etc/nginx/conf.d/example.conf
nginx -t && nginx -s reload

在你自己的电脑上(验证第 ④ 条):

curl http://www.example.com/.well-known/acme-challenge/probe.txt
# 期望输出:PROBE-OK

一句话总结

HTTPS 不难配,难的是“验证自己配对了”。

三条最值钱的:

  1. 先彩排(--dry-run),别把限流额度浪费在排错上
  2. ACME 通道要用真文件验,不能只看状态码
  3. nginx -t 通过才 reload——它已经救过很多次“配置写错但还没生效”的场面