SSD Nodes Learn 🎉 VPS $4.99/月起
指南 Matt Connor作者: Matt Connor

AFFiNE 自托管教程:Docker Compose 部署 Notion 替代品

用 Docker Compose 在一台 VPS 上运行 AFFiNE,了解应用、迁移、Postgres 和 Redis 四个容器,固定镜像标签、数据位置与备份方法,并评估 2 GB 内存的实际容量。

自托管 AFFiNE 的内容

自托管 AFFiNE 后,您可以在自己控制的服务器上运行类似 Notion 的工作区。它由四个容器组成:应用、一次性迁移任务、Postgres 和 Redis。系统包含实时协作功能。默认情况下,自托管工作区最多支持 10 个席位。安装只需要一个 compose 文件和一个 JSON 配置文件。需要重点规划的是镜像标签、磁盘布局、内存上限,以及前置代理。

AFFiNE 将文档编辑器和无限画布放在同一个工作区中。因此,同一页面既可以作为文档阅读,也可以展开为白板。如果您还在决定要运行哪种服务,请先阅读 自托管 Notion 替代方案比较。本指南假定您已经做出选择,重点介绍如何正确运行 AFFiNE,而不是再次进行比较。

本文内容已根据 AFFiNE 自托管文档和已发布的版本文件进行核对,核对日期为 8 August 2026。该日期之前最新的稳定版本是 0.27.3,发布于 23 July 2026。

四个容器的实际用途

affine 将服务器和 Web 客户端集成在同一个镜像中。它监听 3010 端口。

affine_migration 是一个一次性任务。它运行 node ./scripts/self-host-predeploy.js,应用数据库迁移,然后退出。应用将 condition: service_completed_successfully 声明为该任务的依赖项,因此迁移以非零状态退出时,affine 根本不会启动。Web 界面无法启动时,应首先查看该任务的日志。

postgres 存储您的文档、用户、工作区和权限。发布的镜像是 pgvector/pgvector:pg16,即内置 pgvector 扩展的标准 Postgres 16。pgvector 为 Postgres 添加 vector 列类型。这种数值形式用于存储嵌入向量,从而可以按语义搜索文本。

redis 是硬依赖项:服务器和迁移任务都会等待其健康检查通过后再启动。请注意,发布的 compose 文件没有为 Redis 提供卷。Redis 中的任何内容都不会在 docker compose down 后保留,这明确说明其中不存储您的数据,也不需要备份。

为什么使用 pgvector 镜像,而不是标准 postgres

这是 AFFiNE 的架构要求,不是镜像偏好。在 schema.prisma 中,数据源声明了 extensions = [pgvector(map: "vector")],其中四张表包含类型为 vector(1024)embedding 列。无论是否启用 AI 功能,迁移任务都会创建这些表。因此,数据库中必须已存在该扩展,迁移才能完成。替换为 postgres:16 后,扩展就会消失,迁移无法创建这些列,服务器会一直等待一个已经失败的任务。

AFFiNE 从 0.21 版本开始使用 pgvector 镜像。如果安装版本早于该版本,仅修改镜像行并不能完成升级。因此,在拉取任何内容前,请先阅读 AFFiNE 自托管文档中的升级页面。

还需要注意该标签。pg16 表示 Postgres 16。Postgres 主版本号不能直接递增。将现有数据目录改为使用 pg17 后,Postgres 会拒绝启动,并在 docker compose logs postgres 中记录类似 The data directory was initialized by PostgreSQL version 16, which is not compatible with this version 17 的行。主版本升级需要先导出数据,再将数据恢复到新的数据目录中。

自托管 AFFiNE 需要多少 CPU 和 RAM

AFFiNE 的要求页面建议至少使用 4 个 CPU 核心和 2 GB RAM;当文档超过 10,000 个单词后,内存要求会提高到 4 GB。同一页面还说明了内存的用途:同步系统和文档合并。其中有一个值得记住的数据:合并包含 10,000 项修改的文档时,内存峰值可能达到 1 GB。

