我是怎么把「未名笔记」真正上线的

从一个双语博客的想法出发,记录我如何借助 Codex 完成网站,并一步步处理 VPS、域名、HTTPS、Google Search 与日常维护。

本文大纲

这篇文章不是代码教程,更像是一次建站复盘。

我想记录的重点是:我一开始想做什么、我是怎么借助 Codex 把网站做出来的,以及后来怎么一步步处理 VPS、域名、HTTPS、Google Search 和日常维护。以后如果要重装服务器、迁移网站,或者再搭一个类似的站,回来翻这篇就够了。

一开始,我想要的不是一个博客模板

最初的想法其实很简单:我想有一个真正属于自己的地方,长期记录游戏开发、软件工程、产品设计,还有生活里的想法。

但做着做着,要求也逐渐具体了起来:

  • 网站叫「未名笔记」,英文名是 Notes by Wei;
  • 中文和英文都要能完整阅读,而不是只翻译几个菜单;
  • 文章用 Markdown 写,代码、图片、视频和表格都能正常展示;
  • 内容可以按两级栏目整理,比如“工程札记 / 游戏开发”;
  • 我需要一个后台,能自己写文章、上传封面、管理栏目;
  • 首页要有一点个人风格,支持手机、电脑和深色模式;
  • 最终必须部署到自己的 VPS,用自己的域名;
  • 数据和图片不能因为更新网站就丢失,还要有备份;
  • Google 能发现文章,以后也可以继续尝试 AdSense。

所以最后做出来的,其实已经不只是一个博客页面,而是一套很小的双语内容管理系统。

网站是怎么做出来的

代码实现这部分我没有从零手写,而是把需求、页面效果和修改意见交给 Codex,让它帮我完成开发和迭代。

项目最终使用 React、TypeScript、vinext 和 Markdown。Codex 帮我搭好了中英文页面、文章和栏目后台、深色模式、代码高亮、文章目录、图片视频展示,以及 VPS 和 Cloudflare 两套运行方式。我主要做的是提出需求、查看实际效果、指出哪里不对,然后决定下一步要加什么。

代码保存在 GitHub,并用 Git 记录每一次修改。第一篇正式发布的长文是 UE4 UI 输入路由的源码分析,中英文正文和图片也都保留在仓库的 content/ 目录中。

实现细节以后可以直接看代码,这里就不展开了。真正花心思的是怎样把它从“本地能打开”变成一个可以长期运行的网站。

为什么最后选择自己的 VPS

项目最开始可以运行在 Sites/Cloudflare 环境里,数据使用 D1,图片使用 R2。后来我还是希望网站尽量掌握在自己手里,所以又让 Codex 做了一套 VPS 运行方式:

  • 网站本身由 Node.js 运行;
  • 文章和栏目保存在 SQLite;
  • 图片保存在服务器本地目录;
  • Caddy 负责域名、HTTPS 和反向代理;
  • systemd 负责开机启动、故障重启和定时备份。

原来的 Cloudflare 版本没有删,只是正式站最终运行在 VPS 上。

本地开发时,常用的命令是:

Bash
npm run dev:vps
npm run build:vps

第二条命令会生成可以上传服务器的 dist/standalone/

我怎么处理 VPS

先准备一台干净的服务器

这次使用的是 Debian 12 x86_64 VPS,公网 IP 是 65.49.208.223。服务器配置不高,所以目标一直是简单、稳定、少依赖,而不是在上面堆很多服务。

仓库里的 deploy/bootstrap-server.sh 用来初始化新服务器。它会安装 Node.js、Caddy、SQLite、rsync、UFW 等工具,还会创建专门运行博客的 weiming 系统用户。

因为这台 VPS 内存比较小,脚本还额外创建了 1.5 GiB swap,避免系统升级或短时间流量增加时直接耗尽内存。

初始化时,我先把脚本传到服务器再运行:

Bash
export BLOG_VPS_IP="65.49.208.223"

scp deploy/bootstrap-server.sh root@"$BLOG_VPS_IP":/root/
ssh root@"$BLOG_VPS_IP" 'bash /root/bootstrap-server.sh'

防火墙只开放三个端口:

  • 22:SSH;
  • 80:HTTP;
  • 443:HTTPS。

Node.js 应用本身只监听 127.0.0.1:3000,不会直接暴露到公网。

把程序和数据分开

服务器上的目录大概是这样:

TEXT
/opt/weiming-blog/
├── releases/<timestamp>/
└── current -> releases/current-version

/var/lib/weiming-blog/
├── blog.sqlite
└── media/

/var/backups/weiming-blog/

/opt 下面只放程序,每次发布一个新目录;/var/lib 下面才是真正不能丢的数据库和图片。

