Web 编辑器 Docker 部署#
实现方案: VPS 自托管 Hono 后端 + Vditor 前端, 走 clone-push 流程, Docker 容器化, 反代到
editor.kylaan.cn目标读者: Kylaan, 跟着敲就上线 撰写时间: 2026-05-23
你部署完之后会拥有#
- 子域 https://editor.kylaan.cn ↗ (HTTPS + Basic Auth 保护)
- 一个 Docker 容器
blog-editor, 24/7 跑着, 自动重启 - 浏览器开页 → 写文章 → 一键发布 → 1-2 分钟后 https://kylaan.cn ↗ 看到新文章
- 一份 VPS 上的 git 工作区 (容器内), 跟 GitHub 双向同步
- 草稿暂存 + 已发文章列表
- 移动端可用 (Vditor 自带响应式)
时长预估#
| Phase | 内容 | 时间 |
|---|---|---|
| 0 | DNS + 服务器准备 | 10 分钟 |
| 1 | 生成 VPS push SSH key | 5 分钟 |
| 2 | 宝塔加站 + SSL | 10 分钟 |
| 3 | 写项目目录 + 配置文件 | 20 分钟 (复制粘贴) |
| 4 | nginx 反代 + Basic Auth | 10 分钟 |
| 5 | 启动 + 验证 | 10 分钟 |
| 总计 | ~65 分钟 |
数据流回顾#
[浏览器: editor.kylaan.cn]
│ HTTPS + Basic Auth
▼
[宝塔 nginx 反代]
│
▼ 127.0.0.1:3001
[Docker 容器: blog-editor]
│
├── Hono 后端 (服务 API + 静态前端)
├── 挂载: /app/workspace = git clone of blog repo
├── 挂载: /app/drafts = 草稿暂存
└── 挂载: /root/.ssh = GitHub push key (只读)
│
│ 容器内: git pull → 写文件 → spawn publish.mjs → git push
▼
[GitHub repo: Kylaan/blog-asrto]
│
│ push 触发 .github/workflows/deploy.yml
▼
[GitHub Actions runner]
│ pnpm install + astro build + rsync
▼
[/www/wwwroot/kylaan.cn] ──nginx serve──► [访客看到新文章]plaintextPhase 0: 前置准备#
0.1 添加 DNS A 记录#
去你的域名 DNS 控制台 (阿里云/Cloudflare/腾讯云/…) 添加:
| 记录类型 | 主机名 | 值 |
|---|---|---|
| A | editor | <你的服务器公网 IP> |
主机名 = editor, 即完整域名 editor.kylaan.cn。
等 1-5 分钟后验证:
# Windows
nslookup editor.kylaan.cn
# 期望 Address 是你的服务器 IPpowershellDNS 没生效就不要往下做, 否则 SSL 申请会失败。
0.2 确认 Docker 已装好#
SSH 进服务器:
ssh root@<服务器IP>powershell检查 Docker:
docker --version
docker compose versionbash- 都返回版本号 → 已装好, 继续
- 提示 command not found → 在宝塔面板里: 软件商店 → Docker 管理器 → 安装, 选最新稳定版
Phase 1: 生成 VPS 用的 GitHub push SSH key#
这是新的一对 key (我们叫 Key ⑤), 跟之前 Actions 用的 Key ② 完全独立。 Key ② 让 Actions runner 进你服务器, Key ⑤ 让你服务器 push 到 GitHub。职责不同, 必须分开。
1.1 在服务器上生成 key#
# 还在 root SSH 会话里
mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keygen -t ed25519 -C "blog-editor-vps-push" -f ~/.ssh/blog_webapp -N ""
# -N "" = passphrase 空; 容器内不能交互输入密码bash生成两个文件:
~/.ssh/blog_webapp— 私钥, 等下会被容器以只读方式挂载使用~/.ssh/blog_webapp.pub— 公钥, 等下贴 GitHub
chmod 600 ~/.ssh/blog_webapp
chmod 644 ~/.ssh/blog_webapp.pubbash1.2 贴公钥到 GitHub#
打印公钥内容:
cat ~/.ssh/blog_webapp.pub
# 输出形如: ssh-ed25519 AAAAC3...xyz blog-editor-vps-pushbash整行复制 (从 ssh-ed25519 到末尾的注释), 然后:
- 浏览器打开 https://github.com/settings/keys ↗
- 点 New SSH key
- Title:
blog-editor-vps-push - Key type: Authentication Key (不是 Signing Key!)
- Key 框里粘贴上面那一整行
- Add SSH key
注意: 这是 personal SSH key (绑你 GitHub 账号), 不是 deploy key。因为我们要 push 到私库, deploy key 只能针对单个仓库, personal SSH key 拥有你账号下所有仓库的 push 权限。后续如果想收紧, 可以改成给单仓的 deploy key (本仓库 Settings → Deploy keys → Add deploy key, 勾 “Allow write access”)。
1.3 测连通#
在服务器上:
ssh -i ~/.ssh/blog_webapp -o IdentitiesOnly=yes -T git@github.com
# 期望输出: Hi Kylaan! You've successfully authenticated, but GitHub does not provide shell access.bash输错了的话:
| 报错 | 原因 | 修法 |
|---|---|---|
Permission denied (publickey) | 公钥没贴 / 贴错账号 | 重做 1.2 |
Host key verification failed | 第一次连 github.com | 输 yes 接受即可 |
Bad owner or permissions | key 文件权限 | chmod 600 ~/.ssh/blog_webapp |
Phase 2: 宝塔加 editor 子站 + SSL#
2.1 添加站点#
宝塔面板 → 网站 → 添加站点:
| 字段 | 值 |
|---|---|
| 域名 | editor.kylaan.cn |
| 根目录 | /www/wwwroot/editor.kylaan.cn (默认即可, 反代不会用到这个目录) |
| PHP 版本 | 纯静态 |
| 数据库 | 不创建 |
提交。
2.2 申请 SSL#
刚加完的站点 → 设置 → SSL → Let’s Encrypt:
- 勾选
editor.kylaan.cn(只勾这一个, 不要勾 www) - 申请
等 30 秒, 提示证书已下发。然后:
- 开启 强制 HTTPS
如果申请失败 “DNS validation failed”, 回 Phase 0.1 等 DNS 再生效几分钟。
2.3 生成 Basic Auth 密码文件#
服务器上:
# 装 htpasswd 工具
apt update && apt install -y apache2-utils
# 生成密码文件
htpasswd -c /www/server/nginx/conf/.htpasswd_editor kylaan
# 提示输入密码两次, 用强密码 (字母数字符号 12+ 位)
# 例如: blog-editor-2026!Xy7bash保存密码到你自己的密码管理器, 这是访问编辑器要输的密码。
测试:
cat /www/server/nginx/conf/.htpasswd_editor
# 输出形如: kylaan:$apr1$xyz...$ABCDEbashPhase 3: 项目目录 + 所有配置文件#
3.1 建目录结构#
mkdir -p /www/docker/blog-editor/{server,frontend,workspace,drafts,ssh}
cd /www/docker/blog-editorbash3.2 把 SSH key 复制 (硬链接) 到容器挂载目录#
cp ~/.ssh/blog_webapp ./ssh/blog_webapp
cp ~/.ssh/blog_webapp.pub ./ssh/blog_webapp.pub
chmod 600 ./ssh/blog_webapp
chmod 644 ./ssh/blog_webapp.pubbash为什么不直接挂
~/.ssh? 因为里面可能还有你 root 本人的其它 key, 把整个目录暴露给容器太宽。单独建子目录只挂这把。
3.3 写 docker-compose.yml#
nano /www/docker/blog-editor/docker-compose.ymlbash内容:
services:
blog-editor:
build: .
image: blog-editor:latest
container_name: blog-editor
restart: unless-stopped
ports:
- "127.0.0.1:3001:3001" # 只绑 loopback, 强制走 nginx
volumes:
- ./workspace:/app/workspace # blog repo 工作区 (持久)
- ./drafts:/app/drafts # 草稿 (持久)
- ./ssh:/root/.ssh:ro # SSH key 只读挂载
environment:
TZ: 'Asia/Shanghai'
NODE_ENV: production
PORT: 3001
WORKSPACE_PATH: /app/workspace
GIT_REMOTE: 'git@github.com:Kylaan/blog-asrto.git'
GIT_BRANCH: 'main'
GIT_USER_NAME: 'Blog Editor (kylaan.cn)'
GIT_USER_EMAIL: 'editor@kylaan.cn'
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60syamlCtrl+O 保存, Ctrl+X 退出。
3.4 写 Dockerfile#
nano /www/docker/blog-editor/Dockerfilebash内容:
FROM node:20-alpine
# 装容器内要的工具: git + ssh + tini (PID 1 信号处理)
RUN apk add --no-cache git openssh-client tini
WORKDIR /app
# 先装后端依赖 (Hono + 一些 helpers)
COPY server/package.json ./server/package.json
RUN cd server && npm install --omit=dev
# 拷后端源码 + 前端静态文件
COPY server/ ./server/
COPY frontend/ ./frontend/
# entrypoint 处理首次 clone / git config / 启动
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# 预先把 github.com 的 host key 写好, 避免容器内 ssh 提示
RUN mkdir -p /root/.ssh && \
ssh-keyscan github.com >> /root/.ssh/known_hosts && \
chmod 644 /root/.ssh/known_hosts
ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"]dockerfile3.5 写 entrypoint.sh#
nano /www/docker/blog-editor/entrypoint.shbash内容:
#!/bin/sh
set -e
echo "[entrypoint] starting blog-editor container..."
# 1. 配置 git 身份
git config --global user.name "${GIT_USER_NAME:-Blog Editor}"
git config --global user.email "${GIT_USER_EMAIL:-editor@kylaan.cn}"
git config --global core.sshCommand "ssh -i /root/.ssh/blog_webapp -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
git config --global pull.rebase true
git config --global init.defaultBranch main
# 2. 首次启动 clone repo
if [ ! -d "${WORKSPACE_PATH}/.git" ]; then
echo "[entrypoint] cloning ${GIT_REMOTE} into ${WORKSPACE_PATH}..."
rm -rf "${WORKSPACE_PATH:?}"/* 2>/dev/null || true
git clone --depth=30 -b "${GIT_BRANCH:-main}" "${GIT_REMOTE}" "${WORKSPACE_PATH}"
else
echo "[entrypoint] workspace already exists, pulling latest..."
cd "${WORKSPACE_PATH}" && git pull --rebase --autostash || echo "[entrypoint] pull failed, will retry on first publish"
fi
# 3. 启动 server
cd /app/server
echo "[entrypoint] starting Hono server on :${PORT:-3001}"
exec node server.mjsbash3.6 写 server/package.json#
nano /www/docker/blog-editor/server/package.jsonbash内容:
{
"name": "blog-editor-server",
"version": "0.1.0",
"type": "module",
"main": "server.mjs",
"dependencies": {
"hono": "^4.6.0",
"@hono/node-server": "^1.13.0"
}
}json3.7 写 server/server.mjs (后端核心)#
nano /www/docker/blog-editor/server/server.mjsbash内容:
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { exec as execCb, spawn } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs/promises'
import path from 'node:path'
import { Buffer } from 'node:buffer'
const exec = promisify(execCb)
const WORKSPACE = process.env.WORKSPACE_PATH || '/app/workspace'
const DRAFTS_DIR = '/app/drafts'
const BLOG_DIR = path.join(WORKSPACE, 'src/content/blog')
const PORT = parseInt(process.env.PORT || '3001', 10)
const REPO_URL = (process.env.GIT_REMOTE || '').replace(/^git@github\.com:/, 'https://github.com/').replace(/\.git$/, '')
const app = new Hono()
// ===== 0. 健康检查 =====
app.get('/api/health', (c) => c.json({ ok: true, time: new Date().toISOString() }))
// ===== 1. 列出所有文章 =====
app.get('/api/posts', async (c) => {
try {
const entries = await fs.readdir(BLOG_DIR, { withFileTypes: true })
const posts = []
for (const e of entries) {
if (!e.isDirectory()) continue
const idxPath = path.join(BLOG_DIR, e.name, 'index.mdx')
try {
const raw = await fs.readFile(idxPath, 'utf-8')
const fm = parseFrontmatter(raw)
posts.push({
slug: e.name,
title: fm.title || e.name,
publishDate: fm.publishDate || '',
draft: fm.draft === true || fm.draft === 'true',
pinned: fm.pinned === true || fm.pinned === 'true'
})
} catch {}
}
posts.sort((a, b) => (b.publishDate || '').localeCompare(a.publishDate || ''))
return c.json(posts)
} catch (err) {
return c.json({ error: err.message }, 500)
}
})
// ===== 2. 取单篇文章 (用于编辑) =====
app.get('/api/posts/:slug', async (c) => {
const slug = c.req.param('slug')
if (!/^[a-z0-9-]+$/i.test(slug)) return c.json({ error: 'invalid slug' }, 400)
const idxPath = path.join(BLOG_DIR, slug, 'index.mdx')
try {
const raw = await fs.readFile(idxPath, 'utf-8')
const { fm, body } = splitFrontmatter(raw)
return c.json({ slug, frontmatter: fm, body })
} catch {
return c.json({ error: 'not found' }, 404)
}
})
// ===== 3. 发布 (新建或更新) =====
app.post('/api/publish', async (c) => {
const body = await c.req.json().catch(() => ({}))
const { slug, frontmatter, content, mode = 'create' } = body
// 基本校验
if (!slug) return c.json({ error: 'slug 必填' }, 400)
if (!/^[a-z0-9-]+$/i.test(slug)) return c.json({ error: 'slug 只能字母/数字/-' }, 400)
if (!frontmatter?.title) return c.json({ error: 'title 必填' }, 400)
if (!frontmatter?.description) return c.json({ error: 'description 必填' }, 400)
if (!frontmatter?.publishDate) return c.json({ error: 'publishDate 必填' }, 400)
// 拉最新 (防本地端先 push 了)
try {
await exec('git pull --rebase --autostash', { cwd: WORKSPACE })
} catch (err) {
return c.json({ error: 'git pull 失败: ' + (err.stderr || err.message) }, 500)
}
// 写文件
const dir = path.join(BLOG_DIR, slug)
await fs.mkdir(dir, { recursive: true })
const mdx = buildMdx(frontmatter, content || '')
await fs.writeFile(path.join(dir, 'index.mdx'), mdx, 'utf-8')
// 调 publish.mjs (复用本地一键发布脚本)
const commitMsg = mode === 'create'
? `feat: 新文章《${frontmatter.title}》`
: `docs: 更新《${frontmatter.title}》`
return new Promise((resolve) => {
const child = spawn('node', [
path.join(WORKSPACE, 'scripts/publish.mjs'),
'-m', commitMsg,
'--no-check'
], {
cwd: WORKSPACE,
env: { ...process.env, PUBLISH_AUTO_YES: '1' }
})
let out = '', err = ''
child.stdout.on('data', d => { out += d.toString() })
child.stderr.on('data', d => { err += d.toString() })
child.on('exit', (code) => {
resolve(c.json({
ok: code === 0,
code,
stdout: out,
stderr: err,
actionsUrl: REPO_URL ? REPO_URL + '/actions' : null
}, code === 0 ? 200 : 500))
})
child.on('error', (e) => {
resolve(c.json({ ok: false, error: 'spawn failed: ' + e.message }, 500))
})
})
})
// ===== 4. 图片上传 (Vditor 用) =====
app.post('/api/upload', async (c) => {
const slug = c.req.query('slug')
if (!slug || !/^[a-z0-9-]+$/i.test(slug)) {
return c.json({ msg: '请先填 slug', code: -1, data: { errFiles: [], succMap: {} } }, 400)
}
const formData = await c.req.formData()
const files = formData.getAll('file[]')
const dir = path.join(BLOG_DIR, slug)
await fs.mkdir(dir, { recursive: true })
const succMap = {}
const errFiles = []
for (const f of files) {
if (!(f instanceof File)) { errFiles.push('not-a-file'); continue }
const safeName = f.name.replace(/[^a-zA-Z0-9._-]/g, '_')
const buf = Buffer.from(await f.arrayBuffer())
await fs.writeFile(path.join(dir, safeName), buf)
succMap[f.name] = `./${safeName}`
}
return c.json({ msg: '', code: 0, data: { errFiles, succMap } })
})
// ===== 5. 手动同步 (从 GitHub pull) =====
app.post('/api/sync', async (c) => {
try {
const { stdout } = await exec('git pull --rebase --autostash', { cwd: WORKSPACE })
return c.json({ ok: true, output: stdout })
} catch (err) {
return c.json({ ok: false, error: err.stderr || err.message }, 500)
}
})
// ===== 6. 草稿 (本地暂存, 不进 git) =====
app.post('/api/drafts/:slug', async (c) => {
const slug = c.req.param('slug') || 'untitled'
const safe = slug.replace(/[^a-zA-Z0-9-]/g, '_')
const data = await c.req.json()
await fs.mkdir(DRAFTS_DIR, { recursive: true })
await fs.writeFile(path.join(DRAFTS_DIR, safe + '.json'), JSON.stringify(data), 'utf-8')
return c.json({ ok: true })
})
app.get('/api/drafts/:slug', async (c) => {
const slug = c.req.param('slug')
const safe = slug.replace(/[^a-zA-Z0-9-]/g, '_')
try {
const raw = await fs.readFile(path.join(DRAFTS_DIR, safe + '.json'), 'utf-8')
return c.json(JSON.parse(raw))
} catch {
return c.json({ error: 'no draft' }, 404)
}
})
// ===== 静态前端 =====
app.use('/*', serveStatic({ root: '../frontend' }))
serve({ fetch: app.fetch, port: PORT, hostname: '0.0.0.0' }, (info) => {
console.log(`Blog Editor listening on http://0.0.0.0:${info.port}`)
})
// ===== helpers =====
function parseFrontmatter(raw) {
const m = raw.match(/^---\s*\n([\s\S]*?)\n---/)
if (!m) return {}
const fm = {}
for (const line of m[1].split('\n')) {
const lm = line.match(/^([a-zA-Z]\w*):\s*(.*)$/)
if (!lm) continue
let val = lm[2].trim()
if (val.startsWith("'") && val.endsWith("'")) val = val.slice(1, -1)
else if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1)
if (val === 'true') val = true
else if (val === 'false') val = false
fm[lm[1]] = val
}
return fm
}
function splitFrontmatter(raw) {
const m = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)/)
if (!m) return { fm: {}, body: raw }
return { fm: parseFrontmatter(raw), body: m[2] }
}
function buildMdx(fm, body) {
const lines = ['---']
for (const [k, v] of Object.entries(fm)) {
if (v === undefined || v === null || v === '') continue
if (Array.isArray(v)) {
lines.push(`${k}: [${v.map(x => JSON.stringify(x)).join(', ')}]`)
} else if (typeof v === 'boolean' || typeof v === 'number') {
lines.push(`${k}: ${v}`)
} else if (typeof v === 'object') {
// heroImage 等嵌套
lines.push(`${k}:`)
for (const [k2, v2] of Object.entries(v)) {
if (v2 === undefined || v2 === null || v2 === '') continue
if (typeof v2 === 'boolean') lines.push(` ${k2}: ${v2}`)
else lines.push(` ${k2}: '${String(v2).replace(/'/g, "''")}'`)
}
} else if (k === 'publishDate' || k === 'updatedDate') {
lines.push(`${k}: ${v}`) // date 不加引号
} else {
lines.push(`${k}: '${String(v).replace(/'/g, "''")}'`)
}
}
lines.push('---')
lines.push('')
lines.push(body)
return lines.join('\n')
}js3.8 写 frontend/index.html (前端)#
nano /www/docker/blog-editor/frontend/index.htmlbash内容:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog Editor — kylaan.cn</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vditor@3.10.7/dist/index.css">
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 16px; max-width: 1400px; margin: 0 auto; color: #222; }
h1 { margin: 0 0 12px; font-size: 20px; }
.layout { display: grid; grid-template-columns: 220px 1fr; gap: 16px; }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
aside { border-right: 1px solid #eee; padding-right: 12px; }
aside h3 { font-size: 14px; margin: 12px 0 6px; color: #555; }
aside ul { list-style: none; padding: 0; margin: 0; }
aside li { padding: 4px 0; font-size: 13px; cursor: pointer; }
aside li:hover { background: #f5f5f5; }
aside li.active { font-weight: bold; color: #0066cc; }
aside small { color: #888; font-size: 11px; display: block; }
.meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 12px; padding: 10px; background: #fafafa; border: 1px solid #eee; border-radius: 4px; margin-bottom: 12px; }
.meta label { display: flex; flex-direction: column; font-size: 12px; color: #555; gap: 2px; }
.meta input, .meta select { padding: 6px 8px; border: 1px solid #ddd; border-radius: 3px; font: inherit; }
.meta input:focus, .meta select:focus { outline: none; border-color: #0066cc; }
.actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
button { padding: 8px 14px; border: 0; border-radius: 4px; cursor: pointer; font-size: 13px; }
.primary { background: #0066cc; color: #fff; }
.primary:hover { background: #0052a3; }
.secondary { background: #eee; color: #333; }
.secondary:hover { background: #ddd; }
#status { margin-top: 10px; padding: 8px 12px; border-radius: 4px; font-size: 13px; min-height: 30px; }
#status.ok { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
#status.err { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
#status pre { font-size: 11px; max-height: 200px; overflow: auto; margin: 4px 0 0; white-space: pre-wrap; }
#status a { color: inherit; text-decoration: underline; }
</style>
</head>
<body>
<h1>📝 Blog Editor <small style="font-size:12px;color:#888">kylaan.cn</small></h1>
<div class="layout">
<aside>
<button class="primary" style="width:100%" onclick="newPost()">+ 新建文章</button>
<h3>已发布</h3>
<ul id="post-list"></ul>
</aside>
<main>
<div class="meta">
<label>slug (URL) <input id="f-slug" placeholder="random-walk"></label>
<label>title (标题) <input id="f-title" placeholder="≤60 字"></label>
<label style="grid-column:1/-1">description (摘要) <input id="f-description" placeholder="≤160 字"></label>
<label>publishDate <input id="f-publishDate" type="date"></label>
<label>tags (逗号分隔) <input id="f-tags" placeholder="数学,概率"></label>
<label>heroImage.src <input id="f-heroSrc" placeholder="./cover.png"></label>
<label>language <input id="f-language" value="zh-CN"></label>
<label>draft
<select id="f-draft"><option value="false">false (公开)</option><option value="true">true (草稿)</option></select>
</label>
<label>pinned
<select id="f-pinned"><option value="false">false</option><option value="true">true (置顶)</option></select>
</label>
</div>
<div id="editor"></div>
<div class="actions">
<button class="primary" onclick="publish()">🚀 发布 (push + 自动部署)</button>
<button class="secondary" onclick="syncPull()">🔄 从 GitHub 同步</button>
<button class="secondary" onclick="saveDraft()">💾 存草稿 (本地)</button>
</div>
<div id="status">就绪</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/vditor@3.10.7/dist/index.min.js"></script>
<script>
let vditor, currentSlug = null
document.addEventListener('DOMContentLoaded', () => {
vditor = new Vditor('editor', {
height: 600,
mode: 'sv',
cache: { enable: false },
toolbarConfig: { pin: true },
placeholder: '开始写...',
preview: {
math: { engine: 'KaTeX', inlineDigit: true },
markdown: { mark: true, footnotes: true, gfmAutoLink: true }
},
upload: {
accept: 'image/*',
fieldName: 'file[]',
multiple: true,
url: () => '/api/upload?slug=' + (document.getElementById('f-slug').value.trim() || 'tmp'),
format(files, responseText) { return responseText }
},
toolbar: ['emoji', 'headings', 'bold', 'italic', 'strike', 'link', '|',
'list', 'ordered-list', 'check', 'outdent', 'indent', '|',
'quote', 'line', 'code', 'inline-code', 'insert-before', 'insert-after', '|',
'upload', 'table', '|',
'undo', 'redo', '|',
'fullscreen', 'edit-mode', { name: 'more', toolbar: ['both', 'code-theme', 'content-theme', 'export', 'outline', 'preview', 'devtools', 'info', 'help'] }]
})
loadList()
setDefaultDate()
})
function setDefaultDate() {
document.getElementById('f-publishDate').value = new Date().toISOString().slice(0, 10)
}
async function loadList() {
try {
const r = await fetch('/api/posts')
const posts = await r.json()
const ul = document.getElementById('post-list')
ul.innerHTML = ''
posts.forEach(p => {
const li = document.createElement('li')
li.className = currentSlug === p.slug ? 'active' : ''
li.innerHTML = `${p.draft ? '📄' : (p.pinned ? '📌' : '📝')} ${escapeHtml(p.title)}<small>${p.publishDate || ''} · ${p.slug}</small>`
li.onclick = () => loadPost(p.slug)
ul.appendChild(li)
})
} catch (e) {
setStatus('加载列表失败: ' + e.message, 'err')
}
}
async function loadPost(slug) {
try {
const r = await fetch('/api/posts/' + slug)
if (!r.ok) throw new Error(await r.text())
const data = await r.json()
currentSlug = slug
document.getElementById('f-slug').value = slug
const fm = data.frontmatter
document.getElementById('f-title').value = fm.title || ''
document.getElementById('f-description').value = fm.description || ''
document.getElementById('f-publishDate').value = fm.publishDate || ''
document.getElementById('f-tags').value = Array.isArray(fm.tags) ? fm.tags.join(', ') : (fm.tags || '')
document.getElementById('f-heroSrc').value = (fm.heroImage && fm.heroImage.src) || ''
document.getElementById('f-language').value = fm.language || 'zh-CN'
document.getElementById('f-draft').value = String(fm.draft === true || fm.draft === 'true')
document.getElementById('f-pinned').value = String(fm.pinned === true || fm.pinned === 'true')
vditor.setValue(data.body || '')
setStatus('已加载 ' + slug + ' (修改后点发布会覆盖)', 'ok')
loadList()
} catch (e) {
setStatus('加载失败: ' + e.message, 'err')
}
}
function newPost() {
currentSlug = null
;['slug', 'title', 'description', 'tags', 'heroSrc'].forEach(k => document.getElementById('f-' + k).value = '')
document.getElementById('f-language').value = 'zh-CN'
document.getElementById('f-draft').value = 'false'
document.getElementById('f-pinned').value = 'false'
setDefaultDate()
vditor.setValue('# 标题\n\n开始写...\n')
setStatus('准备写新文章', 'ok')
loadList()
}
function collectFrontmatter() {
const get = id => document.getElementById('f-' + id).value.trim()
const fm = {
title: get('title'),
description: get('description'),
publishDate: get('publishDate'),
language: get('language') || 'zh-CN',
draft: get('draft') === 'true',
pinned: get('pinned') === 'true'
}
const tags = get('tags').split(',').map(s => s.trim()).filter(Boolean)
if (tags.length) fm.tags = tags
const hero = get('heroSrc')
if (hero) fm.heroImage = { src: hero, inferSize: hero.startsWith('http') }
return fm
}
async function publish() {
const slug = document.getElementById('f-slug').value.trim()
if (!slug) return setStatus('slug 必填', 'err')
if (!/^[a-z0-9-]+$/i.test(slug)) return setStatus('slug 只能字母/数字/-', 'err')
const fm = collectFrontmatter()
if (!fm.title) return setStatus('title 必填', 'err')
if (!fm.description) return setStatus('description 必填', 'err')
if (fm.title.length > 60) return setStatus('title ≤60 字', 'err')
if (fm.description.length > 160) return setStatus('description ≤160 字', 'err')
const content = vditor.getValue()
setStatus('⏳ 发布中... (git push → GitHub Actions → rsync, 总共 1-2 分钟)', 'ok')
try {
const r = await fetch('/api/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug, frontmatter: fm, content,
mode: currentSlug === slug ? 'update' : 'create'
})
})
const data = await r.json()
if (data.ok) {
currentSlug = slug
const link = data.actionsUrl ? `<a href="${data.actionsUrl}" target="_blank">查看 Actions 进度</a>` : ''
setStatus(`✓ 已 push! ${link}<pre>${escapeHtml(data.stdout || '')}</pre>`, 'ok')
loadList()
} else {
setStatus(`✗ 失败 (exit ${data.code})\n${data.stderr || data.error || ''}\n${data.stdout || ''}`, 'err')
}
} catch (e) {
setStatus('网络错误: ' + e.message, 'err')
}
}
async function syncPull() {
setStatus('⏳ 从 GitHub pull...', 'ok')
try {
const r = await fetch('/api/sync', { method: 'POST' })
const data = await r.json()
if (data.ok) {
setStatus(`✓ 已同步<pre>${escapeHtml(data.output || '')}</pre>`, 'ok')
loadList()
} else {
setStatus('✗ ' + (data.error || ''), 'err')
}
} catch (e) {
setStatus('网络错误: ' + e.message, 'err')
}
}
async function saveDraft() {
const slug = document.getElementById('f-slug').value.trim() || 'untitled'
try {
const r = await fetch('/api/drafts/' + slug, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
frontmatter: collectFrontmatter(),
body: vditor.getValue(),
savedAt: new Date().toISOString()
})
})
const data = await r.json()
setStatus(data.ok ? '💾 草稿已存到 VPS (重启容器不丢)' : '✗ ' + data.error, data.ok ? 'ok' : 'err')
} catch (e) {
setStatus('草稿保存失败: ' + e.message, 'err')
}
}
function setStatus(msg, kind) {
const el = document.getElementById('status')
el.innerHTML = msg
el.className = kind || ''
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
}
</script>
</body>
</html>html3.9 检查目录结构#
cd /www/docker/blog-editor
ls -labash期望看到:
docker-compose.yml
Dockerfile
entrypoint.sh
server/
package.json
server.mjs
frontend/
index.html
workspace/ # 空, 容器启动时 clone
drafts/ # 空
ssh/
blog_webapp
blog_webapp.pubplaintextPhase 4: nginx 反代 + Basic Auth#
4.1 找到 editor 站点的 nginx 配置文件#
ls /www/server/panel/vhost/nginx/
# 看到 editor.kylaan.cn.confbash4.2 编辑反代#
宝塔面板 → 网站 → editor.kylaan.cn → 配置文件, 或者命令行:
nano /www/server/panel/vhost/nginx/editor.kylaan.cn.confbash找到 server { ... } 块 (HTTPS 那个, 监听 443), 在里面 删掉/注释掉 默认的 location / 块, 替换成:
location / {
# Basic Auth: 输 kylaan + 你 Phase 2.3 设的密码
auth_basic "Blog Editor (Kylaan only)";
auth_basic_user_file /www/server/nginx/conf/.htpasswd_editor;
# 反代到容器
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 编辑器粘贴图片可能大一些
client_max_body_size 20M;
# 长轮询/上传可能慢, 给充裕的超时
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# 限流防暴力破解 Basic Auth
limit_req_zone $binary_remote_addr zone=editor_login:10m rate=5r/s;
location = / {
limit_req zone=editor_login burst=10 nodelay;
}nginx注意:
limit_req_zone一般要写在http块, 写在server块某些 nginx 版本会报错。如果保存后 nginx 报错, 把那两行limit_req_zone+limit_req删掉, 暂时不限流, 等问题不大。
保存。宝塔会自动 nginx -t && nginx -s reload。报错就看顶部 banner。
4.3 验证 nginx 配置 (不启容器先试一下 auth)#
curl -I https://editor.kylaan.cn/
# 期望: HTTP/2 401 ← 因为还没输密码, 但 Basic Auth 已经生效
curl -I -u kylaan:你的密码 https://editor.kylaan.cn/
# 期望: HTTP/2 502 ← auth 过了, 但后端 (容器) 还没起所以 bad gatewaybash到这一步 nginx 这一层就 OK 了。
Phase 5: 启动!#
5.1 build + 启动容器#
cd /www/docker/blog-editor
docker compose up -d --buildbash第一次 build 慢 (装 git/ssh/node 包), 大约 1-3 分钟。
5.2 看日志#
docker logs -f blog-editorbash期望看到顺序:
[entrypoint] starting blog-editor container...
[entrypoint] cloning git@github.com:Kylaan/blog-asrto.git into /app/workspace...
Cloning into '/app/workspace'...
remote: Enumerating objects: ...
[entrypoint] starting Hono server on :3001
Blog Editor listening on http://0.0.0.0:3001plaintextCtrl+C 退出 log (容器仍在跑)。
5.3 验证容器是否健康#
docker ps
# 看 blog-editor 状态 STATUS = "Up X minutes (healthy)"
# (healthy 标记要等 60 秒, 因为 start_period: 60s)
# 容器内直接打健康检查
docker exec blog-editor wget -qO- http://localhost:3001/api/health
# 期望: {"ok":true,"time":"2026-..."}bash5.4 验证通过 nginx 也能到#
curl -u kylaan:你的密码 https://editor.kylaan.cn/api/health
# 期望: {"ok":true,"time":"..."}bash5.5 浏览器打开#
访问 https://editor.kylaan.cn ↗, 浏览器弹原生 Basic Auth 对话框, 输 kylaan + 你的密码。
进入后应能看到:
- 左侧: “已发布” 列表 (你现有的文章们)
- 中间: 编辑器 + frontmatter 表单 + 发布按钮
- 上方标题
📝 Blog Editor
Phase 6: 第一次发文测试#
6.1 写一篇测试文章#
- 点 + 新建文章
- 填:
- slug:
editor-test-1 - title:
编辑器测试文章 - description:
从 web 编辑器发的第一篇 - publishDate: 今天 (已自动填)
- tags:
测试
- slug:
- 编辑器里随便写点东西, 比如:
plaintext# 测试 这是从 https://editor.kylaan.cn 发出来的第一篇文章。 $E=mc^2$ - 点 🚀 发布
6.2 观察发布全流程#
界面上会显示:
⏳ 发布中... (git push → GitHub Actions → rsync, 总共 1-2 分钟)plaintext然后 5-10 秒后变成:
✓ 已 push! 查看 Actions 进度
(stdout 日志)plaintext点 “查看 Actions 进度” 打开 GitHub → Actions, 看新触发的 run。
6.3 验证全链路#
- GitHub 上有新 commit: 仓库首页能看到
feat: 新文章《编辑器测试文章》的最新 commit - GitHub Actions 跑起来: Deploy to Server run, 5 step 全绿 (1-2 分钟)
- rsync 完成: 服务器上
ls /www/wwwroot/kylaan.cn/blog/能看到editor-test-1 - 网站可见: 浏览器开 https://kylaan.cn/blog/editor-test-1 ↗, 文章页正常显示
通了 = 全链路工作 ✅
维护 Cheat Sheet#
cd /www/docker/blog-editor
# 看实时日志
docker logs -f blog-editor
# 重启容器 (改了 .env 或想强制重启)
docker compose restart
# 升级镜像 (改了 Dockerfile / server / frontend)
docker compose up -d --build
# 停掉
docker compose down
# 进容器 shell 排错
docker exec -it blog-editor sh
# 在容器里:
cd /app/workspace
git status
git log --oneline -5
# 查看 workspace 文件
ls workspace/src/content/blog/
# 查看草稿
ls drafts/
# 备份 (停容器后做)
docker compose down
tar -czf blog-editor-backup-$(date +%Y%m%d).tar.gz workspace drafts ssh docker-compose.yml Dockerfile entrypoint.sh server frontend
docker compose up -d
# 重置工作区 (假如同步混乱了)
docker compose down
rm -rf workspace/*
rm -rf workspace/.git
docker compose up -d # entrypoint 会自动重新 clone
# 改密码
htpasswd /www/server/nginx/conf/.htpasswd_editor kylaan
# (不要带 -c, 否则会覆盖)
nginx -s reloadbash排错 FAQ#
Q: docker compose up 报 “permission denied” 读 ssh 私钥
A: chmod 600 /www/docker/blog-editor/ssh/blog_webapp, 然后 docker compose down && up -d。SSH 强制要求 600。
Q: 容器一直 restarting
A: docker logs blog-editor 看具体报错。最常见: SSH key 不对, 或 GitHub repo URL 写错。
Q: 浏览器开了页面但 “Vditor 未定义”
A: CDN 加载失败, 国内访问 jsdelivr 偶尔不通。临时方案: 等几分钟刷新; 长期方案: 把 vditor@3.10.7/dist/index.css 和 index.min.js 下载到 frontend/vendor/, HTML 里引用本地路径。
Q: 发布时 stderr 说 “Updates were rejected because the remote contains work that you do not have locally”
A: 你本地用 pnpm publish-post 抢先 push 了, VPS 工作区落后。点 🔄 从 GitHub 同步 按钮, 再发布。
Q: 发布时 stderr 说 “Permission denied (publickey)” git push 不上去
A: VPS 这把 push key (Key ⑤) 没装好。重做 Phase 1, 然后 cp ~/.ssh/blog_webapp /www/docker/blog-editor/ssh/。
Q: 图片上传 “请先填 slug” A: 上传按钮要求 slug 已经填写 (因为图片要按文章归档)。先把 slug 填上, 再粘贴/上传图片。
Q: 文章发出去了但 https://kylaan.cn ↗ 看不到 A: 看 GitHub Actions 那条 run 是否绿。绿了但还看不到 = nginx 缓存或浏览器缓存, 强刷 (Ctrl+Shift+R)。
Q: nginx 报 unknown directive limit_req_zone
A: 把 4.2 配置里 limit_req_zone 和 limit_req 那两段删掉。或者去 nginx 主配置 nginx.conf 的 http {} 块里加 limit_req_zone $binary_remote_addr zone=editor_login:10m rate=5r/s;。
Q: 想用 root 之外的非特权用户跑容器
A: 在 docker-compose.yml 加 user: "1000:1000", 然后把 ssh/, workspace/, drafts/ 都 chown -R 1000:1000。本指南为了简单全用 root。
Q: 移动端访问能用吗? A: 能, Vditor 自带响应式。但移动端写长文体验一般, 推荐桌面写主要内容, 手机改小段。
后续可扩展功能 (留给你以后做)#
按优先级排:
- 文章预览模式: 调 Astro dev server 实时渲染当前文章, iframe 嵌入。需要在容器里多跑个 astro dev 实例
- 草稿列表: 当前只有”存草稿”, 没有”列出所有草稿”的 UI。加
GET /api/drafts+ 前端 sidebar 多一个 tab - 版本回滚: 一个文件改坏了, 点 “回滚到上一版” →
git log <file>+git checkout <hash> -- <file> - 图床外接: 默认图片进 git 历史会让 repo 膨胀。改成上传到 Cloudflare R2 / 七牛 / 阿里 OSS, 只写 URL 进 mdx
- 多人协作: 当前 nginx Basic Auth 一个密码所有人共用。改成 JWT + 用户系统, 不同人不同权限
- AI 辅助: 集成 Claude API, 给”扩写""润色""生成摘要”按钮
- 统计: 哪天写得最多、字数趋势、tags 分布
- Markdown 表格 / Mermaid 模板插入: 工具栏加按钮, 一键插入常用代码块
相关文件#
- 项目 README:
../README.md - 服务器从零搭建:
./SERVER_SETUP_GUIDE.md— Phase 0-7 主站 + Waline - Webapp 接口约定 (旧方案备份):
./MOBILE_WEBAPP_INTERFACE.md— 走 GitHub Contents API 的版本 - 一键发布脚本:
../scripts/publish.mjs— 容器内复用的核心 - GitHub Actions 部署:
../.github/workflows/deploy.yml
更新这份文档时: 改了 server.mjs / index.html 后, 把这里嵌入的代码块也同步更新, 保持 doc 和实际部署一致。