再结合 2 GB 方案和两名用户同时编辑的情况来看。平均使用量没有问题。Postgres 和 Node 进程都低于限制,并且还有余量。问题在于峰值。一次大型合并就可能在现有常驻内存之外再申请 1 GB。在没有 swap 的 2 GB 服务器上,内核的 out-of-memory (OOM) killer 会通过终止占用内存最多的进程来满足请求,而这个进程就是 AFFiNE 服务器。

您的同事不会看到错误信息。他们只会看到页面重新加载,因为 restart: unless-stopped 会在几秒内重新启动容器。不要凭猜测判断,请确认:

docker inspect affine_server --format '{{.State.OOMKilled}} {{.RestartCount}}'
sudo dmesg -T | grep -i -E 'out of memory|killed process'

第一个命令的 true,或第二个命令中用于指明 nodeKilled process 行,表示内存耗尽,而不是发现了程序错误。请从两端解决。先添加 swap,使内存峰值变成变慢,而不是直接失败:

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h

此时 free -h 应报告 2.0Gi 的 swap 总量。swap 不会让 AFFiNE 运行得更快,也不是为此设计的。它会将持续一秒的内存峰值变成缓慢的一秒,而不是让容器退出。解决方案的另一端,是阻止 Postgres 将缓存扩展到应用在合并时需要使用的空间;这正是 Compose 服务的内存限制 的用途。

存储空间则更容易预测。以下是 AFFiNE 在同一页面发布的数据:

ChartPublished AFFiNE storage figures, August 2026
The data behind this chart
[
  {
    "label": "Server install",
    "gb": 1.5
  },
  {
    "label": "Postgres per 1,000 docs",
    "gb": 0.1
  },
  {
    "label": "Blob store per 1,000 uploads",
    "gb": 10
  }
]

服务器安装需要 1.5 GB。大约 1,000 个单词的 1,000 篇文档会增加 0.1 GB 的 Postgres 数据,几乎可以忽略不计。1,000 个上传文件会增加 10 GB,这才是主要因素。这些是公开发布的规划数据,而不是来自运行中实例的测量结果,因此应将其视为规模参考,而不是保证值。关键在于这种规模关系:数据库保持较小,而磁盘空间主要取决于上传文件。

自行编写 compose 文件,并固定镜像标签

文档中的安装步骤会通过 curl -L -o docker-compose.yml https://github.com/toeverything/AFFiNE/releases/latest/download/docker-compose.yml 下载一个预先生成的文件。这样做可以正常运行。不过,在依赖该文件之前,需要了解一个细节:截至 2026 年 8 月 8 日,附加在版本 0.27.3 release 中的文件仍会使用 ${UPLOAD_LOCATION}${CONFIG_LOCATION}${DB_DATA_LOCATION}.env 文件读取路径;而文档的参考页面显示了较新的布局,所有内容都放在 ./data 下,完全不需要 .env。这两种布局都是真实存在的。自行编写该文件即可避免这个问题,而且无论如何都需要编辑它来固定镜像版本并设置数据库密码。

mkdir -p ~/affine/config ~/affine/data
cd ~/affine
printf 'DB_PASSWORD=%s\n' "$(openssl rand -hex 24)" > .env
chmod 600 .env

Compose 会自动从项目目录读取 .env,并为您替换 ${DB_PASSWORD},因此密码不会出现在您可能粘贴到支持工单中的文件里。无论运行哪种 stack,都应保持这种做法。相关原因请参见避免将机密信息写入 compose 文件

现在编写 ~/affine/docker-compose.yml

