一台轻量服务器从零到 HTTPS:Nginx 站点配置实操记录

站点文件早早传上去了,域名解析也做了,浏览器里却一直是个默认页。

这篇最初是当天的操作记录,从「Nginx 不认识这个站点」到「443 能通、证书能握手」。踩到两个坑:一个在自己手上(tee -a 追加了重复的 server 块),一个在云控制台(安全组没放 443)。都写下来了。

后来站点真的跑起来,又陆续补了自定义 404、重复 URL 收敛、gzip 和缓存优先级(第六节),一并记在这里——这几处都是被实际访问问题逼出来的,比配置本身更值得记。

文中域名与 IP 已脱敏:zhi***.online、1.*.*.*。路径里的域名同步做了替换,照抄时全局替换成你自己的即可。

配置块里的 ssl_ciphers、证书路径、目录结构没有打码——这些不是秘密。密码套件是 TLS 握手里明文协商的参数,任何人 openssl s_client 连一下就看得见;证书本身也是公开信息。真正不能出现在博客里的是私钥内容和密码,本文只写到私钥的路径和权限。域名打码的目的不是藏站点(证书透明度日志里本来就公开),而是防止文章被转载后把「博客 → 服务器 → IP」串成一条可直接扫描的线索。

零、起点:文件在,站点不在

服务器是腾讯云轻量,Ubuntu 24.04,Nginx 1.24。静态站的产物已经放在 /var/www/zhi***.online 下,index.html 就在根目录。

Nginx 的默认行为是:/etc/nginx/sites-enabled/ 下的文件会被 nginx.conf 里的 include 加载,而目录里只有一个 default。所以不管域名解析到哪,请求都落到 /var/www/html。

先确认三件事,省得后面白折腾:

# 1. 文件真的在(-R 看权限,Nginx 需要对目录有 x 权限)
ls -la /var/www/zhi***.online

# 2. Nginx 的加载结构
ls -la /etc/nginx/sites-available/ /etc/nginx/sites-enabled/
grep -n "include" /etc/nginx/nginx.conf

# 3. Nginx 在跑、配置是哪一份
systemctl status nginx --no-pager | head -5
nginx -v

第 2 步的输出基本是这两个目录:

目录作用
sites-available/所有站点配置的「仓库」,写在这里不生效
sites-enabled/真正被加载的目录,放的是指向 available 的软链接

这个设计的意义是启停站点 = 建/删一个软链接,不用在配置文件里注释来注释去。Debian/Ubuntu 的 Nginx 包默认就是这套结构,CentOS 的 /etc/nginx/conf.d/ 只有一层,习惯不同但道理一样。

一、写站点配置

在 sites-available/ 下新建一份:

sudo vim /etc/nginx/sites-available/zhi***.online

内容:

server {
    listen 80;
    listen [::]:80;

    server_name zhi***.online www.zhi***.online;

    root /var/www/zhi***.online;
    index index.html index.htm;

    access_log /var/log/nginx/zhi***.online.access.log;
    error_log  /var/log/nginx/zhi***.online.error.log;

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

    # 静态资源长缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
}

逐行说几个容易含糊的点:

  • listen [::]:80; 单独一行,不是写 listen [::]:80 default。少了这行,IPv6 客户端(现在很多移动网络优先走 v6)会连不上,而 IPv4 一切正常——这种「部分用户打不开」最难查。
  • server_name 写两个。www 和裸域在 DNS 上是两条记录,Nginx 上也必须显式列出,否则访问 www. 会落到默认 server。注意这里没有配跳转,两者各回各的,后续应该统一到一个(见第五节)。
  • try_files $uri $uri/ =404:先找文件,再找同名目录(触发目录下的 index),都没有就返回 404。不要写成 try_files $uri /index.html——那是 SPA 的写法,静态站这么做会让所有不存在的 URL 都返回 200,搜索引擎会收录一堆垃圾页。
  • 静态资源那条 location ~* 用了不区分大小写的正则匹配,优先级在普通前缀匹配 location / 之上。immutable 是告诉浏览器「这个 URL 的内容永不变化」,配合文件名带 hash 的构建产物才安全;如果你的文件名不带 hash,把 immutable 去掉,只留 expires 30d。
  • 日志按站点分开写。默认的 access.log 混着所有站点,出了问题先得用 grep 筛域名,很别扭。

