Featured image of post 建站运维 01:个人博客搭建与部署全记录(Hugo + Stack + Nginx + 腾讯云)

建站运维 01:个人博客搭建与部署全记录(Hugo + Stack + Nginx + 腾讯云)

从零搭建 Hugo + Stack 博客并部署到腾讯云的全记录:nginx 缓存与安全头、HTTP 到 HTTPS 的证书制作与部署、云安全组踩坑。

环境:本地 macOS (M1 Pro, arm64) / 服务器 腾讯云 Rocky Linux 9.4 (82.157.171.92) 日期:2026-09-11 站点:https://saflyer.cn/(2026-09-20 ICP 备案通过后恢复域名主入口,IP 访问 301 到域名——过程见 4.9 节)


一、技术栈选型

层选型理由
静态生成器Hugo (v0.166.0, extended)当前最主流的静态博客框架之一,Go 编写、单二进制、毫秒级构建
主题Stack (v4.0.3)卡片式设计、中文博客圈最流行的高颜值 Hugo 主题,自带搜索/归档/标签云/深色模式
Web 服务器Nginx 1.20静态站点标配,占资源少
部署方式scp 全量同步站点仅几百 KB,最简单可靠;文章多了可换 rsync 增量
自动化bash + expect密码登录场景下用 expect 模拟交互完成 ssh/scp

本地目录结构(/Users/wyf/work/boke/):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
boke/
├── 安全编排框架-4-Mac本地部署实操笔记.md   # 文章源文件(只改它)
├── deploy.sh                              # 一键部署脚本
├── tx.sh                                  # 服务器交互式登录
├── txrun.exp                              # 远程执行命令(expect)
├── txscp.exp                              # 上传文件(expect)
├── blog.conf                              # nginx 站点配置(本地备份)
└── blog/                                  # Hugo 站点工程
    ├── hugo.toml                          # 站点配置
    ├── assets/img/                        # 头像、封面(参与构建处理)
    ├── static/img/                        # 原样拷贝的静态文件(OG 图)
    ├── content/
    │   ├── _index.md                      # 首页菜单项
    │   ├── posts/joysafety-mac-deploy/    # 文章(index.md + cover.png 页面包)
    │   └── page/                          # 归档/搜索/关于/友情链接
    ├── layouts/_partials/                 # 主题局部模板覆盖(OG 图、自定义页脚)
    └── themes/stack/                      # Stack 主题

二、关键操作记录

2.1 建站

1
2
3
hugo new site blog
cd blog && git init
git clone --depth 1 https://github.com/CaiJimmy/hugo-theme-stack themes/stack

注意点:

  • Stack v4 要求 Hugo ≥ 0.146(当前 min_version 0.157),版本不够先 brew upgrade hugo
  • 主题配置参考 themes/stack/demo/config/_default/ 下的示例,比文档更准
  • 文章带封面图时要用页面包结构(posts/xxx/index.md + cover.png),front matter 里 image: "cover.png"
  • 头像等图片放 assets/img/(会被 Hugo 处理);需要固定 URL 的图(如 og:image)放 static/img/

2.2 部署流水线(deploy.sh 的逻辑)

1
2
3
同步文章(给 md 补上 front matter:title/tags/categories/image/description/lastmod)
    → hugo --gc --minify(构建到 public/)
    → expect + scp 全量覆盖 /var/www/blog/

lastmod 取 md 文件的修改时间自动写入(供 RSS/sitemap 使用);后来为避免暴露私有备份仓库地址,页面端已去掉「最后更新于」与修订历史入口。

2.3 expect 自动化的两个坑

  • expect 脚本里密码含 +*@ 等特殊字符,要用 set password {密码} 花括号包裹,防止被当正则/变量解析
  • ssh 首次连接的 yes/no 确认,用 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 直接跳过

2.4 服务器侧初始化(一次性)

1
2
3
4
dnf install -y nginx
mkdir -p /var/www/blog
# 上传 nginx 配置后:
nginx -t && systemctl enable --now nginx