name: affine
services:
  affine:
    image: ghcr.io/toeverything/affine:stable
    container_name: affine_server
    ports:
      - '127.0.0.1:3010:3010'
    depends_on:
      redis:
        condition: service_healthy
      postgres:
        condition: service_healthy
      affine_migration:
        condition: service_completed_successfully
    volumes:
      - ./data/storage:/root/.affine/storage
      - ./config:/root/.affine/config
    environment:
      - REDIS_SERVER_HOST=redis
      - DATABASE_URL=postgresql://affine:${DB_PASSWORD}@postgres:5432/affine
      - AFFINE_INDEXER_ENABLED=false
    restart: unless-stopped

  affine_migration:
    image: ghcr.io/toeverything/affine:stable
    container_name: affine_migration_job
    command: ['sh', '-c', 'node ./scripts/self-host-predeploy.js']
    volumes:
      - ./data/storage:/root/.affine/storage
      - ./config:/root/.affine/config
    environment:
      - REDIS_SERVER_HOST=redis
      - DATABASE_URL=postgresql://affine:${DB_PASSWORD}@postgres:5432/affine
      - AFFINE_INDEXER_ENABLED=false
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  redis:
    image: redis:8-alpine
    container_name: affine_redis
    healthcheck:
      test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  postgres:
    image: pgvector/pgvector:pg16
    container_name: affine_postgres
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: affine
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: affine
      POSTGRES_INITDB_ARGS: '--data-checksums'
    healthcheck:
      test: ['CMD', 'pg_isready', '-U', 'affine', '-d', 'affine']
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

与上游提供的文件相比,这里有四处差异,每一处都有明确原因。

  • 127.0.0.1:3010:3010 仅在 loopback 地址上发布端口,因此在您决定访问方式之前,服务器外部无法访问 AFFiNE。上游的 '3010:3010' 会绑定所有网络接口,而在大多数 VPS 镜像中,这也包括公网接口。
  • 删除了 POSTGRES_HOST_AUTH_METHOD: trust,改为设置密码。Trust authentication 允许任何连接到该数据库的客户端以 affine 用户身份访问,且无需密码。该方式目前仅限于私有 Compose 网络;但如果您之后将另一个容器连接到该网络,或在调试时发布 5432 端口,就可能造成问题。
  • redis:8-alpine 替代了不带版本的 redis,后者会解析为 latest。截至 2026 年 8 月,该标签对应 Redis 8,因此固定版本可以保留已测试的主版本,避免未来在无关的 docker compose pull 期间引入 Redis 9。
  • pgvector/pgvector:pg16 保持与上游完全一致,原因见上文。

POSTGRES_PASSWORD 只会在 Postgres 第一次创建数据目录时读取。对于已经存在的实例,请使用 docker compose exec postgres psql -U affine -c "ALTER USER affine WITH PASSWORD 'yourpassword'" 设置密码,然后相应更新 DATABASE_URL

配置位于 config/config.json

AFFiNE 从 config/config.json 读取设置。该目录就是您挂载到 /root/.affine/config 的目录。系统不会自动创建此文件,因此请在首次启动前手动创建。使用编辑器打开 ~/affine/config/config.json,写入以下内容,并将示例域名替换为您自己的域名:

{
  "$schema": "https://github.com/toeverything/affine/releases/latest/download/config.schema.json",
  "server": {
    "name": "Team workspace",
    "externalUrl": "https://affine.example.com"
  },
  "copilot": {
    "enabled": false,
    "byok": {
      "enabled": false
    }
  }
}

server.externalUrl 必须是用户实际在浏览器中打开的地址。AFFiNE 会根据该值生成共享链接和工作区邀请。如果保留为 http://localhost:3010,您发送的邀请会将接收者指向他们自己的计算机,并在那里失败。请在首次启动前将其设置为公开的 HTTPS 地址,确保配置文件和管理面板中的地址一致。

copilot 控制 AI 功能。copilot.byok.enabled 是自带密钥开关,启用后,工作区所有者可以在工作区设置中粘贴自己的模型提供商密钥。自行托管 AFFiNE 不包含 AI 订阅。如果您不需要 AI 功能,请将 false 保持不变。

启动服务栈:

docker compose up -d
docker compose ps

docker compose ps 应将 affine_postgresaffine_redis 列为 healthy,将 affine_server 列为 running,并显示 affine_migration_job 的状态为 exited (0)。迁移任务出现其他退出代码时,应重点排查;其日志会指出停止的步骤:

docker compose logs affine_migration

固定镜像,避免遗忘

