这次要解决的问题很具体:域名已经托管在 Cloudflare 上,但打开还是空的;本地有一套 Obsidian 笔记,希望以后能挑选其中一部分文章发布到公开博客;写作设备不只一台,iPhone、iPad 和 MacBook 都应该能参与写作和发布。
最后落地的是一套静态博客链路:
Obsidian / CMS 写作 ↓GitHub 私有仓库保存源码和文章 ↓Cloudflare Pages 自动构建 Astro ↓自定义域名访问站点本身是 Astro,内容是 Markdown,部署在 Cloudflare Pages,浏览器里的文章管理界面用 Sveltia CMS。这个组合的核心目标不是“功能最多”,而是长期维护成本低、备份清晰、迁移不困难。
目标
这套博客系统一开始约束很明确:
- 免费或接近免费。
- 域名使用 Cloudflare 托管的自有域名。
- 文章源文件要能备份,最好就是 Git 里的 Markdown。
- 不直接公开整个 Obsidian vault,只发布挑选后的文章。
- Mac 上可以直接改代码和 Markdown,移动端可以通过 CMS 写作。
- 首页先是个人主页 + 技术博客,不急着搬运所有文章。
这些目标决定了不适合直接把整个 Obsidian vault 扔到公网,也不适合一开始就上重 CMS。Astro + Cloudflare Pages 的好处是:生成结果是静态站,托管简单;文章就是 Markdown,备份和迁移都在 Git 里;后续要换主题、换 CMS 或换托管平台,也不会被数据库格式锁死。
为什么不是 WordPress 或 Obsidian Publish
WordPress 的优势是完整的后台和成熟生态,但要么需要自己维护服务端和数据库,要么依赖第三方托管。对一个个人技术博客来说,它的运行面偏大:数据库、插件、登录后台、安全更新都要考虑。
Obsidian Publish 的优势是和 Obsidian 原生体验贴合,但它更像“公开笔记库”。这里的目标不是把所有笔记变成数字花园,而是把挑选后的文章发布成一个独立博客。公开文章应该是整理过的内容,不是 vault 的完整镜像。
Quartz / Digital Garden 也适合 Obsidian 场景,但这次更希望首页有个人主页属性,并且后续可以自由调整视觉、路由、RSS、CMS,所以最终选了 Astro。
项目结构
Astro 项目保持很小:
src/ content/ blog/ starter-draft.md layouts/ BaseLayout.astro pages/ index.astro blog/ index.astro [slug].astro admin/ index.astro rss.xml.jspublic/ admin/ config.yml文章放在 src/content/blog,Astro content collection 负责读取 frontmatter。公开列表只显示 draft: false 的文章,所以可以先在仓库里保留草稿,但不发布到网站。
一篇文章的 frontmatter 约定是:
---title: Example Postdate: 2026-06-04summary: A short excerpt for the post list.tags: - Notesdraft: false---首页先做成个人主页 + 最近文章入口,博客列表页负责文章归档,文章详情页负责 Markdown 渲染。这个阶段没有引入数据库,也没有引入服务端渲染。
Cloudflare Pages 部署
Cloudflare Pages 通过 GitHub 集成部署。关键配置很少:
Build command: npm run buildBuild output directory: distProduction branch: main代码 push 到 main 后,Cloudflare Pages 自动拉取仓库、执行 Astro build,然后把 dist 发布出去。Cloudflare 的 GitHub 集成官方文档也强调了这个模式:连接 GitHub 仓库后,每次 push 到指定分支都会自动部署。
域名接入时,根域名指向 Pages 项目,并在 Pages 的 Custom domains 里激活。这里要注意一个细节:DNS 记录只是让流量进入 Cloudflare,Pages 的 Custom domains 还负责把这个 host 和项目绑定起来,并让边缘证书正确覆盖。
www 到主域名的跳转
站点最终希望主域名是唯一入口:
https://example.comwww.example.com 只做 301 跳转。这里有两个坑:
第一,只有 Redirect Rule 还不够。www 必须也有一条经过 Cloudflare 代理的 DNS 记录,否则请求进不到 Cloudflare 的规则引擎。
第二,如果 www 要支持 HTTPS,证书也必须覆盖它。最稳的做法是把 www.example.com 也加到 Pages 项目的 Custom domains,让 Pages/Cloudflare 给它处理证书,然后再用 Redirect Rules 统一跳到 apex。
最终规则是两条:
https://www.example.com/* -> https://example.com/${1}http://www.example.com/* -> https://example.com/${1}并且保留 query string。验证时重点看三件事:
curl -I https://example.comcurl -I https://www.example.comcurl -I "https://www.example.com/blog/?a=1"期望结果是主域名返回 200,www 返回 301,路径和查询参数不丢。
CMS:为什么选择 Sveltia
只靠 Git 和本地 Markdown 写作没有问题,但移动端写作会比较别扭。Sveltia CMS 的定位刚好补这个缺口:它是一个 Git-based CMS,后台页面是静态文件,登录 GitHub 后直接读写仓库里的 Markdown。
CMS 配置放在 public/admin/config.yml,核心是:
backend: name: github repo: <owner>/<repo> branch: main
media_folder: public/uploadspublic_folder: /uploads
collections: - name: blog label: Blog folder: src/content/blog create: true slug: '{{year}}-{{month}}-{{day}}-{{slug}}' fields: - label: Title name: title widget: string - label: Date name: date widget: datetime - label: Summary name: summary widget: text required: false - label: Tags name: tags widget: list required: false - label: Draft name: draft widget: boolean default: false required: false - label: Body name: body widget: markdown/admin/ 页面本身只负责加载 Sveltia CMS:
<script src="https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js"></script>这样后台也不需要单独部署服务端。
OAuth 的坑:为什么会跳到 Netlify Not Found
第一次登录 CMS 时,页面能打开,但 GitHub 登录弹窗显示 Not Found。真正出错的不是 /admin/,而是登录弹窗跳到了类似这样的地址:
https://api.netlify.com/auth?provider=github&site_id=...这说明 CMS 正在使用 Netlify/Decap 兼容的默认 OAuth 入口。但站点部署在 Cloudflare Pages,不是 Netlify 站点,所以 Netlify API 找不到这个 site_id,自然返回 404。
Sveltia 官方给 GitHub 后端提供了两种常见思路:
- 用 access token 登录。
- 自己部署 Sveltia CMS Authenticator,把
base_url指过去。
为了让移动端和浏览器登录体验更像正常 CMS,这次选择了第二种。
部署 Sveltia CMS Authenticator
Sveltia CMS Authenticator 是一个 Cloudflare Worker。部署流程是:
- 部署官方
sveltia-cms-authWorker。 - 在 GitHub 创建 OAuth App。
- OAuth App 的 callback URL 填:
https://<cms-auth-worker>/callback- 在 Worker 里配置环境变量 / secrets:
GITHUB_CLIENT_IDGITHUB_CLIENT_SECRETALLOWED_DOMAINS其中 GITHUB_CLIENT_SECRET 必须用 secret 存储,不应该写进仓库。ALLOWED_DOMAINS 可以限制只有自己的站点域名能使用这个 Worker。
最后在 CMS 配置里加:
backend: name: github repo: <owner>/<repo> branch: main base_url: https://<cms-auth-worker>修完后再点击 Sign In with GitHub,应该跳到:
https://github.com/login/oauth/authorize?client_id=...而不是 api.netlify.com/auth。
权限边界
这里还有一个值得单独记录的取舍:GitHub OAuth App 使用 repo scope 时,授权页会提示它可以访问账号下的公开和私有仓库。实际 CMS 配置写死了 repo: <owner>/<repo>,正常使用只会操作这个博客仓库;但 OAuth token 的理论权限范围比单仓库更大。
这不是当前实现会主动扫描所有仓库,而是 GitHub OAuth scope 的粒度不够细。安全优先时,可以改用 GitHub fine-grained PAT,只授权单个仓库,并在 Sveltia CMS 里选择 Sign In Using Access Token。
这次权衡后继续使用 OAuth,原因是:
- OAuth App 和 Worker 都在自己的账号下。
- CMS 实际配置只指向博客仓库。
- 登录体验更适合多端写作。
但这个决策应该被明确记录,而不是假装权限没有扩大。
验证清单
每改一步都需要能验证:
npm testnpm run build线上部署后验证:
curl -I https://example.comcurl -I https://www.example.comcurl -I https://example.com/admin/curl https://example.com/admin/config.ymlOAuth Worker 验证:
curl -I "https://<cms-auth-worker>/auth?provider=github&site_id=example.com&scope=repo%2Cuser"如果配置正确,它应该返回 302,并把请求导向 GitHub OAuth 授权页。如果缺少 client id 或 secret,会返回 CMS 可识别的配置错误。
这套方案的边界
这不是一个“完整内容平台”。它没有多用户权限系统,没有数据库,也没有复杂审核流程。它适合的是个人博客:
- 文章是 Markdown。
- Git 是备份和版本历史。
- Cloudflare Pages 是静态托管。
- Sveltia CMS 只是一个更方便的 Git 写作界面。
这套边界反而是优点。只要仓库还在,文章就还在;只要 Markdown 还在,以后从 Astro 换到 Hugo、Quartz 或其他系统都不难。
后续真正需要投入的不是架构,而是内容筛选:哪些 Obsidian 笔记值得公开,哪些只是私人工作记录,哪些需要改写成完整文章。