SELinux 注意:文件从 home 目录拷入 web 根目录后要 restorecon -R /var/www/blog 恢复安全上下文,否则 403。


三、Nginx 配置详解(/etc/nginx/conf.d/blog.conf)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    root /var/www/blog;
    index index.html;

    error_page 404 /404.html;

    # HTML 不缓存:更新后立即生效(踩过浏览器拿旧页面的坑)
    location ~* \.html$ {
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options DENY always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        add_header Cache-Control "no-cache" always;
        try_files $uri $uri/ $uri/index.html =404;
    }

    # 静态资源长缓存:Hugo 构建的 CSS/JS 文件名带哈希指纹,内容变文件名就变
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?|webp)$ {
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options DENY always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        add_header Cache-Control "public, max-age=2592000, immutable" always;
        try_files $uri =404;
    }

    location / {
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options DENY always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        try_files $uri $uri/ $uri/index.html =404;
    }

    gzip on;
    gzip_comp_level 6;
    gzip_min_length 1k;
    gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml;
}

两个关键经验:

  1. nginx 的 add_header 继承陷阱:location 块里一旦定义了自己的 add_header,server 级的所有 add_header 对该 location 全部失效。所以安全响应头必须在每个 location 里都写一遍。
  2. Rocky/CentOS 系 nginx.conf 自带默认 server 块:会和 conf.d 里的 default_server 冲突(报 conflicting server name "_")。解法:编辑 /etc/nginx/nginx.conf,删掉自带的 server { listen 80; ... } 块。

四、从 HTTP 迁移到 HTTPS(实际执行全记录)

本节是 2026-09-11 的真实操作记录,含完整决策过程和踩坑。

4.1 决策过程:免费证书的三条路

方案是否免费浏览器信任前提结论
Let’s Encrypt 域名证书✅✅需要有域名✅ 本站当前方案(2026-09-11 晚切换,见 4.9)
Let’s Encrypt IP 证书✅✅2025 年起 CA/B 论坛允许 IP 证书实测失败,见 4.2
自签名证书✅❌(警告一次)无域名过渡期的方案,现为 IP 直连入口用证书(见 4.9)

4.2 踩坑:Let’s Encrypt 拒签纯 IP(实测)

政策上 IP 证书已经放开,于是先尝试正规路线:

1
2
3
4
5
6
dnf install -y epel-release
dnf install -y certbot python3-certbot-nginx   # 装到 certbot 3.1.0

certbot certonly --nginx --preferred-profile shortlived \
    -d 82.157.171.92 \
    --register-unsafely-without-email --agree-tos --non-interactive

结果直接被拒:

1
2
Requested name 82.157.171.92 is an IP address.
The Let's Encrypt certificate authority will not issue certificates for a bare IP address.

结论:虽然 CA/B 论坛 2025 年通过了 IP 证书议案,但 Let’s Encrypt 对公网 IP 的实际签发仍未开放(短时效 IP 证书只在特定 profile/试点场景提供)。没有域名,受信任证书这条路目前走不通,转自签名。

4.3 自签名证书制作(实际执行)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 1. 生成 2048 位 RSA 私钥
mkdir -p /etc/nginx/ssl
openssl genrsa -out /etc/nginx/ssl/blog.key 2048

# 2. 生成自签名证书(10 年有效;关键:-addext 加 SAN,否则 Chrome 不认 CN 字段)
openssl req -x509 -new -key /etc/nginx/ssl/blog.key \
    -out /etc/nginx/ssl/blog.crt -days 3650 \
    -subj "/C=CN/O=WYFBlog/CN=82.157.171.92" \
    -addext "subjectAltName=IP:82.157.171.92"

# 3. 私钥权限收紧(必做)
chmod 600 /etc/nginx/ssl/blog.key

验证证书内容:

1
2
3
4
$ openssl x509 -in /etc/nginx/ssl/blog.crt -noout -subject -dates
subject=C=CN, O=WYFBlog, CN=82.157.171.92
notBefore=Sep 11 05:59:29 2026 GMT
notAfter=Sep  8 05:59:29 2036 GMT    # 10 年有效期