这样更新网站时只需要切换 current 软链接,不会碰到文章数据。某个版本有问题时,也可以重新指回上一个 release。

发布一个新版本

我先在 Mac 上安装依赖并构建:

Bash
npm ci
npm run build:vps

然后创建一个带时间戳的新 release,把 dist/standalone/ 上传上去:

Bash
export BLOG_VPS_IP="65.49.208.223"
export BLOG_RELEASE="$(date -u +%Y%m%dT%H%M%SZ)"

ssh root@"$BLOG_VPS_IP" \
  "install -d -m 0755 /opt/weiming-blog/releases/$BLOG_RELEASE"

rsync -az dist/standalone/ \
  root@"$BLOG_VPS_IP":"/opt/weiming-blog/releases/$BLOG_RELEASE/"

ssh root@"$BLOG_VPS_IP" \
  "ln -sfn /opt/weiming-blog/releases/$BLOG_RELEASE /opt/weiming-blog/current && systemctl restart weiming-blog"

更新后,我会先看服务状态和日志:

Bash
ssh root@65.49.208.223 \
  'systemctl status weiming-blog --no-pager'

ssh root@65.49.208.223 \
  'journalctl -u weiming-blog -n 100 --no-pager'

让网站一直运行

博客由 weiming-blog.service 管理。服务器重启后它会自动启动,程序崩溃时也会自动重试。

服务不是用 root 身份运行,而是使用前面创建的 weiming 用户。这个用户只能写 /var/lib/weiming-blog,其他系统目录基本都是只读的。这样即使应用出问题,影响范围也会小一些。

给后台加密码

公网首页可以直接访问,但 /admin 和文章写接口不能暴露。

我把这层保护放在 Caddy 上,用 Basic Auth 验证。密码不会明文写进配置,而是在服务器上先生成哈希:

Bash
caddy hash-password

然后只把生成的哈希填进 /etc/caddy/Caddyfile

每次修改 Caddy,都先验证配置再重载:

Bash
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

仓库里还留了一份 Caddyfile.locked。如果后台密码还没有配好,可以先用这份配置直接关闭后台和写接口,避免出现几分钟的无密码状态。

每天自动备份

数据库和图片每天自动备份一次:

  • SQLite 使用自己的 .backup 命令生成一致性副本;
  • media/ 打包成压缩文件;
  • VPS 上只保留最近 14 天。

需要时可以手动触发:

Bash
ssh root@65.49.208.223 \
  'systemctl start weiming-blog-backup.service'

不过,同一台服务器里的备份不算真正的灾难备份。VPS 整机损坏时,它们还是会一起丢失。所以长期来看,还需要定期把 /var/backups/weiming-blog/ 下载到本地,或者同步到另一处对象存储。

正式域名接入前,我先用了 nip.io

一开始我没有立刻折腾正式域名,而是先用了:

TEXT
weiming-65-49-208-223.nip.io

nip.io 会把主机名里的 IP 直接解析到服务器。这样我可以先确认:

  • VPS 的 80 和 443 端口能不能访问;
  • Caddy 能不能正常申请 HTTPS 证书;
  • Node.js 和反向代理有没有问题;
  • 后台密码是否真的生效;
  • 重启服务后数据库和图片还在不在。

先用临时域名跑通非常有用。如果这一步没问题,后面正式域名访问失败时,基本就可以把排查重点放在 DNS 和域名配置上。

我怎么处理 notesbywei.com

正式域名是 notesbywei.com,DNS 放在 Cloudflare 管理。

最主要的两条记录是:

类型名称指向
A@65.49.208.223
CNAMEwwwnotesbywei.com

根域名直接指向 VPS,www 跟随根域。至于 Cloudflare 当时使用橙云代理还是仅 DNS,Git 仓库里不会留下记录,所以以后应直接在 Cloudflare 控制台确认。

DNS 生效以后,我做了几件事:

  1. 把 Caddy 的主站域名改成 notesbywei.com
  2. www.notesbywei.com 永久跳转到根域;
  3. 让旧的 nip.io 地址也跳转到正式域名;
  4. 把网站内部的统一基准地址改成 https://notesbywei.com
  5. 重新加载 Caddy,让它自动申请正式域名的 HTTPS 证书。

最后用 curl 检查:

Bash
curl -I https://notesbywei.com/
curl -I https://www.notesbywei.com/

现在主域名正常返回页面,www 会用 301 跳转到根域。这样访客和搜索引擎看到的始终是同一个正式地址。

我怎么处理 Google Search

这一块很容易和 AdSense 混在一起,其实它们不是一回事。

Google Search 的目标是让 Google 能找到并理解网站;AdSense 是广告审核和投放。搜索收录不要求先开通 AdSense。

网站这边先准备好

