在 NAS 上自建静态博客:Astro + Gitea + Jenkins 全流程实践

5097 字
13 分钟

在 NAS 上自建静态博客:Astro + Gitea + Jenkins 全流程实践

发布于

缘起

技术笔记散落在各种笔记软件里,久了既不好检索,也带不走。想找个地方沉淀,评估下来只有两条路:用第三方托管平台(省事,但数据和样式都受制于人),或者自己搭。

手边正好有一台常年开机的 NAS,上面跑着 Docker,还有一套自建的代码托管与 CI 服务。既然如此,就把站点也放进去——写作时只管提交 Markdown,其余全部自动完成。

目标定得很克制:

  • 网站是纯静态:没有运行时、没有数据库,省资源,也不担心被拖库
  • 写作即发布:git push 之后不需要手动做任何事
  • 复用已有设施:不额外购买服务器、不引入第三方托管

实际做完之后,从提交到线上生效约 14 秒。

架构与组件

整个系统跑在一台 NAS 的 Docker 里,对外的 HTTPS 由一台小 VPS 终结:

作者Markdown 写作Gitea源码托管Jenkins构建发布Nexusnpm 代理nginx静态伺服nginxHTTPS 终结(VPS)共享目录releases / currentgit push触发构建依赖写入产物只读挂载反向代理

组件清单:

角色组件说明
站点生成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。需要改动的只有三处:

  1. astro.config.mjs 里的 site(影响 canonical 与 sitemap 的绝对地址)
  2. src/consts.ts 里的站点标题与描述
  3. 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-proxyproxy代理 https://registry.npmjs.org
npm-groupgroup聚合上述代理,对外只暴露这个地址

构建时把 registry 指过去:

npm config set registry https://<你的 Nexus>/repository/npm-group/

坑:Nexus 的匿名访问默认可能是关闭的,关闭状态下匿名拉包一律 401。若仓库只服务内网构建,把匿名读打开最省事,否则需要在 CI 中存放凭据。

curl -sI https://<你的 Nexus>/repository/npm-group/left-pad   # 期望 200

三、Jenkins 侧的准备

三个现实约束:

  1. 没有 Docker 相关插件,且只有内置节点(2 个 executor),不能用容器化 agent,只能用 NodeJS 插件在节点上装 Node
  2. 内置的 NodeJS 安装器版本太老(20.x),不满足 Astro 7 的要求,需要新增一个 24.x 安装器
  3. 构建要拉取私有仓库,需要一对部署密钥

第二条若界面上不好操作,可以通过脚本化接口新增安装器(注意插件版本对应的构造参数,不要照抄旧文档)。

部署密钥的思路是专用且最小权限:单独生成一对密钥只给 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 之上。