为什么不加 SAN 不行:Chrome 58 起不再读证书的 CN 字段,只认 subjectAltName。IP 站点的 SAN 写法是 IP:x.x.x.x(域名则是 DNS:example.com)。

4.4 nginx 配置(本站当前生产配置)

80 和 443 双协议并行(原因见 4.5 的坑):80 直接提供访问,443 提供加密访问。等域名和受信任证书就位后,80 再改成 return 301 https://$host$request_uri;。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# 80 端口:直接提供访问
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    root /var/www/blog;
    index index.html;
    error_page 404 /404.html;
    location / { try_files $uri $uri/ $uri/index.html =404; }
    # (安全头 / 缓存策略 / gzip 与 443 块相同,从略)
}

# 443 端口:HTTPS 站点
server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;
    root /var/www/blog;
    index index.html;

    ssl_certificate     /etc/nginx/ssl/blog.crt;
    ssl_certificate_key /etc/nginx/ssl/blog.key;

    # TLS 加固:只留 1.2/1.3,开会话缓存
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    error_page 404 /404.html;

    location ~* \.html$ {
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options DENY always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        add_header Cache-Control "no-cache" always;
        try_files $uri $uri/ $uri/index.html =404;
    }

    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?|webp)$ {
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options DENY always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        add_header Cache-Control "public, max-age=2592000, immutable" always;
        try_files $uri =404;
    }

    location / {
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options DENY always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        try_files $uri $uri/ $uri/index.html =404;
    }

    gzip on;
    gzip_comp_level 6;
    gzip_min_length 1k;
    gzip_types text/plain text/css application/javascript application/json image/svg+xml application/xml;
}

注意:自签名场景不要加 HSTS 头——浏览器不信任证书时,HSTS 会把用户锁死在警告页。

4.5 踩坑:443 配置好了但外网不通 —— 云安全组

配置后服务器内部验证全部通过:

1
2
3
$ nginx -t && systemctl reload nginx        # 配置语法 OK
$ ss -tlnp | grep :443                       # nginx 已在 443 监听
$ curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1/   # 本机 200

但从外网访问 https://82.157.171.92/ 直接超时(curl 返回 000)。排查发现是腾讯云安全组(云端防火墙)只放行了 80,没放行 443——安全组是云平台层面的规则,在服务器操作系统里改 iptables/firewalld 都没用,只能去控制台加:

腾讯云控制台 → 轻量应用服务器 → 实例详情 → 防火墙 → 添加规则 → TCP 443 → 0.0.0.0/0

期间还顺手排查了服务器上一个可疑的 YJ-FIREWALL-INPUT iptables 链(内容是一批针对特定 IP 的 REJECT 规则,是云镜/主机安全组件的封禁名单),确认与本次无关。

另一个教训:最初把 80 配成 301 → 443 后外网立刻全站打不开(443 被安全组拦着)。所以在云安全组放行之前,绝不能把 80 跳转 443,双协议并行是正确的过渡姿势。

4.6 切换后验证清单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 服务器侧
nginx -t && systemctl reload nginx
ss -tlnp | grep :443

# 本机侧(自签名证书要加 -k 跳过校验)
curl -sk -o /dev/null -w '%{http_code}\n' https://82.157.171.92/   # 期望 200
curl -sI http://82.157.171.92/                                     # 确认 80 仍正常

# 查看对端证书信息
echo | openssl s_client -connect 82.157.171.92:443 -servername 82.157.171.92 2>/dev/null \
    | openssl x509 -noout -subject -dates

浏览器访问 https://82.157.171.92/ 会出现「不受信任」警告 → 点「高级 → 继续访问」即可,只提示一次。这是自签名证书的正常现象,传输本身已加密。

