Inkstone · blog

从空域名到可写博客:Astro、Cloudflare Pages 与 Sveltia CMS 的建站记录

2,244 words 6 min read #Cloudflare#Astro#Sveltia CMS#GitHub#Obsidian

这次要解决的问题很具体:域名已经托管在 Cloudflare 上,但打开还是空的;本地有一套 Obsidian 笔记,希望以后能挑选其中一部分文章发布到公开博客;写作设备不只一台,iPhone、iPad 和 MacBook 都应该能参与写作和发布。

最后落地的是一套静态博客链路:

Obsidian / CMS 写作
GitHub 私有仓库保存源码和文章
Cloudflare Pages 自动构建 Astro
自定义域名访问

站点本身是 Astro,内容是 Markdown,部署在 Cloudflare Pages,浏览器里的文章管理界面用 Sveltia CMS。这个组合的核心目标不是“功能最多”,而是长期维护成本低、备份清晰、迁移不困难。

目标

这套博客系统一开始约束很明确:

  1. 免费或接近免费。
  2. 域名使用 Cloudflare 托管的自有域名。
  3. 文章源文件要能备份,最好就是 Git 里的 Markdown。
  4. 不直接公开整个 Obsidian vault,只发布挑选后的文章。
  5. Mac 上可以直接改代码和 Markdown,移动端可以通过 CMS 写作。
  6. 首页先是个人主页 + 技术博客,不急着搬运所有文章。

这些目标决定了不适合直接把整个 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.js
public/
admin/
config.yml

文章放在 src/content/blog,Astro content collection 负责读取 frontmatter。公开列表只显示 draft: false 的文章,所以可以先在仓库里保留草稿,但不发布到网站。

一篇文章的 frontmatter 约定是:

---
title: Example Post
date: 2026-06-04
summary: A short excerpt for the post list.
tags:
- Notes
draft: false
---

首页先做成个人主页 + 最近文章入口,博客列表页负责文章归档,文章详情页负责 Markdown 渲染。这个阶段没有引入数据库,也没有引入服务端渲染。

Cloudflare Pages 部署

Cloudflare Pages 通过 GitHub 集成部署。关键配置很少:

Build command: npm run build
Build output directory: dist
Production 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.com

www.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。验证时重点看三件事:

Terminal window
curl -I https://example.com
curl -I https://www.example.com
curl -I "https://www.example.com/blog/?a=1"

期望结果是主域名返回 200www 返回 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/uploads
public_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 后端提供了两种常见思路:

  1. 用 access token 登录。
  2. 自己部署 Sveltia CMS Authenticator,把 base_url 指过去。

为了让移动端和浏览器登录体验更像正常 CMS,这次选择了第二种。

部署 Sveltia CMS Authenticator

Sveltia CMS Authenticator 是一个 Cloudflare Worker。部署流程是:

  1. 部署官方 sveltia-cms-auth Worker。
  2. 在 GitHub 创建 OAuth App。
  3. OAuth App 的 callback URL 填:
https://<cms-auth-worker>/callback
  1. 在 Worker 里配置环境变量 / secrets:
GITHUB_CLIENT_ID
GITHUB_CLIENT_SECRET
ALLOWED_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,原因是:

  1. OAuth App 和 Worker 都在自己的账号下。
  2. CMS 实际配置只指向博客仓库。
  3. 登录体验更适合多端写作。

但这个决策应该被明确记录,而不是假装权限没有扩大。

验证清单

每改一步都需要能验证:

Terminal window
npm test
npm run build

线上部署后验证:

Terminal window
curl -I https://example.com
curl -I https://www.example.com
curl -I https://example.com/admin/
curl https://example.com/admin/config.yml

OAuth Worker 验证:

Terminal window
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 笔记值得公开,哪些只是私人工作记录,哪些需要改写成完整文章。

参考