二、启用、测试、重载

# 启用(软链接,用绝对路径,别用相对路径)
sudo ln -s /etc/nginx/sites-available/zhi***.online /etc/nginx/sites-enabled/

# 语法测试
sudo nginx -t

# 重载
sudo systemctl reload nginx

三个动作的顺序不能换,尤其是「先 -t 再 reload」——nginx -t 失败时 reload 也会失败,但更糟的情况是配置改坏了而你没测,reload 直接拒绝生效,你还以为上线了。

nginx -t 的正常输出:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

reload 和 restart 的区别值得记一下:

动作实际行为什么时候用
reload发 SIGHUP,master 校验配置后新起 worker、老 worker 处理完存量连接再退出改配置的默认选择,连接不断
restart停掉整个进程再启动,瞬间拒绝所有连接换二进制、改 worker_processes 等无法热加载的项

reload 之后老 worker 会优雅退出,所以那一刻 ps 里能同时看到两批 worker,属于正常现象。真想确认配置生效,看进程启动时间:

systemctl show nginx -p ActiveEnterTimestamp

三、证书文件放哪、权限怎么设

证书是从腾讯云控制台申请签发的,下载下来是 Nginx 格式的压缩包,解压后有三个文件:

zhi***.online_bundle.crt   # 证书链(站点证书 + 中间 CA)
zhi***.online.key          # 私钥
zhi***.online.csr          # 申请文件,服务器上不需要

统一放到 /etc/nginx/ssl/ 下,按域名建目录——一台机器挂多个站点时,这个习惯能省掉「哪张证书对应哪个站」的混乱:

sudo mkdir -p /etc/nginx/ssl/zhi***.online_nginx
sudo cp zhi***.online_bundle.crt zhi***.online.key /etc/nginx/ssl/zhi***.online_nginx/

# 私钥只给 root 读
sudo chmod 600 /etc/nginx/ssl/zhi***.online_nginx/zhi***.online.key
sudo chmod 644 /etc/nginx/ssl/zhi***.online_nginx/zhi***.online_bundle.crt
sudo chown -R root:root /etc/nginx/ssl

两个细节:

  • bundle.crt 不是单张证书,它把站点证书和中间 CA 按顺序拼在了一起。只传站点证书、漏掉中间证书,浏览器会报「证书链不完整」,PC 上 Chrome 可能靠缓存补全而看起来没事,手机上直接红警告。用 grep -c "BEGIN CERTIFICATE" bundle.crt 数一下,正常是 2 或 3。
  • 私钥权限 600 就够。Nginx 的 master 进程以 root 启动,能读;worker 降权到 www-data 也不影响,因为证书是 master 在启动时读取并放进内存的。设成 644 也能跑,但等于把私钥给全机器所有用户看了一遍。

顺带一提:/etc/nginx 整个目录值得用 Git 管起来。

cd /etc/nginx && sudo git init && sudo git add -A && sudo git commit -m "baseline"

以后每次改配置前 git diff 一下,出了事 git checkout 就回来了——比 cp xxx.bak 这种满目录 .bak 文件清爽得多。

四、补上 443:追加 server 块

这次踩的坑:tee -a 会重复追加

当时用的命令是:

sudo tee -a /etc/nginx/sites-available/zhi***.online > /dev/null << 'EOF'
server {
    listen 443 ssl;
    ...
}
EOF

tee -a 是追加,不是覆盖。第一次执行没问题;如果后来重跑了一次(改配置时很常见),文件里就有两个 server_name 相同的 server 块,nginx -t 会给出这样的警告:

nginx: [warn] conflicting server name "zhi***.online" on 0.0.0.0:443, ignored

「ignored」意味着后写入的那一份会被丢弃——你改的东西完全没生效,但配置测试是「成功」的。这个坑安静得可怕。

正确姿势是用编辑器改文件,或者先确认文件里的块数:

grep -c "listen 443" /etc/nginx/sites-available/zhi***.online   # 期望 1