4.7 关于「密钥」的几个要点(含发布脱敏)

  • 私钥(blog.key / privkey.pem)是命根子:权限 600、属主 root;不进 git、不用 scp 明文传输、绝不出现在任何博文里(本篇只记录命令和路径,不贴任何密钥/证书内容)
  • 本文发布前已做脱敏检查:不含服务器密码、私钥内容、腾讯云密钥;IP 属于公开信息(本身就是站点地址)无需脱敏
  • 服务器登录密码只存在于本地 tx.sh / txrun.exp / txscp.exp,这些文件已在 .gitignore 级别上注意,不要提交到任何公开仓库
  • 以后换 Let’s Encrypt 后:续期由 certbot 定时器自动完成(certbot renew --dry-run 可演练);80 若做了 301,certbot 的 nginx 插件会自动保留 /.well-known/acme-challenge/ 验证通路

4.8 以后有域名了怎么办(10 分钟切换)

2026-09-11 晚已执行:域名 saflyer.cn 上线,以下是当时的预案,实际执行记录见 4.9 节。

  1. 域名 A 记录指向 82.157.171.92,确认安全组 443 已放行
  2. certbot --nginx -d blog.example.com --email you@example.com --agree-tos(自动改 nginx 配置、自动配续期)
  3. Hugo baseURL 改为 https://blog.example.com/,重跑 deploy.sh
  4. 把 80 端口 server 块改为 return 301 https://$host$request_uri;
  5. 可安全开启 HSTS:add_header Strict-Transport-Security "max-age=31536000" always;

4.9 域名上线实操:saflyer.cn(2026-09-11 晚)

按 4.8 预案执行,全程约 15 分钟。域名腾讯云注册 saflyer.cn,DNS 用 DNSPod 解析。

1. DNS 解析(DNSPod 控制台)

控制台 → DNS 解析 DNSPod → 域名 saflyer.cn → 添加两条记录:

主机记录记录类型记录值TTL
@A82.157.171.92600
wwwA82.157.171.92600

验证生效(1~10 分钟):

1
2
dig +short saflyer.cn        # 应返回 82.157.171.92
dig +short www.saflyer.cn    # 同上

知识点:@ 表示裸域名本身;www 只是普通子域名,和 blog.、api. 地位相同,是约定俗成而非必需。「带不带 www 都能访问」是因为三层都配了双名:DNS 两条记录、nginx server_name saflyer.cn www.saflyer.cn;、证书 SAN 同时含两个名字。

2. nginx 绑定域名

blog.conf 两个 server 块的 server_name _; 改为 server_name saflyer.cn www.saflyer.cn;,上传后 nginx -t && systemctl reload nginx。

3. Let’s Encrypt 证书:一条命令完成签发 + 部署

1
2
certbot --nginx -d saflyer.cn -d www.saflyer.cn \
    --register-unsafely-without-email --agree-tos --non-interactive

nginx 插件全自动完成:HTTP-01 验证(走 80 端口,无需停站,自动在 301 跳转前插入验证通路)→ 签发 → 改写 blog.conf(ssl_certificate 指向 /etc/letsencrypt/live/saflyer.cn/,80 块顶部加两个 # managed by Certbot 的 if 跳转块)→ reload。

证书有效期 90 天(至 2026-12-10),certbot 已注册 systemd 定时器自动续期。

踩坑:装完 certbot 后 certbot-renew.timer 状态是 enabled 但 inactive——enabled 只保证开机自启,服务器没重启过它就一直没跑(list-timers 里查无此项,systemctl status 显示 inactive (dead))。需手动 systemctl start certbot-renew.timer 启动一次,之后 systemctl list-timers certbot-renew.timer 可见下次触发时间(每天两次,证书剩余 30 天内才会真正续期)。certbot renew --dry-run 演练需访问 LE staging 服务器,国内网络下可能很慢,属正常现象。

4. 踩坑:IP 访问不跳转 —— nginx 的 default_server 回退

现象:改完 server_name 后,https://82.157.171.92/ 不跳转,直接返回站点内容。

原因:IP 访问的 Host 头匹配不到任何 server_name,nginx 回退到带 default_server 标记的块(即 443 站点块),于是直接响应内容;且 80 块里 return 301 https://$host$request_uri; 的 $host 取访问者输入的主机名,IP 访问会跳回 IP 自己。

解法:加两组兜底 server 块,Host 不是域名的一律 301 到域名(域名块相应去掉 default_server 标记):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 80 兜底:干净跳转,无警告
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 301 https://saflyer.cn$request_uri;
}