我让 Codex 把搜索引擎需要的基础内容补齐,主要包括:

  • robots.txt:允许抓取公开页面,屏蔽后台和 API;
  • sitemap.xml:自动列出首页、栏目、文章、关于和隐私页;
  • 中文和英文页面之间的 hreflang
  • 每个页面自己的 title、description 和 canonical;
  • 文章的发布时间、作者、封面等结构化数据;
  • RSS:feed.xml
  • 旧域名和 www 到正式域名的 301 跳转。

线上可以直接查看:

这些东西不会保证文章一定被收录,但至少让 Google 知道网站有哪些页面、哪个地址才是正式地址,以及中文和英文文章之间是什么关系。

然后去 Search Console

Search Console 里的操作不会记录在 Git 仓库里,所以以后最好按下面这份清单重新确认一次:

  1. 打开 https://search.google.com/search-console
  2. 添加一个 Domain property,填写 notesbywei.com
  3. Google 会给出一条 google-site-verification=... 的 TXT 记录;
  4. 到 Cloudflare 的 DNS 页面,把这条 TXT 加到根域;
  5. 等待 DNS 生效后,回到 Search Console 点击验证;
  6. 验证成功后保留 TXT,不要随手删除;
  7. 在 Sitemaps 页面提交 sitemap.xml
  8. 用 URL Inspection 检查首页和第一篇中英文文章;
  9. 如果页面还没被发现,可以点击 Request indexing;
  10. 之后主要看 Pages、Sitemaps 和 Crawl stats 有没有异常。

我选择 Domain property,是因为它会一起覆盖根域、www、HTTP 和 HTTPS,不需要分别建好几个资源。

Google 收录通常不是马上发生的。提交 sitemap 只是告诉 Google 页面在哪里,并不等于保证收录。新站等几天甚至几周都很正常,反复点击 Request indexing 也不会让它明显变快。

官方说明:

AdSense 目前做到哪一步

网站已经为 AdSense 留好了接口,包括广告脚本、广告位、ads.txt 和中英文隐私政策。但这只代表“代码可以接入”,不代表网站已经通过审核。

以后正式申请时,还需要我自己在 AdSense 后台添加 notesbywei.com,拿到真实的 ca-pub-... 和广告位 ID,再把它们放进服务器环境变量。真实 ID 不应该提交到 Git。

如果网站面向需要用户同意的地区,还要另外配置 Google 认可的 CMP。这个步骤和 Search Console 的收录没有直接关系。

以后怎么更新文章和网站

平时发布内容,大概按这个顺序:

  1. 准备中文 Markdown、英文 Markdown 和图片;
  2. 本地预览两种语言;
  3. 运行检查和构建;
  4. 提交 Git,推送到 GitHub;
  5. 构建新的 VPS release;
  6. 上传服务器,切换 current,重启服务;
  7. 检查首页、文章、图片、sitemap 和 RSS;
  8. 重要文章可以去 Search Console 请求一次索引。

本地常用命令:

Bash
npm ci
npm run dev:vps
npm run lint
npm test
npm run build:vps

服务器常用命令:

Bash
ssh root@65.49.208.223 \
  'systemctl status weiming-blog caddy --no-pager'

ssh root@65.49.208.223 \
  'journalctl -u weiming-blog -n 100 --no-pager'

ssh root@65.49.208.223 \
  'systemctl start weiming-blog-backup.service'

ssh root@65.49.208.223 \
  'caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy'

这次实际用到的工具

回头看,这次建站真正用到的工具并没有特别夸张:

  • Codex:实现网站和协助排查、部署;
  • Git、GitHub:保存代码和修改历史;
  • Node.js、npm:开发和构建;
  • React、TypeScript、vinext:网站本身;
  • SQLite:正式文章数据;
  • SSH、rsync:连接和更新 VPS;
  • Debian、systemd、UFW:服务器运行和安全;
  • Caddy:HTTPS、反向代理、后台密码和域名跳转;
  • Cloudflare:DNS 管理;
  • Google Search Console:搜索收录;
  • curldig:上线后的检查。

最后回头看

这次最重要的并不是写出了多少代码,而是把整条链路走通了:

从提出需求开始,我先用 Codex 把网站实现出来,再完成本地验证、VPS 准备和临时域名测试;最后接入正式域名与 HTTPS,配置搜索发现,并建立更新和备份流程。

现在我控制着代码、服务器、域名、数据库和内容源文件。以后即使换服务器或换部署平台,文章和整个网站也不会被锁在某个现成博客服务里。

还有几件事值得定期确认:Search Console 是否已经验证、sitemap 是否显示成功、异地备份是否正常,以及 SSH 是否已经限制密码登录和 root 直登。把这些补齐之后,这个站才算真正进入长期维护阶段。