在 NAS 上自建静态博客:Astro + Gitea + Jenkins 全流程实践
发布于
缘起
技术笔记散落在各种笔记软件里,久了既不好检索,也带不走。想找个地方沉淀,评估下来只有两条路:用第三方托管平台(省事,但数据和样式都受制于人),或者自己搭。
手边正好有一台常年开机的 NAS,上面跑着 Docker,还有一套自建的代码托管与 CI 服务。既然如此,就把站点也放进去——写作时只管提交 Markdown,其余全部自动完成。
目标定得很克制:
- 网站是纯静态:没有运行时、没有数据库,省资源,也不担心被拖库
- 写作即发布:
git push之后不需要手动做任何事 - 复用已有设施:不额外购买服务器、不引入第三方托管
实际做完之后,从提交到线上生效约 14 秒。
架构与组件
整个系统跑在一台 NAS 的 Docker 里,对外的 HTTPS 由一台小 VPS 终结:
组件清单:
| 角色 | 组件 | 说明 |
|---|---|---|
| 站点生成 | Astro 7 | 静态站点框架,内容为 Markdown |
| 源码托管 | Gitea | 自建 Git 服务,仓库为私有 |
| 持续集成 | Jenkins | 构建与发布流水线 |
| 依赖代理 | Nexus | 私有 npm 代理,缓存依赖 |
| Web 伺服 | nginx | 直接伺服构建产物 |
| 运行环境 | NAS + Docker | 上述容器全部运行于此 |
| 公网入口 | VPS + nginx | 终结 HTTPS,反代到 NAS |
数据流很简单:写 Markdown → push → Jenkins 构建 → 产物落到共享目录 → nginx 立即生效。
下面按搭建顺序记录,每一步附上实际执行的命令与踩到的坑。
一、本地把站点跑起来
Astro 对 Node 版本有要求(≥ 22.12),先确认:
node -v # v24.20.0
创建项目,直接用官方博客模板:
npm create astro@latest my-blog -- --template blog --install --git --yes
模板开箱即有文章集合、RSS 与 sitemap。需要改动的只有三处:
astro.config.mjs里的site(影响 canonical 与 sitemap 的绝对地址)src/consts.ts里的站点标题与描述src/components/Footer.astro加上备案信息(域名在国内解析必须展示)
容易被忽略的一步:模板自带的是英文界面
模板的导航、页脚、示例文章全是英文。如果不管它,中文站点会顶着 “Home / Blog / About” 和 “Hello, Astronaut!” 上线。中文化至少要覆盖:
- 导航链接文字(
Header.astro) - 页脚版权(
Footer.astro) - 首页介绍(
pages/index.astro)与关于页(pages/about.astro) - 日期格式(
FormattedDate.astro的 locale 改为zh-CN) - 各页面的
<html lang="en">改为zh-CN
另外别忘了删掉模板自带的示例文章,否则”第一篇文章”会排在几篇占位文本后面。
二、搭一个私有 npm 代理
站点依赖两百多个 npm 包。如果每次构建都从公网拉取,既慢又不稳定。
既然已有 Nexus(原本只服务 Maven / NuGet),加一个 npm 仓库即可:
| 仓库 | 类型 | 作用 |
|---|---|---|
npmjs-proxy | proxy | 代理 https://registry.npmjs.org |
npm-group | group | 聚合上述代理,对外只暴露这个地址 |
构建时把 registry 指过去:
npm config set registry https://<你的 Nexus>/repository/npm-group/
坑:Nexus 的匿名访问默认可能是关闭的,关闭状态下匿名拉包一律 401。若仓库只服务内网构建,把匿名读打开最省事,否则需要在 CI 中存放凭据。
curl -sI https://<你的 Nexus>/repository/npm-group/left-pad # 期望 200
三、Jenkins 侧的准备
三个现实约束:
- 没有 Docker 相关插件,且只有内置节点(2 个 executor),不能用容器化 agent,只能用 NodeJS 插件在节点上装 Node
- 内置的 NodeJS 安装器版本太老(20.x),不满足 Astro 7 的要求,需要新增一个 24.x 安装器
- 构建要拉取私有仓库,需要一对部署密钥
第二条若界面上不好操作,可以通过脚本化接口新增安装器(注意插件版本对应的构造参数,不要照抄旧文档)。
部署密钥的思路是专用且最小权限:单独生成一对密钥只给 Jenkins 用,Gitea 侧以 Deploy Key(只读) 加入仓库,而不是把某个人的账号密钥交给它。这样即使构建机被攻破,也拿不到写权限。
四、部署方式:不要为了”跨机器”而走网络
最初的设计很自然:Jenkins 构建完,把产物 scp 到 nginx 所在的机器。
结果 scp 一直失败:
scp: Connection closed
排查过程相当费解:TCP 能连、SSH banner 能拿到、密钥交换都正常,但对端 sshd 的日志里一条记录都没有,换成 1 字节的小文件也一样。环境层面的限制没有查到确切原因。
真正让方案跑起来的是换了个思路:
Jenkins 和 nginx 本来就是同一台 NAS 上的两个容器,为什么要走网络?
在 docker-compose 里给两个容器挂载同一个宿主机目录,Jenkins 写、nginx 读,传输层的问题直接消失:
services:
jenkins:
volumes:
- /path/to/blog:/blog # 构建产物写这里
nginx:
volumes:
- /path/to/blog:/blog:ro # 只读挂载,路径需与上面一致
一个要点:发布用软链接切换(current -> releases/42),而软链接里存的是绝对路径,所以两个容器的挂载点必须一致(这里都是 /blog),否则链接会指向不存在的路径。
发布逻辑因此变得很短:
REL=/blog/releases/${BUILD_NUMBER}
mkdir -p "$REL"
tar xzf dist.tar.gz -C "$REL"
ln -sfn "$REL" /blog/current # 原子切换
# 只保留最近 5 个版本,便于回滚
ls -dt /blog/releases/*/ | tail -n +6 | xargs -r rm -rf
软链接切换的好处:更新是原子的,回滚也只是一次 ln -sfn。
五、nginx:两个真正花时间的坑
坑 1:目录权限看起来完全正常,nginx 却说没权限
vhost 配好后访问一直失败,nginx 错误日志里是:
stat() "/blog/current/index.html" failed (13: Permission denied)
但目录权限看起来毫无问题:
drwxr-xr-x 1000:1000 /blog
ACL 也显示 other 可读。于是做了一组对照实验:
| 以哪个身份访问 | 结果 |
|---|---|
| root (uid 0) | 可读 |
| uid 1000(目录属主) | 可读 |
| nginx 用户 (uid 101) | 拒绝 |
| nobody | 拒绝 |
setfacl 授权同样无效。结论是:这台 NAS 对其共享目录做了内核层的访问控制,白名单之外的 uid 一律拦截,POSIX 权限位与 ACL 在这类目录上并不完全生效。
可行的做法只有两种:让读目录的进程以 root(或白名单内的 uid)运行;或把数据放到不受该策略约束的位置(如 Docker 命名卷)。
最终选择前者:挂载一份自定义主配置,把 nginx 的 worker 用户从 nginx 改为 root。
这个坑的启示是:容器内的权限判断(
ls -l、getfacl)只反映你看到的那一层。当”权限看起来没问题却始终拒绝”时,要怀疑容器之外的策略(宿主机 ACL、SELinux/AppArmor、厂商定制策略),并用”换身份实测”的方式定位,而不是反复调整目录权限。
坑 2:新域名上线,把老服务搞挂了
新站点的 server 块上线后,原本正常的几条服务路径全部变成了 404。
原因不复杂:这些路径原本由默认 server(default_server)分发,而新站点把整个裸域接管了——请求不再落到默认 server,新 server 里又没有这些 location。
修复方式是在新 server 内补回全部转发规则。核心教训:
按 Host 新增 server 块时,等于接管了这个 Host 在默认 server 上原本承担的所有路径,必须把其中每一条都显式搬过来,否则就是一次静默的回归故障。
顺带一句缓存配置
静态站点的哈希产物(_astro/*)可以放心长缓存:
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/index.html =404;
}
六、公网打通
对外这一层交给 VPS 上的 nginx:DNS 解析到 VPS,由它做 HTTPS 终结与反向代理。
上线前有件事值得单独确认:证书的 SAN 是否覆盖了裸域。很多人申请的是 *.example.com,而通配符不覆盖 example.com 本身,浏览器会直接报证书错误。
一条命令确认:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -ext subjectAltName
只有当 subject 与 SAN 都包含裸域时,才算真的没问题。检查通过后,再把 HTTP 全部 301 到 HTTPS。
七、让发布自动化
最后一步是触发:push 之后要自动开始构建。
Jenkins 传统的 git/notifyCommit 端点在开启统一认证(如 OIDC)的环境里,匿名调用会直接 401;另外 Declarative Pipeline 的触发器由 Jenkinsfile 声明,在界面上手工添加的触发器可能不生效。
可用的方式是直接调用构建接口(带认证):
curl -X POST -u "<user>:<api-token>" https://<你的 CI 服务>/job/<job-name>/build
把这条 URL 配到 Gitea 的 Webhook(Push 事件)里,就实现了”提交即构建”。
需要权衡的是:URL 里带 token,会明文存放在 Gitea 配置中。如果不能接受,替代方案是在 Jenkinsfile 里声明轮询:
triggers { pollSCM('H/2 * * * *') }
代价是发布最多延迟两分钟,好处是零凭据暴露。取舍取决于你对”提交后立刻看到结果”的需求有多强。
成果与复盘
最终效果:
- 写一篇 Markdown →
git commit && git push→ 约 14 秒后线上可见 - 每次发布保留最近 5 个版本,回滚只需改一次软链接
- 全程不依赖任何第三方托管服务
回头看,真正消耗时间的都不是”配置怎么写”,而是三类问题:
| 类型 | 例子 | 教训 |
|---|---|---|
| 环境/平台的隐藏约束 | NAS 对特定 uid 的内核级拦截 | 权限”看起来对”不等于”真的能访问”,用换身份实测定位 |
| 方案选型的惯性 | 为跨机器部署引入 SSH,其实两个容器在同一台机器 | 动手前先质疑方案的必要性 |
| 默认配置的隐性依赖 | 默认 server 承担的路径分发被新站点接管 | 改路由/入口配置前,先弄清原本还有谁依赖它 |
如果说有什么值得记住的,大概是第二类:技术选型的成本,往往不在实现难度上,而在你是否真的需要它。
本文记录的对象即本站。环境版本:Astro 7、Node.js 24.20、Jenkins LTS (JDK 17)、Gitea、Nexus、nginx,全部运行于一台 NAS 的 Docker 之上。