# 443 兜底:先用旧自签名证书完成握手,再跳转
server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;
    ssl_certificate     /etc/nginx/ssl/blog.crt;
    ssl_certificate_key /etc/nginx/ssl/blog.key;
    return 301 https://saflyer.cn$request_uri;
}

遗留限制:https://IP 跳转之前浏览器仍会弹一次自签名警告——TLS 握手发生在 301 之前,而 CA 不给裸 IP 签证书(见 4.2),无法彻底消除。只影响还在用旧 IP 链接的人,http://IP 则干净跳转。

5. Hugo 侧同步

  • hugo.toml:baseURL 改为 https://saflyer.cn/(RSS/sitemap/og:image 全部跟着变)
  • layouts/_markup/render-link.html:外链判断加入 saflyer.cn,避免站内链接被加新标签+外链图标
  • deploy.sh:完成提示改为域名,重跑一键部署

6. 验证矩阵

访问方式结果
http://82.157.171.92/301 → https://saflyer.cn/(无警告)
https://82.157.171.92/一次自签名警告后 301 → https://saflyer.cn/
http://saflyer.cn/301 → https://saflyer.cn/
https://saflyer.cn/、https://www.saflyer.cn/200,Let’s Encrypt 证书(issuer: YE1),无警告

curl -s https://saflyer.cn/(不带 -k)返回 200 即证书受系统信任。

7. 可选后续

  • 主域名统一:裸域与 www 二选一做 301,避免搜索引擎把两个地址当重复页面分散权重;本站 baseURL 用裸域,若统一则把 www 301 到裸域
  • HSTS:受信任证书已就位,可安全开启 add_header Strict-Transport-Security "max-age=31536000" always;

8. 后续变动:域名遭拦截,回退 IP 直连(2026-09-11 深夜)

域名上线数小时后访问突然全挂。排查证据:

测试结果
TCP 连接 82.157.171.92:443能连上
TLS 握手带 SNI=saflyer.cn一发送即 Connection reset by peer
同 IP 同端口、SNI 不带域名正常 200
http://域名(Host 头带域名)被拦(异常 302),数小时后间歇恢复 301
服务器本机自测 / fail2ban / nginx全部正常

结论:链路上的设备按域名精准拦截(TLS SNI / HTTP Host 检测),是境内云厂商对未备案域名的准入拦截特征;服务器与配置本身无问题。域名刚解析时检测系统有延迟所以能通,被发现后即遭拦截,且拦截规则刷新是逐步的(80 先恢复、443 仍被拦)。ICP 备案流程较长,决定暂回退 IP 直连;域名侧配置全部保留,备案通过后无需改配置即可恢复。

注意:「跳转域名失败再自动跳回 IP」在服务端做不到——拦截发生在 TLS 握手阶段,nginx 根本看不到该请求,只能由用户手动选择可用入口。

回退后的最终架构:blog.conf 共 4 个 server 块;站点公共配置抽为 /etc/nginx/blog-site-common.conf,被两个 443 块 include,避免重复维护(本地同名文件备份,已纳入 deploy.sh 的 git 备份清单)。

访问方式行为
http://82.157.171.92/301 → https://82.157.171.92/(不跳域名)
https://82.157.171.92/自签名证书,default_server 直接出站点
http://saflyer.cn/301 → https://saflyer.cn/(配置保留)
https://saflyer.cn/LE 证书正式站点(外部被拦期间仅服务器本机可达,已带 SNI 自测 200)

同步改动:hugo.toml 的 baseURL 改回 https://82.157.171.92/;certbot 自动续期不受影响(验证走 80 端口域名块,该块已保留)。