stable 是会变化的标签。AFFiNE 的发布流程会为每个稳定版本指向多个标签,其中两个与此处有关:stable 会在每次发布时重新指向新镜像;stable- 后跟 git 短哈希,指向不会变化。若一直使用 stable,六个月后执行 docker compose pull 时,获取的可能是另一个镜像,并在您未选择的时刻对数据库执行迁移。请固定为已测试的确切镜像:

docker compose pull
docker image inspect ghcr.io/toeverything/affine:stable --format '{{index .RepoDigests 0}}'

该命令会输出类似 ghcr.io/toeverything/affine@sha256: 的一行,后面跟着一长串哈希值。将完整字符串粘贴到 affineaffine_migrationimage: 行中。这两个值必须始终一致,因为它们表示同一个镜像,但承担不同角色。如果不一致,数据库可能迁移到一种 schema,却由另一种 schema 提供服务。之后升级就会变成有意执行的编辑,而不是意外操作:修改 digest,备份,docker compose pulldocker compose up -d

创建管理员账户,防止其他人抢先完成注册

在全新实例上打开 /admin 时,AFFiNE 会将您转到账户创建页面,因为服务器上还没有管理员。该流程不需要邀请代码,也不需要设置令牌。第一个加载此页面的人会成为您服务器的管理员,因此完成注册前必须保持端口关闭。

因此,上面的 compose 文件将服务绑定到 127.0.0.1。请从您自己的计算机通过 SSH 隧道访问:

ssh -L 3010:127.0.0.1:3010 you@your-server-ip

保持该隧道运行,然后在本地浏览器中打开 http://127.0.0.1:3010/admin。完成注册并登录后,关闭隧道。此时,才可以安全地为实例配置公网域名。

AFFiNE 保存数据的位置

以下 3 个路径包含全部数据,并且都位于您创建的目录中。

  • ./data/postgres 是 Postgres 数据目录,包含文档、用户、工作区和权限。
  • ./data/storage 挂载到容器中的 /root/.affine/storage,包含所有上传的文件。
  • ./config 挂载到 /root/.affine/config,包含 config.json

上游项目在这里使用绑定挂载,而不是命名卷。这是有意的:您可以使用普通命令对这些路径进行 tar 打包和复制,无需查询 Docker 将它们存储在哪里。代价是,主机上的文件所有权需要由您负责。相关权衡见绑定挂载和命名卷

如何备份 AFFiNE

需要备份两类数据,备份方式也不同。数据库是运行中的服务器,直接复制其运行中的文件会得到损坏的副本。应改用转储:

mkdir -p ~/affine/backup
cd ~/affine
docker compose exec -T postgres pg_dump --format c --username affine affine \
  > backup/affine-$(date +%F).dump
ls -lh backup/

转储命令通过容器内的本地套接字执行,因此不会提示输入密码。检查 ls 输出中的文件大小。几百字节的文件通常表示转储失败,但 shell 仍然创建了该文件。这种问题往往要到六个月后才会被发现。-T 同样重要:没有它,Compose 可能会分配终端,从而损坏二进制流。

上传的文件就是普通文件,因此使用 tar 打包:

tar czf backup/storage-$(date +%F).tgz -C data storage
cp config/config.json backup/config-$(date +%F).json

手动将 config.json 纳入备份。AFFiNE 文档截至 August 2026 仍将管理面板中的配置导出列为未实现,因此磁盘上的文件是设置的唯一副本。将这三个文件全部复制到服务器之外。同一磁盘上同时保存备份和受保护的数据,不能算作备份。

恢复数据,以及官方步骤中的一个陷阱

提前阅读官方恢复步骤,并仔细核对。官方步骤在 August 2026 发布的版本中,将名为 affine.backup 的文件复制到容器中,然后从 ./pg.backup 恢复。这是两个不同的名称。步骤还会删除 ./postgres 目录,而当前 compose 文件将数据保存在 ./data/postgres 中。请根据实际使用的路径操作,不要直接照搬代码片段中的路径。按照本指南中的目录布局,操作顺序如下:

