背景

我曾经维护过几年博客,但随着时代在变、年纪渐长,注意力越来越难以支持长文的阅读和书写。很多杂乱的想法越来越倾向于在各种社交媒体上发表,唯一仅剩的长文书写欲仅存于技术和各种个人服务折腾经验的分享。对于这种类型的文本来说,相比按照发布时间线形排布的博客,类似于笔记的知识库系统显然是更好的选择。

目前将 Obsidian 笔记公开发布到 Web 的方法除了每月 8 USD 的官方 Obsidian Publish,比较主流的似乎也就只有 QuartzObsidian Digital Garden。两者我都试用过,后者的部署方式始终过于捆绑 Vercel 这种 serverless 部署平台,秉持着手头已经有一堆 VPS 何必再给这种部署平台花钱的心态,我是非常抵触的,于是 Quartz 成了一个必然的选择。

Quartz 的本质类似于 Jekyll 和 Hexo 这类静态站点生成器,只是天生针对 Obsidian 开发。和搭建博客一样,部署公开的知识库看似有很多方案可以选择,但实际称得上好用的并不多。有的编辑语法复杂得像 Wikipedia,有的定位面向团队内部而不支持自定义域名。我并非是 Obsidian 或者静态网站生成器的教徒,只是要同时满足我所有需求的选择实在不多:

  • 支持 Markdown 语法
  • 方便的数据导入、导出格式
  • 支持桌面端和移动端无缝切换编辑、发布
  • 支持自托管/提供廉价、可自定义域名的托管方案
  • 美观的前端和后台界面
  • 好用的后台编辑环境

在这种情况下往往只能选择一个最接近的方案缝缝补补,而这套 Obsidian + Quartz 是我目前能找到的最接近我需求的方案。

为什么不用 Quartz 官方部署方案

按照 Quartz 官方文档的指引,我们本该在本地克隆官方 GitHub 仓库,通过 Git 仓库创建、管理、发布站点和更新 Quartz 程序。然而这套方案里有两个我很不喜欢的点:

  • Quartz 程序代码未与站点数据分离:与 Hexo 等软件通过 npm 全局安装后在任意目录创建站点的使用方法不同,Quartz 的程序代码与站点数据、配置等全都挤在同一个 Git 仓库目录。这导致本该可以随地创建的 Obsidian 笔记仓库难以脱离 Git 仓库目录。
  • 移动端的适配问题:这是静态网站生成器的一个通病,而 Quartz 更甚。Quartz 程序的更新和站点的数据同步、部署发布都严重依赖 Git 和 npx 命令。尽管这方便了站点数据与配置的版本管理和同步,但同时也引入了更加严峻的移动端适配问题。尤其是在 iOS 环境中,用户很难提供一个自由的 Git 环境用于管理、同步仓库,各种 Quartz 命令也更是难以执行。

如前文所说,我手头已经有好几台 VPS,所以使用 GitHub Pages 部署静态页面并不是我的强需求。说实话,相比让这种笔记仓库出现在我的 GitHub 里,我反而更希望它能像我的其他个人服务一样使用 Docker 部署在自己的 VPS 上统一管理。

Docker + WebDAV 的同步部署思路

值得庆幸的是,Quartz 现在提供了官方的 Docker 镜像,因此我们可以直接使用 Docker 部署实时服务器监听数据目录变化,再将必要的站点配置文件和数据目录挂载进容器,实现站点数据与程序代码分离。

解决完 Docker 发布,剩下的问题是如何将本地的数据推送至远端 VPS 目录。得益于 Obsidian 的插件系统,我们有很多选择。我的方案是在 VPS 端同样使用 Nginx 的 Docker 镜像架设一个 WebDAV 服务器,再使用 Remotely Save 插件将笔记仓库作为站点数据推送到远端。

当然,这只是其中一种方案,你可以根据你惯用的多端同步方式进行选择,毕竟服务器端本质上也只是多端同步的一个单向节点。我使用 WebDAV 的前提是我的 Obsidian 仓库本身已经通过 iCloud 在 Mac 和 iOS 间无感同步,此时仅需一个单向通道来将本地设备端的数据推送至远端,触发实时服务器更新构建站点。

参考配置

compose.yaml

services:
  quartz:
    container_name: quartz
    image: ghcr.io/jackyzha0/quartz:latest
    restart: unless-stopped
    mem_limit: 800M
    memswap_limit: 1G
    volumes:
      - ./temp:/usr/src/app/.quartz
      - ./data/content:/usr/src/app/content
      - ./data/quartz.config.yaml:/usr/src/app/quartz.config.yaml
    entrypoint:
      - /bin/sh
      - -c
    command:
      - |
        npx quartz plugin install --from-config &&
        exec npx quartz build --serve
    networks:
      - web
    labels:
      - traefik.enable=true
      - traefik.http.routers.quartz.rule=Host(`example.com`)
      - traefik.http.services.quartz.loadbalancer.server.port=8080
 
  webdav:
    container_name: webdav
    build:
      context: .
      dockerfile: Dockerfile.webdav
    restart: unless-stopped
    volumes:
      - ./data:/var/www/webdav
      - ./config/webdav.conf:/etc/nginx/nginx.conf:ro
      - ./config/htpasswd:/etc/nginx/htpasswd:ro
    networks:
      - web
    labels:
      - traefik.enable=true
      - traefik.http.routers.webdav.rule=Host(`push.example.com`)
      - traefik.http.services.webdav.loadbalancer.server.port=80
 
 
networks:
  web:
    external: true

Dockerfile.webdav

FROM alpine:3.20 
RUN apk add --no-cache nginx nginx-mod-http-dav-ext apache2-utils && \
    mkdir -p /run/nginx 
CMD ["nginx", "-g", "daemon off;"]

webdav.conf

user root;
worker_processes auto;
load_module /usr/lib/nginx/modules/ngx_http_dav_ext_module.so;
 
events {}
 
http {
    server {
        listen 80;
        server_name _;
        client_max_body_size 500M;
 
        location / {
            root /var/www/webdav;
            autoindex off;
 
            dav_methods PUT DELETE MKCOL COPY MOVE;
            dav_ext_methods PROPFIND OPTIONS LOCK UNLOCK;
            dav_access user:rw group:rw all:r;
            create_full_put_path on;
 
            auth_basic "WebDAV";
            auth_basic_user_file /etc/nginx/htpasswd;
        }
    }
}