环境:本地 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/):
| |
二、关键操作记录
2.1 建站
注意点:
- 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 的逻辑)
lastmod 取 md 文件的修改时间自动写入(供 RSS/sitemap 使用);后来为避免暴露私有备份仓库地址,页面端已去掉「最后更新于」与修订历史入口。
2.3 expect 自动化的两个坑
- expect 脚本里密码含
+*@等特殊字符,要用set password {密码}花括号包裹,防止被当正则/变量解析 - ssh 首次连接的
yes/no确认,用-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null直接跳过
2.4 服务器侧初始化(一次性)
SELinux 注意:文件从 home 目录拷入 web 根目录后要 restorecon -R /var/www/blog 恢复安全上下文,否则 403。
三、Nginx 配置详解(/etc/nginx/conf.d/blog.conf)
| |
两个关键经验:
- nginx 的 add_header 继承陷阱:location 块里一旦定义了自己的
add_header,server 级的所有add_header对该 location 全部失效。所以安全响应头必须在每个 location 里都写一遍。 - 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 证书已经放开,于是先尝试正规路线:
结果直接被拒:
结论:虽然 CA/B 论坛 2025 年通过了 IP 证书议案,但 Let’s Encrypt 对公网 IP 的实际签发仍未开放(短时效 IP 证书只在特定 profile/试点场景提供)。没有域名,受信任证书这条路目前走不通,转自签名。
4.3 自签名证书制作(实际执行)
| |
验证证书内容:
为什么不加 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;。
| |
注意:自签名场景不要加 HSTS 头——浏览器不信任证书时,HSTS 会把用户锁死在警告页。
4.5 踩坑:443 配置好了但外网不通 —— 云安全组
配置后服务器内部验证全部通过:
但从外网访问 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 切换后验证清单
| |
浏览器访问 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 节。
- 域名 A 记录指向 82.157.171.92,确认安全组 443 已放行
certbot --nginx -d blog.example.com --email you@example.com --agree-tos(自动改 nginx 配置、自动配续期)- Hugo
baseURL改为https://blog.example.com/,重跑deploy.sh - 把 80 端口 server 块改为
return 301 https://$host$request_uri; - 可安全开启 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 |
|---|---|---|---|
| @ | A | 82.157.171.92 | 600 |
| www | A | 82.157.171.92 | 600 |
验证生效(1~10 分钟):
知识点:@ 表示裸域名本身;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 证书:一条命令完成签发 + 部署
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 标记):
| |
遗留限制: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 分钟:
blog.conf两个 IP 兜底块恢复为return 301 https://saflyer.cn$request_uri;(443 块保留自签名证书仅用于握手,随后 reload nginx)hugo.toml的baseURL改回https://saflyer.cn/,重跑deploy.sh(sitemap/RSS/canonical 全部跟随)- 验证矩阵回到本节第 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 仓库的 Discussions | GitHub 登录 | ✅ 采用:零后端零成本,主题原生支持 |
| utterances | GitHub Issues | GitHub 登录 | 备选,思路同 giscus |
| Twikoo | 腾讯云开发 | 可匿名 | 想开放匿名评论再换,迁移成本低 |
| Waline | LeanCloud/自建 | 可匿名 | 要维护服务端,放弃 |
| Disqus | Disqus 云端 | 可匿名 | ❌ 国内访问不了 |
4.5.2 GitHub 侧操作(一次性,约 10 分钟)
- 建公开仓库
blog-comments(必须 Public,勾选 Add a README 让仓库非空) - 仓库 Settings → Features → 勾选 Discussions(默认不开)
- 安装 giscus App:https://github.com/apps/giscus → Install → Only select repositories → 只授权
blog-comments - 到 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)
| |
填完重跑 deploy.sh 即可,无需改任何代码。
4.5.4 日常使用与运维
- 发评论:访客在文章页底部用 GitHub 登录后评论(首条评论自动建 Discussion 帖)
- 管理评论:在 GitHub 仓库的 Discussions 页面删帖/锁帖/置顶,改动实时同步到博客
- 验证:
curl文章页 HTML 里应有giscus.app/client.js和正确的data-repo - 不想用某篇文章的评论:该文章 front matter 加
comments: false
五、日常运维速查
| |
六、遗留事项
-
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 自建访问量统计
不蒜子只能统计「当前页面」,无法实现列表页显示每篇文章阅读量。自建方案:
| |
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,写错会导致按钮白字白底「隐形」