如果已经重复了,把文件拆回两个干净的 server 块。顺手记一条:tee -a 只适合「往日志尾部塞一行」,不适合往配置文件里塞结构。

干净的配置

一个文件里两个 server 块:80 负责跳转,443 负责服务。

# ---------- HTTP:只做跳转 ----------
server {
    listen 80;
    listen [::]:80;
    server_name zhi***.online www.zhi***.online;

    # 保留 ACME 挑战路径,方便以后换成自动续期
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# ---------- HTTPS:真正干活 ----------
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name zhi***.online www.zhi***.online;

    root /var/www/zhi***.online;
    index index.html index.htm;

    ssl_certificate     /etc/nginx/ssl/zhi***.online_nginx/zhi***.online_bundle.crt;
    ssl_certificate_key /etc/nginx/ssl/zhi***.online_nginx/zhi***.online.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options SAMEORIGIN always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    # 别在响应头里报名自己的版本号
    server_tokens off;

    access_log /var/log/nginx/zhi***.online.access.log;
    error_log  /var/log/nginx/zhi***.online.error.log;

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

    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
}

值得解释的几处:

  • http2 写在 listen 后面(listen 443 ssl http2;)是 Nginx 1.25.1 之前的语法;从 1.25.1 起官方推荐独立的 http2 on; 指令,旧写法在这个版本之后会被标记为 deprecated(1.24 及更早照常用旧写法,不会报错)。所以先 nginx -v 看版本再抄:1.24 及以下用 listen 443 ssl http2,1.25.1 及以上改成 listen 443 ssl; + http2 on;。
  • ssl_protocols 只留 TLSv1.2 / 1.3。默认值里可能还带 TLSv1.1,那是 2021 年就被主流浏览器判死刑的协议。
  • ssl_prefer_server_ciphers off 是反直觉的:让客户端选密码套件,因为现代客户端的偏好排序通常比服务端配置更准,且能享受客户端的硬件优化。只有在必须强制某套件(比如合规要求国密)时才开。
  • ssl_session_cache shared:SSL:10m 让多个 worker 共享会话票据,10MB 大约能存 4 万个会话。不加这行,每个 worker 各自缓存,回访用户经常要重新完整握手,移动端感知很明显。
  • always 后缀让安全响应头在 4xx/5xx 上也照发。不带 always 时,Nginx 在错误页里会丢掉这些头——攻击者最喜欢用错误页做探测。
  • server_tokens off 把 Server: 响应头从 nginx/1.24.0 (Ubuntu) 改成 nginx。这条比藏 ssl_ciphers 有意义得多:密码套件摊在明面上无所谓(见文首说明),但版本号会直接被扫描器拿去匹配 CVE 列表。顺便说一句,server_tokens off 不影响 nginx -v 和错误页——错误页里的版本号是另一处,真要去干净得靠 error_page 重定向,静态站一般不值得。

五、80 和 www 的收敛(当时没做,现在补上)

严格说这一节是事后补的,但它是同一件事的收尾:一个站点只应该有一个规范地址。

上面的配置已经做了 80 → 443 的 301,但 www 和裸域还是各回各的、都返回 200。结果是同一个页面有四个可访问地址:

http://zhi***.online        → 301 → https://zhi***.online
http://www.zhi***.online    → 301 → https://www.zhi***.online
https://zhi***.online       → 200
https://www.zhi***.online   → 200

对搜索引擎来说是两份重复内容,对你自己来说是两套缓存、两份统计。选一个当主域名(静态站在裸域上省一次跳转,我选裸域),另一个收敛成跳转。

这里有个前提必须先确认:上面那个 443 主块的 server_name 同时写了裸域和 www。收敛有两种写法,选哪种取决于你要不要动这一行——选错了会直接撞上第四节那个 conflicting server name。

写法 A:独立 server 块(更干净)

前提:把主 443 块的 server_name 改成只剩裸域,www 从主块里移除。

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name www.zhi***.online;

    ssl_certificate     /etc/nginx/ssl/zhi***.online_nginx/zhi***.online_bundle.crt;
    ssl_certificate_key /etc/nginx/ssl/zhi***.online_nginx/zhi***.online.key;

    return 301 https://zhi***.online$request_uri;
}