9. 后续变动二:ICP 备案通过,恢复域名主入口(2026-09-20)

备案审核通过,按"备案后恢复"预案把回退的三处原样改回,全程约 10 分钟:

  1. blog.conf 两个 IP 兜底块恢复为 return 301 https://saflyer.cn$request_uri;(443 块保留自签名证书仅用于握手,随后 reload nginx)
  2. hugo.toml 的 baseURL 改回 https://saflyer.cn/,重跑 deploy.sh(sitemap/RSS/canonical 全部跟随)
  3. 验证矩阵回到本节第 6 小节的状态:http://IP 干净 301 到域名;https://IP 一次自签名警告后 301;域名 443 不带 -k 直接 200

域名侧 nginx 配置、Let’s Encrypt 证书、certbot 续期全程未动、直接可用——回退时"配置全部保留,备案后无需改配置即可恢复"的设计兑现了价值。


四点五、接入评论系统 giscus(实际执行记录)

静态博客没有后端,评论数据寄存在第三方。选型结论:技术博客用 giscus(评论存在自己 GitHub 仓库的 Discussions 里),零成本、零后端、Stack 主题原生支持。

4.5.1 选型对比(为什么不用别的)

方案数据存哪访客门槛结论
giscus自己 GitHub 仓库的 DiscussionsGitHub 登录✅ 采用:零后端零成本,主题原生支持
utterancesGitHub IssuesGitHub 登录备选,思路同 giscus
Twikoo腾讯云开发可匿名想开放匿名评论再换,迁移成本低
WalineLeanCloud/自建可匿名要维护服务端,放弃
DisqusDisqus 云端可匿名❌ 国内访问不了

4.5.2 GitHub 侧操作(一次性,约 10 分钟)

  1. 建公开仓库 blog-comments(必须 Public,勾选 Add a README 让仓库非空)
  2. 仓库 Settings → Features → 勾选 Discussions(默认不开)
  3. 安装 giscus App:https://github.com/apps/giscus → Install → Only select repositories → 只授权 blog-comments
  4. 到 https://giscus.app/zh-CN↗ 生成参数:
    • 仓库填 用户名/blog-comments,出现 ✅ 才继续
    • Discussion 分类选 Announcements(只有仓库主能发起新帖,最干净)
    • 复制底部生成代码里的 4 个值:data-repo / data-repo-id / data-category / data-category-id

说明:repo-id 和 category-id 是公开标识符(本来就嵌在网页源码里),不属于敏感信息,可以出现在本文中——本文也只做占位化展示。

4.5.3 Hugo 侧配置(hugo.toml)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
[params.comments]
  enabled = true
  provider = 'giscus'
  [params.comments.giscus]
    repo = '<用户名>/blog-comments'
    repoID = '<你的 repo-id>'
    category = 'Announcements'
    categoryID = '<你的 category-id>'
    mapping = 'pathname'        # 每篇文章按路径对应一条 Discussion
    strict = 0
    reactionsEnabled = 1        # 允许表情点赞
    emitMetadata = 0
    inputPosition = 'bottom'
    lightTheme = 'light'        # 跟随博客明/暗双主题
    darkTheme = 'dark_dimmed'
    lang = 'zh-CN'
    loading = 'lazy'

填完重跑 deploy.sh 即可,无需改任何代码。

4.5.4 日常使用与运维

  • 发评论:访客在文章页底部用 GitHub 登录后评论(首条评论自动建 Discussion 帖)
  • 管理评论:在 GitHub 仓库的 Discussions 页面删帖/锁帖/置顶,改动实时同步到博客
  • 验证:curl 文章页 HTML 里应有 giscus.app/client.js 和正确的 data-repo
  • 不想用某篇文章的评论:该文章 front matter 加 comments: false

五、日常运维速查

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 更新文章(本地)
/Users/wyf/work/boke/deploy.sh

# 服务器侧
nginx -t                          # 改配置后必做
systemctl reload nginx            # 平滑重载(不断连)
tail -f /var/log/nginx/access.log # 访问日志
tail -f /var/log/nginx/error.log  # 错误日志
certbot renew                     # 手动续期证书(一般不需要,有定时器)

