---
skill_name: self-hosted-setup
skill_description: 在这台机器上逐步部署自托管的 Contextify Cloud 服务器，并在关键决策处暂停。
title: "自托管 Contextify：智能体配置手册"
---

[English](https://contextify.sh/docs/self-hosted/agent-setup.md) | [Deutsch](https://contextify.sh/de/docs/self-hosted/agent-setup.md) | [简体中文](https://contextify.sh/zh-hans/docs/self-hosted/agent-setup.md)

# 自托管 Contextify：智能体配置手册

你正在帮助用户在他们自己运维的机器（Mac mini、Linux 主机或私有 tailnet（Tailscale 私有网络）中的主机）上部署他们自己的 **Contextify Cloud** 服务器。Contextify Cloud 会同步用户的 Claude Code 和 Codex 历史记录，让他们可以跨机器搜索。这是免费的单用户 **Personal Self-Hosted**（个人自托管版）部署，源码可用，采用 Functional Source License, Version 1.1, Apache 2.0 Future License（FSL-1.1-Apache-2.0）许可证。

在用户的 shell 中按从上到下的顺序执行本手册，每次只做一步。如果你在中断后被再次调用，请先运行“恢复检查”。

## 操作约定（先读，全程遵守）

- **两类指令。** **[YOU RUN]** 表示由你在用户的 shell 中运行的命令。**[USER DOES]** 表示由用户亲自执行的操作（打开浏览器页面、使用 Mac 应用，或在提示符处输入机密信息）。遇到 [USER DOES] 操作时，引导用户完成并等待；不要自己尝试。
- **绝不打印机密信息。** 不要把 API 密钥、数据库密码或配置令牌（setup token）回显到屏幕上。这个会话的对话记录本身也会由 Contextify 建立索引，所以打印出来的机密信息会变成可搜索的历史记录。生成机密信息后直接写入本地 `.env` 文件，不要打印其值，并执行 `chmod 600 .env`。
- **绝不在 `.env` 中写入一行 `$(...)` 命令。** Docker Compose 按字面读取 `.env`，不会执行其中的命令。在写入 `.env` 的 shell 命令中用 `$(...)` 生成值没有问题；但 `.env` 里出现字面的 `$(...)` 则不行。
- **遇到每个 [ASK ME] 都要暂停。** 在以下节点停下来询问用户，没有得到答复就不要继续：服务器主机名（第 1 步）、入口方式以及任何系统级网络变更（第 2 步），以及创建所有者账号之前（第 2 步）。
- **默认保持私有（fail closed，故障时默认拒绝访问）。** 这台服务器保存着用户全部已建立索引的历史记录。唯一认可的入口方式是 Tailscale，它只在用户的私有 tailnet 内部可达。不要把服务器暴露到公共互联网。公共反向代理只是一个高级的、风险自负的例外：没有单独的 [ASK ME]，就绝不能这样做；在那次询问中，用户必须接受登录页面会变得可从互联网访问，并且 TLS、身份验证和速率限制的加固都由他们自己负责。
- **不要越界。** 只在克隆下来的项目目录和 Docker 内工作。不要把用户的数据发送到任何地方，也不要推送或发布任何内容。系统级网络操作（Tailscale、代理）只能在第 2 步的 [ASK ME] 之后执行。
- **出现任何失败就停下。** 展示错误，并查阅下文的“故障排查”。如果那里没有列出解决方法，先说明你的计划，得到用户同意后再运行。未经询问，绝不使用 `sudo`、安装系统软件包、更改端口、编辑 Docker 配置或修改用户组。
- **遵守机密信息防护检查。** 首次启动前替换自带的默认值。对于已有的数据库，更改凭据前先按下文的恢复流程操作。

## 恢复：如果你是被再次调用，先运行这一步

如果用户在中断后重新开始，不要重做已完成的工作，也不要覆盖现有状态。先弄清当前进度（**不要打印任何机密值**），然后从第一个未完成的阶段继续：

1. `contextify-cloud-self-hosted` 目录是否已经克隆？如果是，不要再次 `git clone`，直接 `cd` 进去。
2. `.env` 是否存在？写入之前，用第 1 步的初始化脚本校验全部三个机密赋值。它会拒绝重复项，并保留已确立的值。
3. 三个机密值是否都非空，且自带的默认值都已替换？初始化脚本会在不显示值的情况下检查这一点。如果数据库存储已存在，或其状态不确定，在脚本停止时按恢复流程处理。
4. `.env` 的权限模式是否为 `600`？（`ls -l .env`。）
5. `ALLOWED_ORIGINS`、`EMAIL_BASE_URL`、`INVITATION_BASE_URL` 是否已设为用户的 HTTPS 源（origin），而不是 localhost 默认值？
6. 服务栈是否已启动？`docker compose -f docker-compose.selfhosted.yml ps`。
7. 服务是否健康？`curl -sS http://127.0.0.1:8443/api/v1/health`。
8. HTTPS 入口是否已就位，所有者账号是否已经存在（所有者存在后，`/setup` 页面会返回 410）？

从第一个尚未完成的阶段继续。如果 `.env` 已存在但不完整（仍有占位值，或缺少配置令牌），完成第 1 步中 `.env` 相关的工作，而不是重新复制 `.env.example`。

## 你要做的事（先告诉用户）

共四步：运行服务器、加固安全、让 Contextify 连接到它、验证搜索和跨会话检索。你会在关键选择处暂停询问，所有操作都在用户的机器上运行。

## 第 1 步：预检、选择主机名、运行服务器

**[YOU RUN] 预检工具：** 必须具备 `git`、`python3`（3.8 或更高版本）、`curl`、`docker` 和 `docker compose`（`docker --version`、`docker compose version`、`git --version`、`python3 --version`、`curl --version`）。如果缺少任何一个，停下来询问用户想怎样安装；不要替他们选择安装方式。

**[ASK ME] 确定用户的各台机器访问这台服务器时使用的主机名**（只写主机名，不带协议）。它会固化到服务器配置和 TLS 证书中，所以要在写入任何配置之前选定。Tailscale HTTPS 主机名（`your-host.your-tailnet.ts.net`）最简单：名称稳定，证书有效，而且只在用户的私有 tailnet 内可用。记下这个主机名（例如 `hive.example.ts.net`）；HTTPS 源就是 `https://<hostname>`（如果用户输入时带了协议，不要重复添加）。

**[YOU RUN]** 在用户指定的目录中操作（不要放在另一个 git 仓库内）。如果 `contextify-cloud-self-hosted` 已经在那里，不要再次克隆。否则：

```
git clone https://github.com/PeterPym/contextify-cloud-self-hosted.git
cd contextify-cloud-self-hosted
```

仅当 `.env` 尚不存在时，才从 `.env.example` 创建 `.env`。创建时使用 `600` 权限模式；保留已存在的文件：

```sh
if [ ! -e .env ] && [ ! -L .env ]; then
  (umask 077; set -C; cat .env.example > .env)
fi
```

**[YOU RUN]** 在初始化缺失或默认的数据库密码之前，先确认数据库存储的状态。阅读 `docker-compose.selfhosted.yml`，找出数据库挂载，以及 `.env` 或 shell 中的任何卷名覆盖。用 `docker context show` 和 `docker volume ls` 核对目标 Docker 守护进程和确切的卷。容器已停止或已删除，并不代表它的卷不存在。如果卷已存在、使用了绑定挂载、检查失败或历史不确定，都应视为 `existing` 或 `unknown`。

仅在确认目标卷不存在、且不会挂载任何已有数据库之后，才能为下面的命令设置 `CONTEXTIFY_DB_STATE=uninitialized`。每次都先重新检查，再使用只作用于该命令的前缀 `CONTEXTIFY_DB_STATE=uninitialized python3`；绝不要 export 这个断言。否则不要设置该变量（即 `unknown`），或将其设为 `existing`。每次只运行一个初始化脚本，运行时服务栈须处于停止状态，且没有同时编辑 `.env` 的操作。使用本手册之前，先清除 shell 中对这三个机密键的覆盖，让 Compose 使用 `.env` 中的值。

初始化脚本会在写入前检查整个文件的赋值结构。它只为缺失或为空的机密键生成值，并替换两个自带的默认值。已有的非默认赋值会逐字节保持不变。它接受单行 `KEY=value` 赋值，包括带引号的字面机密值和行内注释。如果三个机密赋值中出现不支持的字面语法或变量插值，脚本会停下来等待本地检查，且不显示任何值。其他赋值保留原值，包括插值。

```sh
python3 - <<'PY'
import os
from pathlib import Path
import re
import secrets
import stat
import tempfile

def stop(message):
    raise SystemExit(message + " No secret values displayed; .env unchanged.")

path = Path('.env')
spec = {'API_SECRET_KEY': (32, 'dev-secret-change-me'),
        'DB_PASSWORD': (20, 'contextify'),
        'CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN': (32, None)}
if any(key in os.environ for key in spec):
    stop('Clear shell overrides for the three secret keys first.')
if path.is_symlink() or not path.is_file():
    stop('Expected a regular .env file; create it privately from .env.example if absent.')
original = path.read_bytes()
try:
    lines = original.decode('utf-8').splitlines(keepends=True)
except UnicodeError:
    stop('Expected UTF-8 .env text.')
found = {}
for index, line in enumerate(lines):
    if not line.strip() or line.lstrip().startswith('#'):
        continue
    assignment = re.fullmatch(r'([A-Za-z_][A-Za-z0-9_]*)=([^\r\n]*)[\r\n]*', line)
    if not assignment:
        stop('Unsupported .env assignment; review syntax locally.')
    key, raw = assignment.groups()
    # Multiline values could hide assignments; require single-line values throughout.
    raw = raw.strip()
    if raw.startswith(("'", '"')):
        quoted = re.fullmatch(r"(['\"])(.*?)\1(?:\s+#.*)?", raw)
        if not quoted:
            stop('Unsupported quoted value; use single-line assignments.')
    if key not in spec:
        continue
    if key in found:
        stop('Duplicate secret key: ' + key + '. Resolve it using trusted credentials.')
    value = quoted.group(2) if raw.startswith(("'", '"')) else re.split(r'\s+#', raw, 1)[0].strip()
    if any(char in value for char in '$\\\'"') or any(char.isspace() for char in value):
        stop('Unsupported secret syntax for ' + key + '; review the literal value locally.')
    found[key] = (index, value)
missing = [key for key, (_, default) in spec.items()
           if key not in found or not found[key][1] or found[key][1] == default]
if 'DB_PASSWORD' in missing and os.environ.get('CONTEXTIFY_DB_STATE', 'unknown') != 'uninitialized':
    stop('Database storage is existing or unknown. Follow the database credential recovery path below.')
for key in missing:
    replacement = key + '=' + secrets.token_hex(spec[key][0]) + '\n'
    if key in found:
        lines[found[key][0]] = replacement
    else:
        if lines and not lines[-1].endswith(('\n', '\r')):
            lines[-1] += '\n'
        lines.append(replacement)
updated = ''.join(lines).encode('utf-8')
if updated == original:
    path.chmod(stat.S_IRUSR | stat.S_IWUSR)
else:
    # mkstemp creates a private file beside .env; replace only after a complete write.
    fd, temporary = tempfile.mkstemp(prefix='.env.', dir='.')
    try:
        with os.fdopen(fd, 'wb') as output:
            os.fchmod(output.fileno(), stat.S_IRUSR | stat.S_IWUSR)
            output.write(updated)
            output.flush()
            os.fsync(output.fileno())
        os.replace(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)
print('Secret initialization complete; .env mode 600. Values preserved or initialized without display.')
PY
```

**数据库凭据恢复：** 如果存储已存在或其历史不确定，保持卷和 `.env` 不动。通过用户的本地编辑器，从用户可信的配置或备份中恢复已确立的 `DB_PASSWORD`。如果数据库是用默认密码初始化的，与用户一起安排一次有备份的 PostgreSQL 密码更改，然后更新 `.env` 使其一致。仅编辑 `.env` 不会更改 PostgreSQL 的密码。如果无法找回正确的密码，就停下来，转入数据库恢复。绝不要为了完成这一步而删除或重建卷。

对于重复的键，先让用户根据可信配置消除歧义，然后再重新运行。保留已确立的值，而不是碰巧出现在最后的那个赋值。注意事项：

- `API_SECRET_KEY` 不能保留为 `dev-secret-change-me`；否则服务器会拒绝启动。
- **首次启动前设置好 `DB_PASSWORD`。** PostgreSQL 会把密码保存在它的数据卷中。恢复配置流程时要保留这个密码。
- `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN` 不在 `.env.example` 中；没有它，Compose 无法启动。它控制一次性所有者配置页面的访问。

**[YOU RUN]** 把 `.env` 中的 URL 值设为本步骤开头确定的 HTTPS 源（这些不是机密信息，正常编辑即可）：

- `ALLOWED_ORIGINS`：将示例值整体替换为 `https://<hostname>`。
- `EMAIL_BASE_URL` 和 `INVITATION_BASE_URL`：都设为 `https://<hostname>`。
- 保留 `SELF_HOSTED=true`。保留 `DATABASE_URL` 这一行（这个 Compose 文件会忽略它）。邮件服务商是可选的：登录使用密码；当 `RESEND_API_KEY` 为空时，身份验证邮件不会发出，而是写入服务器日志。

**[YOU RUN]** 启动服务栈（首次运行会构建 API 镜像，需要几分钟）：

```
docker compose -f docker-compose.selfhosted.yml up -d --build
```

**[YOU RUN]** 确认本地健康状态。API 默认只绑定在回环地址 `127.0.0.1:8443` 上，首次启动时会运行数据库迁移，所以请等待并重试，最多 1 分钟：

```
curl -sS http://127.0.0.1:8443/api/v1/health
```

健康的服务器会返回 `{"status": "ok", "self_hosted": true}`。如果一直失败，检查 `docker compose -f docker-compose.selfhosted.yml logs api --tail 50`。等 HTTPS 在真实主机名上就位后，再在第 2 步创建所有者账号。

## 第 2 步：加固安全（Tailscale），然后创建所有者账号

API 只监听回环地址。在第 1 步选定的主机名上为它加一层 HTTPS，这样证书能证明该主机名，服务器也保持私有。

**[ASK ME] 在任何系统级网络变更之前。** 确认入口方式，并在改动项目目录之外的任何东西之前获得用户批准。

**Tailscale（认可的路径，仅限 tailnet）：**

1. 前提：用户已在 Tailscale 管理控制台中为其 tailnet 启用 MagicDNS 和 HTTPS 证书，并且这台机器已加入该 tailnet（`tailscale status`）。
2. **[YOU RUN]** 确认版本和语法（`serve` CLI 在不同版本间有过变化）：`tailscale version` 和 `tailscale serve --help`。
3. **[YOU RUN]** 在后台（持久运行、不阻塞）通过 tailnet HTTPS 主机名提供回环 API。认可的形式是将 HTTPS 代理到 `http://127.0.0.1:8443`；使用你所用版本的 `--help` 显示的写法，例如：

```
tailscale serve --bg http://127.0.0.1:8443
```

   使用 `tailscale serve`，绝不使用 `tailscale funnel`（`funnel` 会把服务器暴露到公共互联网，而这种部署不希望如此）。`--bg` 让它保持运行，而不占用前台。
4. **[YOU RUN]** 用真实主机名验证配置和实际的 HTTPS 路径：

```
tailscale serve status --json
curl -sS https://<hostname>/api/v1/health
```

   健康检查调用必须通过 HTTPS 返回 `{"status": "ok", "self_hosted": true}`，然后才能继续。

**公共反向代理（高级、风险自负，非默认）：** 仅在用户明确要求公开暴露服务器时使用。这会让登录页面可从互联网访问，并由用户负责 TLS、身份验证和速率限制的加固。没有单独的 **[ASK ME]** 让用户接受这些后果，就不要走这条路。如果用户接受，就由他们在自己的主机名上终止 TLS（例如使用 Caddy），并代理到 `127.0.0.1:8443`。

**[YOU RUN]** HTTPS 就位后，让配置与最终的源保持一致：

- 确认 `.env` 中的 `ALLOWED_ORIGINS`、`EMAIL_BASE_URL` 和 `INVITATION_BASE_URL` 都等于 `https://<hostname>`。
- 在 `.env` 中设置 `FORCE_SECURE_COOKIES=true`（示例文件默认是 `false`，用于本地 HTTP 验证）。
- 重新运行 `docker compose -f docker-compose.selfhosted.yml up -d`，让容器应用这项更改。

**[ASK ME] 在创建所有者账号之前**，然后按 **[USER DOES]** 由用户完成（两种方式都涉及机密信息，所以由用户执行，而不是你）。Personal Self-Hosted 是单用户的；这个首个账号就是服务器的所有者。先与用户确认邮箱，然后二选一：

- 用户在浏览器中打开真实 HTTPS 主机名上的一次性配置页面：`https://<hostname>/setup?token=THEIR_SETUP_TOKEN`（用户从自己的 `.env` 中读取 `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN`；不要打印它）。或者
- 用户在自己的终端中运行 `docker compose -f docker-compose.selfhosted.yml exec api python -m contextify_cloud create-admin --email them@example.com`，并在提示时输入密码。不要在命令行中传递 `--password`（它会留在 shell 历史记录和对话记录中）。

**[YOU RUN] 确认所有者已存在**，然后再继续：配置页面现在会返回 410（`curl -sS -o /dev/null -w '%{http_code}' https://<hostname>/setup`），表示所有者账号已经存在。配置表单上的工作空间名称字段只是这台单用户服务器的一个标签。所有者存在后，配置令牌即失效。

## 第 3 步：让 Contextify 连接到它

自托管服务器通过 API 密钥连接（浏览器登录流程是托管版提供的便捷方式，不用于自定义服务器 URL）。

**[USER DOES] 生成密钥：** 用户在自托管仪表盘（位于 HTTPS 源）中登录，然后在 Cloud 设置中创建一个具有同步权限的 API 密钥（以 `ctx_` 开头）。仅限搜索的密钥无法上传。

- **Mac 应用（[USER DOES]）：** 打开“Settings”，进入“Cloud”，选择“Contextify Cloud Self-Hosted”，输入服务器 URL，并粘贴 `ctx_` 密钥。
- **CLI：** `contextify cloud setup --url https://<hostname>` **[YOU RUN]** 会以交互方式提示输入 API 密钥；由 **[USER DOES]** 在提示处输入（或粘贴）`ctx_` 密钥。这是交互式读取，不是 shell 参数，所以不会留在历史记录中。然后 **[YOU RUN]** `contextify cloud status --json` 和 `contextify cloud sync`。还有一种无头形式（`contextify cloud setup --url ... --key ctx_... --no-input`）；如果使用它，应由用户自己运行，这样密钥就不会经过智能体键入的参数。

如果服务器只能在 tailnet 内部访问，请在该 tailnet 中的设备上运行连接命令。

## 第 4 步：验证搜索和跨会话检索

- **[YOU RUN]** `contextify cloud status --json`，确认最近的项目已经同步。只读仪表盘同样位于 HTTPS 源。
- 在 Mac 应用或仪表盘中搜索一个你确定出现在过往会话中的词。结果来自用户自己的服务器。
- 用 Total Recall（跨会话历史检索）把过去的某个决策或修复带入当前工作：在 Claude Code 中使用 `/total-recall`，在 Codex 中使用 `$total-recall`。

如果刚连接后搜索结果为空，应用会先为本地历史记录建立索引，再同步，所以数据量大的首次运行需要一些时间。这是预期行为，不是故障。

## 验证清单

- `curl -sS http://127.0.0.1:8443/api/v1/health` 返回 `{"status": "ok", "self_hosted": true}`。
- `curl -sS https://<hostname>/api/v1/health` 通过 HTTPS 返回相同结果。
- 所有者账号已存在（`/setup` 页面返回 410），并且可以在 HTTPS URL 上登录。
- `contextify cloud status --json` 显示自托管服务器和最近的同步。
- 搜索能返回一个已知的过往结果。

## 故障排查

- **Compose 无法启动，提示某个变量必须设置：** `.env` 中缺少一个必需的机密值。最常见的是 `CONTEXTIFY_SELF_HOSTED_SETUP_TOKEN`，它不在 `.env.example` 中，必须手动添加。
- **服务器因 API 机密值而拒绝启动：** `API_SECRET_KEY` 仍是 `dev-secret-change-me`。轮换它；不要禁用这项防护检查。
- **更改密码后 API 无法连接数据库：** 使用第 1 步的数据库凭据恢复流程。恢复已确立的密码，或与用户协调一次有备份的 PostgreSQL 密码更改。保留现有的卷。
- **通过回环地址访问 `/setup` 没有反应：** API 只绑定在回环地址上，所以从其他设备无法访问 `http://127.0.0.1:8443/setup`。请使用真实的 HTTPS 主机名，或使用 `create-admin` 命令。
- **`tailscale serve` 似乎卡住，或 HTTPS 健康检查失败：** 确认已在 Tailscale 管理控制台中启用 MagicDNS 和 HTTPS 证书，确认你使用了 `--bg`，并确认 `tailscale serve status --json` 显示了到 `127.0.0.1:8443` 的代理。绝不要改用 `tailscale funnel` 来“修复”可达性；那会把服务器公开暴露。
- **端口 8443 已被占用：** 有其他程序绑定了该端口。询问用户想怎样处理；不要悄悄修改 compose 文件中的端口。
- **Linux 上出现 `docker: permission denied`：** 用户不在 `docker` 组中（该组等同于 root 权限）。更改组成员身份之前先询问；不要条件反射地运行 `sudo docker`。
- **无法解析或访问主机名：** 分别排查 DNS、tailnet 路由、端口可达性，以及证书所标识的主机名。对于 Tailscale，确认这台机器已加入 tailnet，且证书与 `.ts.net` 名称匹配。
