# notepub VPS 部署方案
把 `~/notes/` 这个 markdown 目录变成一个公网可访问的网站。
> 本文是部署方案/蓝图,讲清楚架构选型、组件分工、部署步骤、典型坑。
> 当前生产环境的实际状态、命令、路径请见单独的运维文档(如有)。
---
## 整体架构
```
┌─ 你本地 ─────────┐ ┌─ VPS ────────────────────┐
│ │ │ │
│ ~/notes/ │ rsync 同步 │ /var/lib/notepub/notes/ │
│ ├── 写新文章 │ ─────────────▶ │ /usr/local/bin/notepub │
│ └── 改翻译 │ │ │
│ │ │ ┌──────────────────┐ │
│ notepub 源码 │ scp 上传 │ │ systemd 守护进程 │ │
│ └── go build │ ─────────────▶ │ │ ↓ 跑 │ │
│ │ │ │ notepub :8080 │ │
└──────────────────┘ │ │ ↑ 反代 │ │
│ │ Caddy :443 │ │
浏览器 │ └──────────────────┘ │
▲ │ │
└─────│ https://notes.你域名 │
└──────────────────────────┘
```
四个组件配合:
1. **notepub** —— 真正读 markdown、生成网页的程序
2. **systemd** —— 让 notepub 长期跑着,崩了自动重启
3. **Caddy** —— 接管 80/443 端口,处理 HTTPS,把流量转给 notepub
4. **rsync** —— 本地内容同步到 VPS
也可以单机部署(开发和生产同一台机器),把 `scp` / `ssh vps` 这些远程操作换成本地命令即可,整体逻辑不变。
下面逐个讲。
---
## 组件 1:notepub(自写的 Go 二进制)
**它是什么**
一个 HTTP 服务。启动时给它一个 markdown 目录,它就在某个端口(默认 8080)提供这些文档的网页版。
```bash
notepub serve /var/lib/notepub/notes --port 8080
```
**文件规则(统一,无领域硬编码)**
scanner 不知道你的目录是关于什么的(笔记 / 博客 / 日报 / 产品文档),只按下面这套规则吃文件:
| 维度 | 规则 |
|---|---|
| `.md` 标题 | 抽第一个 `# H1`,抽不到 fallback 文件名 |
| `.html` 标题 | 抽 `<title>`,抽不到 fallback 文件名 |
| 双语配对 | `<slug>.en.md` + `<slug>.zh.md` 自动配成一对,带语言切换 |
| 单语文档 | 没语言后缀(如 `xxx.md`)就是 `default`,侧栏不显示语言角标 |
| 根级 `README.md` | scanner 跳过,由 `/` 路由直接渲染为站点首页 |
| 子目录 `README.md` | 抽到 `Tree.DirReadme[dir]`,该分类名变蓝色链接(带 `›` 箭头);没 README 的分类名是普通灰文字 |
| `.html` 文件 | 独立 Doc(无语言配对),iframe 嵌入显示(自带样式不污染主框架) |
| 其他扩展名 | 忽略 |
任何目录扔进去都按同一套规则渲染,加新分类不用改代码。
**内容生产者的责任**
scanner 不替源头补锅。比如 HN 每日报告归档时,生产者(`run_pipeline.py`)负责把 `<title>` 写成 `HN 热榜 · YYYY-MM-DD` 这种唯一可识别的字符串,而不是让 scanner 用文件名特殊化处理。原则:
> 唯一可识别的标题是内容生产者的责任,不是发布工具的责任。
新接归档场景(X 推文 / 博客订阅)只需决定"放哪、文件名怎么取、`<title>` 怎么写",notepub 这边一字不改自动接住。
**视觉与交互**
- 浅色 / 深色 / 跟随系统三档主题(localStorage 持久化,内联脚本防 FOUC)
- 移动端汉堡菜单 + 全屏抽屉(768px 断点切换)
- 源文 / 渲染单按钮 toggle(同一文档两种视图)
- 代码高亮:浅色用 solarized-light,深色用 GitHub-dark,`--code-bg` CSS 变量统一
- chi 路由:`/`、`/view/<slug>`、`/raw/<slug>`、`/html/<slug>`、`/asset/<rel>`、`/static/`、`/healthz`
**它解决什么问题**
markdown 文件在硬盘里不能直接被浏览器渲染。需要一个程序:扫目录、转 HTML、按双语命名约定配对、提供主题 / 视图切换。这些事 mdBook、Docusaurus 这类现成工具都能做一部分,但都不识别 `.en.md / .zh.md` 这种双语命名约定,也不混合 markdown + HTML 文件。所以自己写最省心。
**为什么用 Go**
- 编译出来是单个二进制,部署简单(scp 上去就能跑)
- 跨平台交叉编译方便(本地 Mac 编出 Linux 版本)
- 自带 HTTP 库 + goldmark(markdown 解析)+ chroma(代码高亮)
二进制大约 18MB,跑起来内存几十 MB。
**关键设计:每次请求实时读 markdown**
notepub 不缓存内容。`rsync` 同步后浏览器刷新就是新内容,不用重启服务。这是日常使用最大的便利。
**`build` 模式(可选)**
```bash
notepub build /var/lib/notepub/notes -o ./dist
```
把整站导出成静态 HTML,丢到 GitHub Pages / Cloudflare Pages / 任何静态托管都能直接用。URL 走 `/...` 绝对根路径,跟 serve 模式行为一致。
---
## 组件 2:systemd(进程守护)
**它是什么**
Linux 系统自带的服务管理器。你给它一段配置,它负责把指定程序"当作系统服务跑"。
**它解决什么问题**
如果你 SSH 上 VPS 直接 `notepub serve ...` 跑起来,关掉 SSH 程序就死了。就算用 `nohup` 后台跑,崩溃了也没人重启。
systemd 解决三件事:
1. **开机自启** —— VPS 重启后服务自动起来
2. **崩了自动重启** —— 程序异常退出,systemd 立刻拉起
3. **日志统一管理** —— `journalctl -u notepub` 直接看日志
**配置文件(推荐带安全沙箱)**
`/etc/systemd/system/notepub.service`:
```ini
[Unit]
Description=notepub — markdown notes site
After=network.target
[Service]
Type=simple
User=notepub
Group=notepub
ExecStart=/usr/local/bin/notepub serve /var/lib/notepub/notes --listen 127.0.0.1:8080
Restart=on-failure
RestartSec=3
# 安全沙箱
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/notepub
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
```
监听 `127.0.0.1:8080` 而非 `0.0.0.0:8080`:notepub 只接受本机连接,外面只走 Caddy。多一层防护。
启用:
```bash
systemctl enable --now notepub # 立即启动 + 开机自启
systemctl status notepub # 看运行状态
journalctl -u notepub -f # 实时看日志
```
---
## 组件 3:Caddy(反向代理 + HTTPS)
这是初学者最容易疑惑的一环。详细讲。
**它是什么**
一个 Web 服务器。但我们这里不让它直接服务文件,而是让它做"反向代理"。
**为什么不让 notepub 直接对外**
理论上 notepub 也可以监听 443 端口直接对外服务,但有几个问题:
1. **HTTPS 证书** —— 公网访问必须是 https://(不然浏览器报警)。证书要找 Let's Encrypt 申请,每 90 天续期一次,自己用 Go 写这套逻辑很重
2. **端口权限** —— 80/443 是特权端口,需要 root 才能监听。不想让 notepub 跑在 root 下
3. **多服务并存** —— 以后可能在同一个 VPS 跑别的服务(博客、API),都要 443,得有一个东西分流
4. **静态资源缓存、压缩、限流** —— 这些都是 Web 服务器擅长的事
所以分工:notepub 只在内网跑 8080,外面套一层 Caddy 处理 HTTPS 和公网访问。
**反向代理是什么意思**
```
浏览器 ──https──▶ Caddy:443 ──http──▶ notepub:8080
(公网) (本机)
```
Caddy 收到 `https://notes.your-domain.com` 的请求,把请求内容原样转给本机的 8080 端口,notepub 处理完返回结果,Caddy 再返回给浏览器。"反向"的意思是"代理服务端"(普通代理是代理客户端,比如翻墙)。
**为什么是 Caddy 不是 Nginx**
Nginx 也能做同样的事,但 Caddy 有一个杀手锏:**自动 HTTPS**。
只要域名 DNS 指向 VPS,Caddy 启动时自动从 Let's Encrypt 申请证书、自动续期,零配置。Nginx 要手动配 certbot。
**配置文件**
`/etc/caddy/Caddyfile`:
```caddy
notes.your-domain.com {
reverse_proxy localhost:8080
}
# DNS 没生效前,IP 直访也能用(兜底)
:80 {
@notdomain not host notes.your-domain.com
handle @notdomain {
reverse_proxy localhost:8080
}
}
```
域名块走自动 HTTPS。`:80` 兜底块让你在 DNS 切换期或域名过期时还能用 IP 访问。
```bash
caddy validate --config /etc/caddy/Caddyfile # 改完先验证语法
systemctl reload caddy # 再重载
```
---
## 组件 4:rsync(本地到 VPS 的同步)
**它是什么**
一个文件同步工具。比 scp 聪明:只传变化的部分。
**它解决什么问题**
你在本地 `~/notes/` 写新文章、改翻译。这些变化要怎么到 VPS?
三种选择:
| 方式 | 优点 | 缺点 |
|---|---|---|
| **scp** 整个目录上传 | 简单 | 每次全量传,慢 |
| **git push + git pull** | 有版本历史 | 每次要 commit + ssh 上去 pull,两步 |
| **rsync** | 增量同步,一条命令 | 没有版本历史 |
短期用 rsync 最快。要版本历史可以两个一起用:本地 git commit 留历史,rsync 推 VPS。
**用法**
```bash
rsync -av --delete ~/notes/ vps:/var/lib/notepub/notes/
```
参数解释:
- `-a` 保留权限、时间戳等所有属性
- `-v` 显示传了什么
- `--delete` 本地删了的文件,VPS 上也删(保持镜像一致)
- 末尾的 `/` 很关键 —— `~/notes/` 表示"目录里的内容",没斜杠表示"目录本身"
**进阶:包一层 notepub-sync 脚本**
裸 `rsync` 命令好用但有两个不顺手的地方:
- 没有"先看变更再决定要不要同步"的预览
- 同步后没有自动健康检查
可以包一层脚本:`rsync -ain --delete`(dry-run)→ 让用户看到变更 → 询问 `[y/N]` → 真正 `rsync -a --delete` → `curl /healthz` 验证服务还活着。这就是 `notepub-sync` 工具的本质。值得做,因为它把"上线一次笔记"从两条命令缩成一条带交互的命令。
---
## 一次性部署(按顺序)
假设:
- VPS 是 Ubuntu/Debian
- 已有域名 `notes.example.com`,DNS A 记录指向 VPS IP
- VPS 用户名是 `root` 或有 sudo 权限的用户
### Step 1:本地编译 notepub
```bash
cd /path/to/notepub-source
GOOS=linux GOARCH=amd64 go build -o notepub ./cmd/notepub
```
`GOOS=linux GOARCH=amd64` 让 Mac/Windows 也能编出 Linux 二进制。如果开发和部署在同一台 Linux 机器,省掉这两个环境变量直接 `go build` 也行。
### Step 2:上传二进制和内容到 VPS
```bash
scp notepub vps:/tmp/
ssh vps 'install -m 0755 /tmp/notepub /usr/local/bin/notepub'
ssh vps 'mkdir -p /var/lib/notepub'
rsync -av ~/notes/ vps:/var/lib/notepub/notes/
```
用 `install -m 0755` 而不是 `mv + chmod`:原子写、自动设权限,且如果目标文件正在被运行(systemd 跑着 notepub)也能替换掉,避免 `text file busy`。
### Step 3:在 VPS 上创建专用用户
```bash
ssh vps
useradd -r -s /usr/sbin/nologin -d /var/lib/notepub notepub
chown -R notepub:notepub /var/lib/notepub
```
为什么要专用用户:notepub 服务跑在自己的账号下,不会有任何 root 权限。万一有漏洞也炸不到系统。
### Step 4:装 systemd unit
把上面"组件 2"那段配置写到 `/etc/systemd/system/notepub.service`,然后:
```bash
systemctl daemon-reload
systemctl enable --now notepub
systemctl status notepub
```
最后一条应该看到 `active (running)`。
### Step 5:装 Caddy
⚠️ **Ubuntu/Debian 默认仓库的 Caddy 版本太旧**,要从 cloudsmith 装:
```bash
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null
apt update && apt install -y caddy
caddy version
```
把上面"组件 3"那段 Caddyfile 写到 `/etc/caddy/Caddyfile`,把日志目录权限交给 Caddy:
```bash
mkdir -p /var/log/caddy && chown -R caddy:caddy /var/log/caddy
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
```
Caddy 启动后会自动从 Let's Encrypt 申请证书。第一次可能要等 30 秒。
### Step 6:开防火墙 + 验证
```bash
ufw allow 80/tcp && ufw allow 443/tcp
```
浏览器打开 `https://notes.your-domain.com`,应该看到目录树和文档。DNS 还没生效时用 `http://VPS_IP/` 访问(`:80` 兜底块)。
---
## 日常使用
**写完新文章后同步:**
```bash
rsync -av --delete ~/notes/ vps:/var/lib/notepub/notes/
```
只要这一条。notepub 不缓存内容,下次请求就是新内容。
**改 notepub 代码后更新二进制:**
```bash
GOOS=linux GOARCH=amd64 go build -o notepub ./cmd/notepub
scp notepub vps:/tmp/notepub.new
ssh vps 'install -m 0755 /tmp/notepub.new /usr/local/bin/notepub && systemctl restart notepub'
```
`install` 而不是 `mv`:原子替换,且能覆盖正在被 systemd 运行的二进制(`mv` 会报 `text file busy` 必须先 stop)。
**封装成脚本(推荐):**
```bash
# ~/bin/notepub-deploy
#!/bin/bash
set -e
rsync -av --delete ~/notes/ vps:/var/lib/notepub/notes/
echo "✓ Notes synced"
```
`chmod +x ~/bin/notepub-deploy`,以后写完文章 `notepub-deploy` 一条命令。要更顺手见组件 4 那段"包一层 notepub-sync 脚本"。
---
## 隐私分级
`~/notes/` 里有些工具笔记可能不想公开。三种方案:
### 方案 A:全公开
不需要任何配置。
### 方案 B:子目录白名单(推荐)
让 notepub 启动时只服务指定子目录:
```ini
ExecStart=/usr/local/bin/notepub serve /var/lib/notepub/notes \
--include agents-series \
--include sandbox-tech \
--listen 127.0.0.1:8080
```
`guides/` 整个目录就不会出现在网站上。
### 方案 C:Caddy basic auth 公私分线
```caddy
notes.your-domain.com {
handle /private/* {
basicauth {
you JDJhJDEwJEVCNm...
}
reverse_proxy localhost:8080
}
handle {
reverse_proxy localhost:8080
}
}
```
`/private/*` 路径要密码,其他路径公开。密码哈希用 `caddy hash-password` 生成。
---
## 故障排查
**网站打不开 / 浏览器报 502**
```bash
ssh vps
systemctl status notepub # notepub 跑着没?
journalctl -u notepub -n 50 # 最近 50 行日志
curl localhost:8080/healthz # 本地能访问吗?应返回 ok
```
如果 `curl localhost:8080/healthz` 能通但浏览器不行,问题在 Caddy 或防火墙。
**HTTPS 证书没签下来**
```bash
journalctl -u caddy -n 100 | grep -iE 'error|cert'
```
最常见原因:DNS 还没生效,或者域名指向了别的 IP。可以先用 `:80` 兜底块跑着,等 DNS 生效后 `systemctl reload caddy`。
**改了内容但网站没更新**
```bash
ssh vps 'ls -la /var/lib/notepub/notes/'
```
确认 rsync 真的传到位。如果时间戳没变就是没同步。
---
## 典型踩坑
部署中遇到、值得提前知道的 6 个坑:
1. **Ubuntu 默认仓库的 Caddy 版本太旧**,没法用。从 cloudsmith 装最新稳定版(见 Step 5)。
2. **Caddy 日志目录权限**:`mkdir + chown` 顺序错了会让日志文件 root:root,Caddy 以 caddy 用户跑写不进去启动失败。修:`chown -R caddy:caddy /var/log/caddy`。先创目录再 chown,别先 chown 再 systemctl restart。
3. **覆盖运行中二进制报 `text file busy`**:`cp src dst` 不能覆盖正在被 systemd 运行的二进制。两种解法:(a) 先 `systemctl stop notepub` 再 cp 再 start;(b) 用 `install -m 0755 src dst` —— 原子替换,不阻塞,推荐。
4. **Go 标准 flag 包遇到位置参数停止解析**:`notepub serve /var/lib/notepub/notes --port 8080` 里的 `--port` 会被吞掉(`flag.Parse` 在第一个非 flag 参数处停下)。CLI 实现里要在解析前把 flag 和位置参数分开,或者明确文档要求 `--port 8080 /var/lib/notepub/notes` 这样的顺序。
5. **DNS 没生效就配域名块**:Caddy 启动时如果域名 A 记录还没指过来,会日志报 `automatic HTTPS will not be applied`,域名块没证书。但 `:80` 兜底块照常工作。等 DNS 生效后 `systemctl reload caddy` 一下即可。
6. **rsync itemize 元数据噪音**:第一次部署用 `cp` 而非 `rsync -a` 会丢 mtime/owner,之后任何 `rsync -ain` dry-run 都把全部文件标成 "modified"。这是元数据差异不是内容差异。跑一次完整 `rsync -a` 把元数据对齐就清掉了。
---
## VPS 选型
| 服务商 | 价格 | 推荐 |
|---|---|---|
| Hetzner CX11 | €4/月(1C 2G) | 性价比最高 |
| Vultr Regular | $6/月(1C 1G) | 节点多 |
| DigitalOcean | $4/月(1C 0.5G) | 文档好 |
| 阿里云 ECS | ¥30/月起 | 国内访问快 |
notepub 实际只吃几十 MB 内存,最便宜的就够。
---
## 成本一览
| 项目 | 成本 |
|---|---|
| VPS | €4/月 |
| 域名 | $10/年 |
| HTTPS 证书 | 免费(Caddy + Let's Encrypt) |
| 流量 | VPS 自带,纯文档站撑得住 |
总计约 €5/月。
---
## 参考链接
- notepub 项目(暂未公开)
- Caddy 文档:https://caddyserver.com/docs/
- systemd unit 安全加固:https://www.freedesktop.org/software/systemd/man/systemd.exec.html
- Let's Encrypt:https://letsencrypt.org/