# 本地预览(改主题/配置时先本地看效果)
cd /Users/wyf/work/boke/blog && hugo server -D   # http://localhost:1313/

六、遗留事项

  • GitHub 社交链接占位 已改为真实地址 github.com/iyongfei
  • 未接评论系统 已接入 giscus(见 4.5 节),评论存于 GitHub Discussions
  • HTTPS 未启用 自签名证书已部署(见第四章),443 已放行,80 已 301 跳转
  • 有域名后切换 Let’s Encrypt 受信任证书 saflyer.cn 已上线,LE 证书已部署(见 4.9 节)
  • saflyer.cn 完成 ICP 备案后恢复域名为主入口(4.9 节第 8 条;域名配置已保留,届时只需把 baseURL 改回域名)
  • 主域名统一:www 与裸域二选一 301(4.9 节第 7 条,备案后处理)
  • 开启 HSTS(4.9 节第 7 条,备案后处理)

七、上线后的持续优化记录(2026-09-11 下午)

7.1 部署流水线升级

  • rsync 增量部署替代 scp 全量覆盖:rsync -az --delete,只传变化文件,秒级完成
  • 文章自动扫描:deploy.sh 不再硬编码文章,自动扫描目录所有 md;元数据登记在 articles.conf,内部文档(如周报)用 articles.ignore 排除
  • Git 自动备份:每次部署自动提交并推送到 GitHub 私有仓库;.gitignore 隔离含密码的 expect 脚本;仓库级 git 身份与公司 GitLab 全局配置隔离

7.2 传输性能

  • HTTP/2:listen 443 ssl http2;(注意 nginx 1.25 以下写法的 http2 在 listen 里,1.25+ 才是 http2 on;),实测 ALPN 协商到 h2
  • Brotli 压缩:dnf install nginx-mod-brotli(EPEL),brotli on; 比 gzip 再省 15~20%,实测 content-encoding: br。踩坑:dnf 包已自动在 /usr/share/nginx/modules/mod-brotli.conf 加载模块,手动再写 load_module 会报重复加载
  • 图片 WebP:头像 53KB→11KB(-80%),封面全转 WebP;og:image 保留 PNG 保证微信/微博分享兼容
  • 资源提示:主 CSS 加 preload;giscus/不蒜子等外部域名加 preconnect/dns-prefetch

7.3 自建访问量统计

不蒜子只能统计「当前页面」,无法实现列表页显示每篇文章阅读量。自建方案:

1
浏览器 JS → nginx /api/counter 反代 → Python 计数服务(127.0.0.1:8901) → counter.json

50 行 Python 标准库实现,systemd 托管,详情页计数、列表页批量只读查询,sessionStorage 防刷新刷量。详见独立文章《50 行 Python 给静态博客加访问量统计》。

7.4 安全防护

  • 排查发现服务器 24 小时内遭 397 次 SSH 密码爆破
  • 部署 fail2ban(10 分钟失败 5 次封 24 小时),踩坑:Rocky 9 的 SSH 日志在 systemd journal 需 backend = systemd
  • 详见独立文章《服务器防爆破实战:fail2ban 部署全记录》

7.5 交互与视觉(主题定制)

  • 特效:粒子星空背景、多句轮换打字机签名、标题渐变流光、卡片入场/3D 倾斜、封面视差滚动、暗色切换圆形扩散(View Transitions API)、点击飘字
  • 功能:阅读进度条、悬浮迷你目录、代码行号、图片 Lightbox、外链自动新标签+图标、文末分享卡、终端风关于页
  • 实现方式:Stack 主题的 assets/scss/custom.scss + layouts/_partials/footer/custom.html + 模板覆盖,不改主题源码,主题升级不丢
  • 踩坑:主题主色变量是 --accent-color 不是 --accent,写错会导致按钮白字白底「隐形」
使用 Hugo 构建
主题 Stack 由 Jimmy 设计