写法 B:主块内 server 级 if(我线上用的)

不想多一个 server 块,就保留 server_name 里的 www,在主 443 块里加三行:

if ($host = www.zhi***.online) {
    return 301 https://zhi***.online$request_uri;
}

if 放在 server 级、里面只有 return,属于 rewrite 阶段,每个请求只执行一次,行为是确定的。(Nginx 社区那句「if is evil」针对的是在 if 里放 proxy_pass、try_files 这类内容处理指令,不是否定所有 if。)

两种写法不能同时用

主块 server_name 已经含 www,再新建一个 server_name www.zhi***.online 的 443 块,nginx -t 只会给一条警告:

nginx: [warn] conflicting server name "www.zhi***.online" on 0.0.0.0:443, ignored

后一个块被静默丢弃。跳转看起来配好了,实际完全没生效——和第四节 tee -a 那个坑是同一个机制。所以要么从主块删 www 用写法 A,要么保留 www 用写法 B,二者选一。

注意 $request_uri 而不是 $uri:$request_uri 保留原始路径和查询串(/a?b=1),$uri 是解码并规范化之后的路径,查询串会丢。这个细节在带参数的分享链接上会直接暴露。

301 会被浏览器长期缓存(Chrome 甚至缓到磁盘),配错了很难回退——调试阶段先用 302,确认无误再改 301。

六、后来又补的四件事

站点能打开只是及格线。下面这四处都是「实际访问出了问题」之后补的,每一处都对应一个不明显的机制。

1. 自定义 404:状态码和页面是两件事

try_files $uri $uri/ =404 只是返回 404 状态码,页面还是 Nginx 自带的那个白底黑字版本。要换成站点自己的:

error_page 404 /404.html;

location = /404.html {
    internal;
}

internal 不能省。少了它,用户可以直接访问 /404.html,拿到一个状态码 200 的「页面不存在」页——搜索引擎会把它当正常内容收录,等于凭空多一个重复页。

另一个反直觉的细节:error_page 404 /404.html 是内部跳转,浏览器地址栏不变,返回的仍是 404 状态码。用 curl -o /dev/null -w '%{http_code}' 验证时看到 404 才对,看到 200 说明写成了 rewrite 或 return。

2. 同一份内容只留一个 URL

第五节处理了 www,还剩 /index.html:/ 和 /index.html 都能打开首页,是两个地址、两份索引。最直觉的写法是加一条精确匹配:

location = /index.html {
    return 301 /;
}

这个写法会死循环。 因为 try_files $uri/ 命中目录后会内部跳转到 /index.html,又触发这条 301 跳回 /,首页 301 到自己,浏览器报 ERR_TOO_MANY_REDIRECTS。我在这里卡了一次,症状是首页打不开、日志里一串 301。

正确姿势是用 server 级判断——它只在 server-rewrite 阶段执行一次,try_files 的内部跳转不会再进入这个阶段:

if ($request_uri ~ "^/index\.html$") {
    return 301 /;
}

3. gzip:别看响应头,看字节数

静态站 90% 的字节是 HTML / CSS / JS,开 gzip 通常能省 60% 以上。实测首页 11022 → 4188 字节:

gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json
           application/xml image/svg+xml;

三个点:

  • text/html 不用写进 gzip_types。Nginx 默认就对它压缩,重复写会触发 duplicate MIME type 警告
  • gzip_min_length 1024 是止损线。几百字节的响应压完可能更大,gzip 头本身就有开销
  • gzip_vary on 输出 Vary: Accept-Encoding,否则 CDN 或中间代理可能把压缩版发给不支持的客户端

验证要看实际字节数,不是看响应头里有没有 content-encoding:

curl -s -o /dev/null -w 'gzip=%{size_download}\n' -H 'Accept-Encoding: gzip' https://<domain>/
curl -s -o /dev/null -w 'plain=%{size_download}\n' https://<domain>/

4. 缓存:/_astro/ 那条规则其实没生效

