notepub VPS 部署方案

# 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/