cd ~/affine
docker compose down
sudo mv data/postgres data/postgres.old
docker compose up -d postgres
docker compose cp backup/affine-2026-08-08.dump postgres:/tmp/affine.dump
docker compose exec postgres pg_restore --format c --username affine \
  --dbname affine --verbose /tmp/affine.dump
docker compose up -d

请注意,这里使用的是 mv,而不是 rm。如果没有保留副本就直接覆盖数据库,一条错误命令可能导致全部数据丢失;先将旧目录移到其他位置不会造成任何损失。还要使用 tar xzf backup/storage-2026-08-08.tgz -C data 恢复上传文件,否则所有文档中的附件都会显示为损坏。然后登录,并打开一个包含图像的文档。这就是测试。恢复后如果没有在浏览器中打开过,它只是一个文件,不算备份。

将 AFFiNE 放在现有代理之后

AFFiNE 使用 WebSocket,这不是可选项。文档对此说明得很明确:WebSocket 是 AFFiNE 同步和协作系统的基础。如果代理不升级这些连接,工作区中的编辑就会悄悄停止同步。页面可以加载,登录也能成功,但在一个浏览器中进行的编辑不会传到另一个浏览器。在浏览器的开发者工具中打开 Network 选项卡,并筛选 WS。反复打开和关闭的连接,说明代理没有传递升级请求。

如果您已经使用 Traefik 代理其他容器,AFFiNE 可以作为普通服务加入其中。删除 ports: 服务中的 affine 块,然后添加:

    networks:
      - default
      - proxy
    labels:
      - 'traefik.enable=true'
      - 'traefik.docker.network=proxy'
      - 'traefik.http.routers.affine.rule=Host(`affine.example.com`)'
      - 'traefik.http.routers.affine.entrypoints=websecure'
      - 'traefik.http.routers.affine.tls.certresolver=letsencrypt'
      - 'traefik.http.services.affine.loadbalancer.server.port=3010'

在文件底部、与 services: 同级的位置添加:

networks:
  proxy:
    external: true

证书解析器名称必须与 Traefik 配置中定义的名称一致,loadbalancer.server.port 是容器端口 3010,不能填写主机端口。Traefik 无需额外配置即可代理 WebSocket 连接,因此不需要再添加其他内容。有关使用一个 Traefik 实例代理多个应用的信息,请参阅 在多个应用前使用单个 Traefik

使用 nginx 时,必须显式请求升级:

location / {
    proxy_pass http://127.0.0.1:3010;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    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 100m;
}

nginx 中 client_max_body_size 的默认值为 1 MB。如果没有这一行,所有大于小尺寸照片的上传都会失败,并返回 413 状态。AFFiNE 日志中不会出现任何内容,因为请求根本没有到达 AFFiNE。Caddy 只需添加一行,即 reverse_proxy http://127.0.0.1:3010,并会自动处理证书和 WebSocket 升级。

自托管构建版本不包含的功能

在将团队迁移过来之前,请先如实评估这一点。

实时协作功能可用。这也是所有容量建议围绕的功能,因为 AFFiNE 的文档将内存使用归因于同步系统和文档合并。离线编辑是许多人选择本地优先工具的原因,桌面应用可以将您的自托管服务器添加到工作区列表,并使用该服务器登录。在确定方案之前,请测试团队实际依赖的离线行为:关闭网络后,在桌面应用中编辑;重新连接网络;然后在第二台设备上检查结果。功能列表不是证据,这份列表也一样。

已发布的 compose 文件未启用服务器端全文搜索,其中 AFFINE_INDEXER_ENABLED=false 在服务器和迁移任务中均已设置。启用该功能需要添加一个 Manticore Search 容器,这会增加到第五个服务,并占用更多内存。在 2 GB 的服务器上,这项改动会让您超出容量上限。客户端内的搜索仍可用于当前打开的工作区。