构建产物 /_astro/about.9CU-ITdI.css 文件名带 hash,内容一变文件名就变,可以永久缓存。于是我加了:

location /_astro/ {
    expires 1y;
}

结果实测只拿到 30 天:

cache-control: max-age=2592000

原因是正则 location 优先于普通前缀 location。文件下面还有一条 location ~* \.(css|js|woff2?|ttf|eot)$,.css 先被正则抢走,/_astro/ 从头到尾没命中过。

^~ 就是为这种情况准备的:前缀匹配命中后直接采用,跳过后续所有正则。

location ^~ /_astro/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    access_log off;
}

注意这里没有写 expires。expires 1y 本身就会输出一个 Cache-Control: max-age=31536000,再 add_header Cache-Control 会追加出第二个同名头,两个 Cache-Control 并存时浏览器取哪个是不确定的。要用 immutable 就只用 add_header 把完整值写全。

顺带记一条 /api/ 的:走反代时必须把真实 IP 传过去。

location /api/ {
    proxy_pass http://127.0.0.1:8787;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

漏了 X-Real-IP,服务端看到的所有访客都是 127.0.0.1。如果它按 IP 做去重,所有人共用一个 key,计数永远只加一次——这种 bug 不报错、不打日志,只表现为「数字不动」。

七、验证:四层,从里往外

# 第 1 层:端口在听吗
sudo ss -tlnp | grep -E ':(80|443)'

# 第 2 层:本机 HTTP 通吗
curl -sI http://127.0.0.1 -H 'Host: zhi***.online' | head -5

# 第 3 层:TLS 握手和证书对不对(-k 跳过校验只为看握手)
curl -sI -k https://127.0.0.1 -H 'Host: zhi***.online' | head -5

# 第 4 层:证书链和域名是否匹配(不看返回码,只看证书)
openssl s_client -connect 127.0.0.1:443 -servername zhi***.online </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

那天的实际结果是:443 正常监听,本地请求返回 200 OK,ss 里能看到 nginx: worker process 占着 443。本机层面全部通过。

但用手机 4G 访问公网 IP,转圈到超时。这就是下一节。

如果第 4 层用 openssl 看,重点核对三样:subject 里的 CN/SAN 是否含你的域名、issuer 是不是中间 CA 而不是根 CA、notAfter 的到期时间。

八、本机能通、外网不通:按顺序排查

这个现象只有三种可能,按「离你最近的开始」排:

层检查命令通过的表现
1. 进程是否监听ss -tlnp | grep :443有 LISTEN,进程是 nginx
2. 主机防火墙sudo ufw status / sudo firewall-cmd --list-ports443 已放行,或防火墙未启用
3. 云平台安全组控制台 → 轻量应用服务器 → 防火墙有 TCP:443 允许规则
4. 域名解析dig +short zhi***.online返回的 IP 与服务器一致

当时的 ufw status 是 inactive,第 1、2 层都没问题——问题必然在第 3 层。这是排查这类问题的好用思路:先用排除法把范围压到一个,再动手,比在服务器上反复重启 Nginx 高效得多。

腾讯云轻量的操作路径:控制台 → 轻量应用服务器 → 选中实例 → 防火墙 → 添加规则:

字段值
应用类型自定义
协议TCP
端口443
来源0.0.0.0/0(全部)
策略允许

保存即生效,不用重启服务器。之后 https://1.*.*.* 就通了。

这里有个容易混淆的点:轻量应用服务器的「防火墙」和 CVM 的「安全组」是两个东西。轻量用的是简化版的防火墙页面,配置逻辑跟安全组一样但入口不同,照着 CVM 的教程找会找不到。而且轻量的防火墙规则默认不含 443,只开了 22/80/3389 这类常用端口——所以「装了证书、配了 Nginx、本机 200」,外网照样连不上。

九、用 IP 访问报证书错误,是正常的

配置完第一次用 https://1.*.*.* 打开,浏览器一定会红:

NET::ERR_CERT_COMMON_NAME_INVALID

原因很简单:证书是签发给 zhi***.online 的,subject 和 SAN 里根本没有 IP。TLS 握手时客户端拿「我访问的地址」去比对证书里的域名,比不中就报错——这不是配置错了,是证书本来就不该这么用。

真正的解决办法是用域名访问,而域名访问在国内服务器上又卡在备案:未备案的域名解析到境内服务器,80/443 会被运营商侧拦截。所以顺序是:

  1. 备案通过前:只能用 IP + 忽略证书错误访问(自己看内容够用)
  2. 备案通过后:https://zhi***.online 直接打开,无警告

补一条实测(2026-11 更新)。未备案被拦的表现比「连不上」刁钻得多:

http://zhi***.online/...     → 302 → dnspod.qcloud.com/static/webblock.html
https://zhi***.online        → 连接被直接切断
https://1.*.*.*(不带 SNI)  → 200 正常

也就是说 80/443 端口本身是通的,被拦的是 Host / SNI。这个区别直接决定证书续期方案:Let’s Encrypt 的 HTTP-01(往 80 端口放验证文件)和 TLS-ALPN-01(在 443 握手阶段校验)都会被这道拦截挡掉,验证必然失败,还会白白消耗失败配额。唯一可行的是 DNS-01——在 DNSPod 加一条 TXT 记录完成验证,请求根本不经过 80/443,具体命令在下一节。

想彻底消掉 IP 访问的警告,只能给 IP 单独签一张证书(部分 CA 支持 IP SAN,通常要付费),对一个静态博客完全不值得。加个 -k 或者点「继续前往」就行。

十、收尾:四件没做但该做的事

站点能用不等于可以不管了。以下四条按紧急程度排:

1. 证书续期(最紧急)

免费证书有效期通常 90 天(腾讯云免费 DV 就是 3 个月),过期那天全站直接不可用,而且是浏览器级的红屏,比 502 更难解释。腾讯云控制台有到期提醒,但别只依赖它——服务通知邮件很容易漏。

告警只是止损,真正省事的是自动续期。备案没下来之前只能走 DNS-01(原因见上一节):

# 1. 装 acme.sh(GitHub 拉不动时用 gitee 镜像)
git clone https://gitee.com/neilpang/acme.sh.git
cd acme.sh && ./acme.sh --install -m you@example.com

# 2. 密钥单独放文件,别写在命令行里(否则进 shell history)
cat > /etc/acme.sh/dnspod.env <<'EOF'
DP_Id="你的ID"
DP_Key="你的Key"
EOF
chmod 600 /etc/acme.sh/dnspod.env

# 3. 签发,证书直接写回 Nginx 现有路径,配置一行不用改
/etc/acme.sh/acme.sh --issue --dns dns_dnspod \
  -d zhi***.online -d www.zhi***.online \
  --key-file       /etc/nginx/ssl/zhi***.online_nginx/zhi***.online.key \
  --fullchain-file /etc/nginx/ssl/zhi***.online_nginx/zhi***.online_bundle.crt \
  --reloadcmd "systemctl reload nginx"

三个必须注意的点:

  • --reloadcmd 不能省。续期成功后证书文件换了,但 Nginx 只在启动时把证书读进内存,不 reload 就还在用旧证书——文件是新的、握手是旧的,这种状态最难判断
  • -d 要把 www 一起带上(前提是证书需要覆盖 www)。漏了它,续期后 www 直接掉证书
  • 用 --fullchain-file 而不是 --cert-file 写 bundle.crt。只写站点证书会丢中间证书,就是第三节那个「证书链不完整」

如果暂时不想动 DNS API,也可以只做告警,cron 仍然值得留着:

# /etc/cron.daily/check-cert  (记得 chmod +x)
#!/usr/bin/env bash
set -euo pipefail

DOMAIN="zhi***.online"
END=$(openssl s_client -connect 127.0.0.1:443 -servername "$DOMAIN" </dev/null 2>/dev/null \
      | openssl x509 -noout -enddate | cut -d= -f2)
LEFT=$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 ))

