HedgeDoc 协作文档部署:权限、数据库与图片一起恢复
固定 HedgeDoc 与 PostgreSQL 镜像,核对机房、权限和实时协作,保存数据库与图片同一轮备份并在独立卷恢复。
先确认协作与权限需求
几个人要同时改一份上线清单,文件来回传很快就会出现不同版本。HedgeDoc 可以在浏览器里共同编辑 Markdown,同时预览结果。它适合会议共写、发布记录和短期协作;如果需要按部门、项目精细分配成员权限,先核对它的权限模型,再决定是否使用。
本文部署固定的 HedgeDoc 1.12.0,以 PostgreSQL 保存笔记、独立卷保存上传图片。先关闭公共注册,用命令行创建账号,再配置 HTTPS。图片、账号和正文都需要进入恢复流程,不能只确认编辑器能打开。
按协作人数、机房位置与存储量选择
少量同事共写文本,可先复用有余量的 Linux VPS。整机 2 GiB 内存、20 GiB 本地磁盘可作为试部署起点;这是选型建议,不是本站测出的最低配置。示例给应用设置 512 MiB、数据库设置 256 MiB 上限,系统、反向代理和备份过程另需余量。附件多、并发多时,按实际内存、磁盘和同步情况调整。
机房优先考虑主要协作者的访问条件。实时编辑需要持续的 WebSocket 连接,套餐标注的端口速率不能保证跨地区同步顺畅。先用月付或已有机器完成两地协作、图片上传与恢复验证,再决定长账期。费用还包括域名、异地备份,以及备份期间保留副本所需的空间。
确实需要新实例时,可比较雨云 RCS 的地区、本地磁盘、月付和续费条件。普通协作文档不需要住宅 IP;厂商快照能否恢复数据库与上传文件,也要自行验证。
注册推荐码:KuZhuJi
两份持久数据,固定两个镜像版本
在已有 Docker Engine 与 Compose 插件的 Linux 服务器上,用有 Docker 权限的 root shell 操作。以下目录用于新实例;已有同名部署时先核对数据,不覆盖旧配置。先准备自己的独立域名,下面会将实际主机名写入配置,不使用临时地址生成分享链接。
set -eu
umask 077
mkdir -p /srv/hedgedoc
chmod 700 /srv/hedgedoc
cd /srv/hedgedoc
test ! -e .env
printf '正式域名(只填主机名,不带协议或路径):'
IFS= read -r HEDGEDOC_DOMAIN
case "$HEDGEDOC_DOMAIN" in
""|*[!a-zA-Z0-9.-]*|*..*|.*|*.) printf '域名格式不符\n' >&2; exit 1 ;;
esac
printf 'HEDGEDOC_DOMAIN=%s\n' "$HEDGEDOC_DOMAIN" > .env
printf 'HEDGEDOC_DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" >> .env
printf 'HEDGEDOC_SESSION_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
chmod 600 .env
密码使用十六进制字符,放进数据库 URL 时不需要额外转义;会话密钥生成后持续保留。上面的文件存在检查用于阻止重复执行时覆盖秘密。不要把 .env 发到聊天或提交进 Git。
保存 compose.yaml。本文选择 PostgreSQL 17,数据卷路径是 /var/lib/postgresql/data;PostgreSQL 18 镜像的目录约定不同,不能仅修改主版本号继续使用这份挂载。应用和数据库都固定到本次核对的镜像摘要。
services:
database:
image: postgres:17.11-alpine3.23@sha256:05aa77262db9277fde6374bb2f901c9604b8a9373029e1a7993440c6aae48736
restart: unless-stopped
environment:
POSTGRES_USER: hedgedoc
POSTGRES_DB: hedgedoc
POSTGRES_PASSWORD: ${HEDGEDOC_DB_PASSWORD:?database password required}
volumes:
- database:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U hedgedoc -d hedgedoc"]
interval: 10s
timeout: 5s
retries: 10
mem_limit: 256m
app:
image: quay.io/hedgedoc/hedgedoc:1.12.0@sha256:202460cadb220ca568328045827f49a6e5575cb33de337b7ee59e6a5f9b79509
restart: unless-stopped
depends_on:
database:
condition: service_healthy
environment:
NODE_ENV: production
CMD_DB_URL: postgres://hedgedoc:${HEDGEDOC_DB_PASSWORD:?database password required}@database:5432/hedgedoc
CMD_DOMAIN: ${HEDGEDOC_DOMAIN:?public hostname required}
CMD_PROTOCOL_USESSL: "true"
CMD_URL_ADDPORT: "false"
CMD_SESSION_SECRET: ${HEDGEDOC_SESSION_SECRET:?session secret required}
CMD_EMAIL: "true"
CMD_ALLOW_EMAIL_REGISTER: "false"
CMD_ALLOW_ANONYMOUS: "false"
CMD_ALLOW_ANONYMOUS_EDITS: "false"
CMD_DEFAULT_PERMISSION: private
CMD_ENABLE_UPLOADS: registered
CMD_IMAGE_UPLOAD_TYPE: filesystem
CMD_ALLOW_GRAVATAR: "false"
CMD_ENABLE_STATS_API: "false"
ports:
- "127.0.0.1:3005:3000"
volumes:
- uploads:/hedgedoc/public/uploads
mem_limit: 512m
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
database:
uploads:
数据库没有发布端口,应用也只绑定宿主机回环地址。CMD_PROTOCOL_USESSL 用于生成 HTTPS 地址,并不会让应用自己监听 TLS;证书由下一节的反向代理处理。CMD_EMAIL 表示允许邮件地址形式的本地账号登录,不代表已配置发信或找回密码服务。
docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=80 app
账号先建好,再接入 HTTPS
确认数据库和应用启动后,在服务器的交互终端创建两个普通账号。将示例邮箱换成成员自己的标识,按提示输入密码;不使用 --pass 把密码留在命令历史或进程参数中。
docker compose exec -e NODE_ENV=production app node bin/manage_users --add member-a@your-domain.tld
docker compose exec -e NODE_ENV=production app node bin/manage_users --add member-b@your-domain.tld
这里需要终端输入,不能加 -T,也不要通过管道传密码。后续忘记密码时,可由有服务器管理权限的人,用同一工具的 --reset 替换 --add 进行重置。创建账号不会自动发送邀请邮件。
宿主机已有 Caddy 时,在现有配置中增加以下命名片段,在自己的实际域名站点块内添加 import hedgedoc_backend,再验证配置并重载。DNS 应指向这台主机,入站 80、443 与证书申请条件需满足。不要为了试用清空已有 Caddyfile。
(hedgedoc_backend) {
reverse_proxy 127.0.0.1:3005
}
Caddy 的反向代理支持 WebSocket。若代理运行在另一个容器,127.0.0.1 指向代理容器自己,需要改为共享网络中的应用地址;不能直接套用宿主机示例。浏览器打开正式 HTTPS 域名,确认静态资源没有错误地指向 HTTP 或带上 3000 端口,再用两个账号登录。
先用无敏感内容的笔记检查权限
新笔记默认 Private,只有创建者可读写。需要实例内成员共同编辑时,由创建者将这份笔记改为 Limited:已登录用户可读写,访客不可读写。这里的“已登录用户”包括实例中的其他账号,并不是一份单独的受邀成员清单。因此应保持公共注册关闭,不能把它当成按项目隔离的团队权限。
成员 A 写一段内容,成员 B 用独立账号打开,分别编辑不同段落;刷新后核对两人的修改仍在。再用未登录窗口访问同一笔记,确认阅读被拒绝。只开两个同账号窗口,或者只看到首页返回 200,都不能完成这项验证。若页面能打开而文字不同步,先看 WebSocket 连接、代理转发与应用日志,不急着换机器。
HedgeDoc 还有 Editable、Protected 等权限,它们的访客阅读和登录者编辑范围不同。不要凭中文名称猜含义;每次改变权限后,用作者、另一个普通账号和未登录窗口分别检查。整理会议记录后保留结论与负责人,未确认的排错猜测不要写成已经完成的事实。
上传一张普通测试图片,再用未登录窗口直接打开图片 URL。文档权限不应被当作附件访问控制;本地上传路径可能允许拿到地址的人读取。机密截图、凭据和私人票据不要上传到尚未核清访问边界的实例。仅限内部使用时,可将整个服务放到已有私有网络之后。
停止写入,把数据库与上传文件归为同一轮备份
笔记、账号及修订信息在数据库中,图片在 uploads 卷。导出数据库的同时继续上传或编辑,会得到时间不一致的两份数据。下面先停止应用,保持数据库运行,完成自定义格式导出和图片归档,再启动应用。这是有短暂停写的维护操作,先安排使用时间;失败会尝试启动原服务,不把半成品当作成功备份。
set -eu
cd /srv/hedgedoc
umask 077
mkdir -p /srv/hedgedoc-backups
snapshot=$(mktemp -d /srv/hedgedoc-backups/cold-XXXXXX)
cid=$(docker compose ps -q app)
test -n "$cid"
trap 'docker compose start app >/dev/null' EXIT
docker compose stop -t 60 app
state=$(docker inspect --format '{{.State.Status}} {{.State.OOMKilled}} {{.State.ExitCode}}' "$cid")
case "$state" in
"exited false 0"|"exited false 143") ;;
*) printf '未确认正常停止,拒绝备份:%s\n' "$state" >&2; exit 1 ;;
esac
docker compose exec -T database pg_dump -U hedgedoc -d hedgedoc -Fc > "$snapshot/database.dump.partial"
docker compose run --rm --no-deps -T --entrypoint sh app \
-c 'tar -C /hedgedoc/public/uploads -czf - .' > "$snapshot/uploads.tar.gz.partial"
docker compose exec -T database pg_restore --list < "$snapshot/database.dump.partial" >/dev/null
tar -tzf "$snapshot/uploads.tar.gz.partial" >/dev/null
cp compose.yaml .env "$snapshot/"
mv "$snapshot/database.dump.partial" "$snapshot/database.dump"
mv "$snapshot/uploads.tar.gz.partial" "$snapshot/uploads.tar.gz"
(cd "$snapshot" && sha256sum database.dump uploads.tar.gz compose.yaml .env > SHA256SUMS)
docker compose start app
trap - EXIT
printf '备份目录:%s\n' "$snapshot"
把完成的整个目录加密保存到另一台设备。数据库导出和 .env 都是私密资料,不能放到网页可访问目录。备份目录和原卷在同一块盘上,只能应付一部分误操作,不能应对整台 VPS 丢失。
恢复时先用相同版本,不在正式卷上试验。确认测试主机有额外资源后,把 snapshot 换成实际备份目录,建立新目录并复制配置:
set -eu
snapshot=/srv/hedgedoc-backups/cold-实际后缀
(cd "$snapshot" && sha256sum -c SHA256SUMS)
restore=$(mktemp -d /srv/hedgedoc-restore.XXXXXX)
cp "$snapshot/.env" "$restore/.env"
cd "$restore"
在这个新目录保存下面的 compose.restore.yaml。它不发布端口,网络仅供数据库和应用互连,不接外网;镜像与备份使用相同版本。
services:
database:
image: postgres:17.11-alpine3.23@sha256:05aa77262db9277fde6374bb2f901c9604b8a9373029e1a7993440c6aae48736
environment:
POSTGRES_USER: hedgedoc
POSTGRES_DB: hedgedoc
POSTGRES_PASSWORD: ${HEDGEDOC_DB_PASSWORD:?database password required}
volumes:
- database:/var/lib/postgresql/data
networks: [recovery]
mem_limit: 256m
app:
image: quay.io/hedgedoc/hedgedoc:1.12.0@sha256:202460cadb220ca568328045827f49a6e5575cb33de337b7ee59e6a5f9b79509
environment:
NODE_ENV: production
CMD_DB_URL: postgres://hedgedoc:${HEDGEDOC_DB_PASSWORD:?database password required}@database:5432/hedgedoc
CMD_DOMAIN: 127.0.0.1
CMD_PROTOCOL_USESSL: "false"
CMD_URL_ADDPORT: "true"
CMD_SESSION_SECRET: ${HEDGEDOC_SESSION_SECRET:?session secret required}
CMD_EMAIL: "true"
CMD_ALLOW_EMAIL_REGISTER: "false"
CMD_ALLOW_ANONYMOUS: "false"
CMD_ALLOW_ANONYMOUS_EDITS: "false"
CMD_DEFAULT_PERMISSION: private
CMD_ENABLE_UPLOADS: registered
CMD_IMAGE_UPLOAD_TYPE: filesystem
CMD_ALLOW_GRAVATAR: "false"
CMD_ENABLE_STATS_API: "false"
volumes:
- uploads:/hedgedoc/public/uploads
networks: [recovery]
mem_limit: 512m
networks:
recovery:
internal: true
volumes:
database:
uploads:
保持在刚才的恢复目录和同一个 shell,继续执行。独立随机项目名用于创建新卷,不要改为原实例的项目名。
project="hd-recovery-$(basename "$restore" | tr 'A-Z.' 'a-z-')"
docker compose -p "$project" -f compose.restore.yaml up -d database
ready=false
for attempt in $(seq 1 30); do
if docker compose -p "$project" -f compose.restore.yaml exec -T database pg_isready -h 127.0.0.1 -U hedgedoc -d hedgedoc >/dev/null; then
ready=true; break
fi
sleep 2
done
test "$ready" = true
docker compose -p "$project" -f compose.restore.yaml exec -T database \
pg_restore -U hedgedoc -d hedgedoc --exit-on-error --no-owner < "$snapshot/database.dump"
docker compose -p "$project" -f compose.restore.yaml run --rm --no-deps -T --entrypoint sh app \
-c 'tar -xzpf - -C /hedgedoc/public/uploads' < "$snapshot/uploads.tar.gz"
docker compose -p "$project" -f compose.restore.yaml up -d app
docker compose -p "$project" -f compose.restore.yaml logs --tail=80 app
容器启动还不等于数据恢复。先通过容器内的应用请求核对两个原账号、指定笔记全文、原权限和图片字节;有受控浏览器测试环境时,再检查双人同步和退出登录后的访问边界。无端口、无外网的副本不能直接从电脑浏览器打开,不要为了方便随意解除隔离。缺少这些检查就保留“恢复待验”,不能只看日志或数据库行数宣布成功。
验完在该恢复目录执行 docker compose -p "$project" -f compose.restore.yaml stop,保留副本和备份供排查。正式换机时再安排接管,旧实例与新实例不得同时指向同一数据库或上传卷。
升级时保留整套恢复点
升级应用前读目标版本发布说明,完成数据库与 uploads 同一轮备份,再在副本验证。应用版本和 PostgreSQL 主版本分开变更,避免一次排查两种迁移。PostgreSQL 大版本升级需要对应的迁移方法,不能把新版本镜像直接挂到旧数据目录。
如果应用升级已经改写数据库,换回旧镜像并不能保证恢复。需要退回时,使用升级前的数据库导出、图片和原配置,在独立卷完成验证后接管。本文的固定版本用于这份配置复现,不代表后续版本无需维护;域名证书、异地备份和真实多人协作仍要按实际环境检查。