在邀请其他人加入之前,您需要了解两个限制。自托管工作区最多分配 10 个席位,超过该数量需要向 AFFiNE 购买 Team 许可证。文档将自托管实例的无限 blob 存储和无限 blob 大小描述为计划支持、但尚未完全实现的功能;该状态于 August 2026 检查确认。如果是家庭或小型团队,这两个限制都不会造成影响。但如果您计划迁移 40 人,这两个限制都会影响方案。

升级

首先阅读发行说明,尤其是执行 0.26 到 0.27 这类小版本升级时,因为其中可能包含不兼容变更。执行任何操作前,先备份数据库和存储目录,因为迁移任务会在下次启动时修改数据库架构,而且无法撤销。然后修改固定的 digest,运行 docker compose pull,再运行 docker compose up -d,并监控 docker compose logs -f affine_migration,直到它正常退出。之后运行 docker image prune 清理旧层。对于使用非常旧版本安装的用户,还需注意一项历史变更:从 0.23.0 开始,镜像名称从 affine-graphql 更改为 affine。因此,早于该版本的 compose 文件需要先重写其中的镜像行,pull 才能找到对应镜像。

FAQ

AFFiNE 容器为什么始终无法启动?

affine 服务在 affine_migration 作业上声明了 condition: service_completed_successfully,因此如果迁移以任何非 0 状态退出,服务器就不会启动,Web 界面也完全不会出现。运行 docker compose logs affine_migration,查看具体在哪个步骤停止。手动编辑 compose 文件时,最常见的原因是使用了普通的 postgres 镜像,而不是 pgvector/pgvector:pg16。这是因为 AFFiNE 的架构声明了 pgvector 扩展,并使用 vector(1024) 列创建表,而普通 Postgres 无法创建这些列。

自托管 AFFiNE 需要多少 RAM?

AFFiNE 的需求页面要求至少 4 个 CPU 核心和 2 GB RAM;当文档超过 10,000 个词时,要求增至 4 GB。页面还指出,合并包含 10,000 项修改的文档时,内存峰值可能达到 1 GB。在 2 GB 服务器上,导致服务终止的是这个峰值,而不是空闲负载:内核的内存不足终止程序会停止 AFFiNE 进程,然后由 restart: unless-stopped 再次启动,因此用户看到的是页面重新加载,而不是错误。使用 docker inspect affine_server --format '{{.State.OOMKilled}}'sudo dmesg -T | grep -i 'out of memory' 确认这一点,然后添加 2 GB swap 文件,使内存突增变慢,而不是直接导致服务终止。

AFFiNE 将数据存储在哪里?我需要备份什么?

compose 目录下的 3 个路径包含全部数据:./data/postgres 用于数据库,./data/storage 用于上传的文件,./config 用于 config.json。使用 docker compose exec -T postgres pg_dump --format c --username affine affine > affine.dump 备份数据库,不要直接复制文件,因为运行中的 Postgres 无法安全复制。使用 tar 打包 ./data/storage 以备份上传文件,并手动保留 config.json 的副本,因为截至 August 2026,管理面板中的配置导出仍标记为尚未实现。

自托管 AFFiNE 支持实时协作吗?

支持,而且无需启用任何功能。唯一的要求是正确配置反向代理,因为同步通过 WebSocket 连接运行。对于 nginx,这意味着配置 proxy_http_version 1.1 以及 UpgradeConnection: upgrade 请求头;Traefik 和 Caddy 无需额外配置即可转发这些连接。如果代理未执行协议升级,工作区仍能正常加载和登录,但在一个浏览器中进行的编辑不会出现在另一个浏览器中。

可以使用普通 Postgres 镜像运行 AFFiNE 吗?

不可以。AFFiNE 的 schema.prisma 声明了 extensions = [pgvector(map: "vector")],并定义了 4 个表;这些表包含一个类型为 vector(1024)embedding 列。即使关闭 AI 功能,迁移作业仍会创建这些表。请使用 pgvector/pgvector:pg16,它是内置该扩展的 Postgres 16。如果改为让 AFFiNE 连接外部 Postgres 服务器,请先在该服务器上安装 pgvector,并在目标数据库中创建扩展,然后再运行迁移。

#affine#notion-alternative#docker-compose#自托管#knowledge-base