if [ "$LEFT" -lt 30 ]; then
    logger -t check-cert "WARN: ${DOMAIN} 证书 ${LEFT} 天后到期"
    echo "WARN: ${DOMAIN} 证书 ${LEFT} 天后到期"
fi

两个细节:/etc/cron.daily/ 下的脚本必须有可执行位,否则 cron 会静默跳过;结尾用 if 而不是 [ ... ] && echo——在 set -e 下,当条件不成立时 && 整句返回非 0,脚本会以失败状态退出,看起来像「检查脚本挂了」。

顺手接入前面那套拨测的「证书剩余天数」监控,两个渠道互为备份。

2. 配置备份

/etc/nginx 没进版本控制,改错一次就得凭记忆回滚。第三节提的 git init 现在做,只要一分钟。

3. HSTS

让浏览器在指定时间内强制走 HTTPS,防住 SSL Strip 这类降级攻击:

add_header Strict-Transport-Security "max-age=31536000" always;

但先别急着开 includeSubDomains 和 preload。HSTS 会被浏览器记住,一旦某个子域只有 HTTP,开了 HSTS 就等于把它废了,而且回退要等到期。先用 max-age=300 观察一周。

4. 日志轮转

Nginx 包默认装了 logrotate 配置(/etc/logrotate.d/nginx),但自定义的日志路径不在默认规则里。上面我把日志写到了 /var/log/nginx/zhi***.online.access.log,如果哪天访问量上来,这个文件会一直长。检查并补一条:

ls /etc/logrotate.d/nginx && grep -n "<domain>" /etc/logrotate.d/nginx || echo "需要补规则"

不补的后果是磁盘满,而磁盘满的表现是「Nginx 突然 500、SSH 登不上、数据库写不进去」——查起来又是另一轮折腾。

十一、这次操作的清单

留一份可复用的核对表,下次换机器照着走:

# 1. 文件到位
ls /var/www/<domain>/index.html

# 2. 写配置(80 + 443 两个 server 块,一个文件)
vim /etc/nginx/sites-available/<domain>

# 3. 放证书
mkdir -p /etc/nginx/ssl/<domain>_nginx && chmod 600 /etc/nginx/ssl/<domain>_nginx/*.key

# 4. 启用
ln -s /etc/nginx/sites-available/<domain> /etc/nginx/sites-enabled/

# 5. 测试 + 重载
nginx -t && systemctl reload nginx

# 6. 本机验证
ss -tlnp | grep :443
curl -sI -k https://127.0.0.1 -H 'Host: <domain>' | head -3
openssl s_client -connect 127.0.0.1:443 -servername <domain> </dev/null 2>/dev/null | openssl x509 -noout -dates

# 7. 云控制台放行 443(最容易被忘的一步)

# 8. 外网验证
curl -sI https://<domain> | head -3
  • 站点根目录就位
  • 80 端口站点配置生效
  • 443 server 块追加,证书路径正确
  • nginx -t 通过,reload 完成
  • 本机 443 监听、本地返回 200
  • 安全组放行 TCP:443
  • 80 → 443 全量 301,www 收敛到裸域
  • 自定义 404 页(error_page + internal)
  • /index.html 收敛到 /
  • gzip 与静态资源长缓存(^~ 修正优先级)
  • 证书续期脚本(acme.sh DNS-01,待 DNSPod 密钥启用)
  • /etc/nginx 入 Git
  • 日志轮转补规则
  • HSTS(观察期结束后再开)

小结

整个过程没什么技术含量,值钱的是顺序:

  1. 先确认文件在,再谈 Nginx 认不认
  2. sites-available 写、sites-enabled 链接,启停就是建删软链接
  3. 改完必 nginx -t,再 reload 而不是 restart
  4. 本机自测只看应用层,ss + curl -H Host 能证明 Nginx 没问题
  5. 外网不通先排防火墙三层:进程 → 主机 → 云安全组,用排除法压范围
  6. IP 访问证书报错不是 bug,是证书本来就没有 IP SAN
  7. 能用之后立刻处理到期,证书过期是唯一「一定会发生且一定全站挂」的故障
  8. 缓存规则要按响应头验收,location 的优先级能让一条规则从头到尾没生效过

那天最花时间的不是配 Nginx,是「本机能通、手机不通」的那二十分钟。而最后发现的原因,是控制台里少点了一个「添加规则」。

运维里这类事情的比例比想象中高:命令都对,环境差一层。