Multica Docs

Self-Host 快速上手

在自己的服务器或本机用 Docker 把 Multica 跑起来(也可以在 Kubernetes 上用 Helm)。约 10 分钟。

这一页带你用 Docker 把 Multica 的服务器(后端 + 前端 + PostgreSQL)跑在自己的机器或服务器上。走完这一篇你的数据就完全在自己手里——包括 工作区issue评论智能体 配置。

智能体执行还是靠你本地跑的 守护进程 + 本地装好的 AI 编程工具——这点和 Cloud 完全一样。Self-host 换掉的是服务器那一层,不是执行那一层。

前置要求

  • Docker 安装好并且能跑 docker compose
  • Git 可选(推荐——可以拉源码)
  • 一台能长期开机的机器(本地 / 内网 / 云主机都行)
  • 至少一款 AI 编程工具装在运行守护进程的机器上(不一定是跑服务器的机器——可以是你开发用的笔记本)

1. 拉取项目 + 一键启动后端

已经有 Kubernetes 集群? 不用走 Docker,直接用 Helm chart——跳到下面的 Kubernetes 部署(替代方案),装完再回到 第 4 步 完成登录。

git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost

make selfhost 会:

  1. 如果没有 .env 文件,从 .env.example 自动生成一份,并生成随机 JWT_SECRET 和 Postgres 密码
  2. 拉取官方 Docker 镜像(PostgreSQL、Multica backend、Multica frontend)
  3. docker-compose.selfhost.yml 启动全部服务
  4. 等后端 /health 端点准备就绪

如果是启动完成后的生产探针,想让数据库或 migration 异常也体现为失败,请改用 /readyz

后端容器启动时会自动跑数据库 migrationdocker/entrypoint.sh 在启动 server 前执行 ./migrate up)——你会在 backend 日志里看到 migration 输出。升级版本时同样自动处理。

镜像还没发布? 如果 make selfhost 报拉不到镜像,可能是你在某个未发布的版本标签上。切到稳定版本或直接从源码构建:make selfhost-build

启动完成后:

所有端口只监听 127.0.0.1 docker-compose.selfhost.yml 把每个 publish 出来的端口都绑到 loopback —— ss -tlnp 不会看到 0.0.0.0:8080,外网/其它机器默认根本连不上。这是为了避免服务密钥和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS,详见下方 Step 5b —— 跨机访问:用反向代理把服务挡在前面

2. 重要:保持生产安全配置

docker-compose.selfhost.yml 默认把 APP_ENV 设成 production,并让 MULTICA_DEV_VERIFICATION_CODE 为空,所以公网实例默认没有固定验证码。

只在本地或私有测试自动化里设置 MULTICA_DEV_VERIFICATION_CODE。如果在 APP_ENV 非 production 时启用了固定验证码,任何能请求验证码的人都能用这个固定值登录。详见 登录与注册配置 → 固定本地测试验证码

公网部署前一定检查 .envAPP_ENV=production,且 MULTICA_DEV_VERIFICATION_CODE 为空。

3. 配置邮件服务(可选但推荐)

如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。

支持两种发送通道,按部署环境二选一:

Option A — Resend(公网/云端部署):

  1. Resend 注册并拿一个 API key

  2. 验证一个你控制的发件域名

  3. .env 里设:

    RESEND_API_KEY=re_xxxxxxxxxxxx
    RESEND_FROM_EMAIL=noreply@yourdomain.com

Option B — SMTP relay(内网/自部署):

适合内网无法访问 api.resend.com,或已经有内部邮件中继(Microsoft Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 SMTP_HOST 优先级高于 Resend,验证码和邀请邮件不会走外部 provider。服务端 advertise STARTTLS 时会自动升级;端口 465(SMTPS / 隐式 TLS)会自动启用连接即握手的 TLS 模式,非标准 SMTPS 端口用 SMTP_TLS=implicit(别名:smtpsssl)强制开启。

匿名 Exchange 内部 relay(端口 25) —— 主机按 IP 被信任,不需要凭据:

SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com  # 同时作为 SMTP From: 头

认证提交(端口 587,STARTTLS) —— relay 需要 service account;服务端 advertise STARTTLS 时自动升级:

SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false        # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com

隐式 TLS / SMTPS(端口 465) —— 适用于阿里云 / 腾讯企业邮箱等不广告 STARTTLS 的服务商。端口 465 自动启用隐式 TLS,因此这里 SMTP_TLS 可省略:

SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit              # 465 上可省略;非标准 SMTPS 端口上必填
RESEND_FROM_EMAIL=noreply@yourdomain.com

对于拒绝来自公网 IP 的默认 localhost 问候的严格公网 relay(例如 Google Workspace smtp-relay.gmail.com,把 SMTP_EHLO_NAME 设成 relay 期望的 FQDN——否则连接会被直接断开,并在后续某条命令上表现为一个不知所云的 EOF。它默认取容器主机名,而后者通常不是合法的 FQDN:

SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com   # relay 接受的 FQDN;默认取(非 FQDN 的)容器主机名
RESEND_FROM_EMAIL=noreply@yourdomain.com

之后重启:docker compose -f docker-compose.selfhost.yml restart backend。重启时 backend 会打印当前选择的 provider 和协商出的 TLS 模式(EmailService: SMTP relay <host>:<port> (starttls|implicit-tls) from=… / Resend API / DEV mode),密码不会被记录,所以这行截图给同事是安全的。

更多 auth 配置(OAuth、注册白名单)以及完整的 SMTP 变量说明见 登录与注册配置环境变量

4. 首次登录 + 创建工作区

打开 http://localhost:3000

  • 输入你的邮箱
  • 从你配置的邮件后端(Resend 或 SMTP relay)收到的邮件里拿验证码;两者都没配的话,从 server 容器的 stdout 里抄 [DEV] Verification code 这行
  • 不要直接使用 888888;只有在非 production 私有实例上显式设置 MULTICA_DEV_VERIFICATION_CODE=888888 后它才会生效
  • 登录后创建第一个工作区

5. 连接命令行工具到你自己的 server

命令行装法和 Cloud 快速上手 → 2. 装命令行工具 一样——Homebrew / 脚本 / PowerShell 任选。

5a. 同一台机器

CLI 和 server 在同一台机器上时,默认参数就够用:

multica setup self-host

会自动连 http://localhost:8080(backend)+ http://localhost:3000(frontend),引导你在浏览器里登录、把 PAT 存到本地、自动启动守护进程

5b. 跨机访问:用反向代理把服务挡在前面

因为 compose 默认只监听 127.0.0.1,从别的机器跑的 daemon 是连不上 http://<server-ip>:8080 的——这也是有意为之,否则服务密钥会直接暴露在公网。正确做法是在 server 上跑一个反向代理(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 127.0.0.1:8080(backend)和 127.0.0.1:3000(frontend)。然后把 CLI 指到公开的 HTTPS 域名:

multica setup self-host \
  --server-url https://<你的域> \
  --app-url https://<你的域>

更习惯用环境变量?省略对应 flag 时,setup self-host 会读取 MULTICA_SERVER_URLMULTICA_APP_URL(同时设置时 flag 优先)。MULTICA_SERVER_URL 也接受环境变量里那种 ws://…/ws 的 daemon 写法,并自动归一化为 HTTP 地址。

最小可用的 Caddyfile,单域名同时挂前后端(带 WebSocket 转发,daemon 和网页端都依赖):

multica.example.com {
    # WebSocket 路由——必须在 catch-all 之前
    @ws path /ws /ws/*
    handle @ws {
        reverse_proxy 127.0.0.1:8080 {
            flush_interval -1
        }
    }

    # Backend API
    handle /api/* {
        reverse_proxy 127.0.0.1:8080
    }

    # 其它请求 → 前端
    reverse_proxy 127.0.0.1:3000
}

代理起好之后,记得在 server 的 .env 里把 FRONTEND_ORIGIN 设成 https://multica.example.com 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 故障排查 → WebSocket 连不上)。

Cloudflare Tunnel 也是不错的选择——它直接给一个公开域名 + TLS,host 上不用对外暴露任何端口。Nginx 也能做(分 app. / api. 两个域名 + proxy_set_header Upgrade 转 WebSocket),关键就是终结 TLS、并在 /ws 上转发 Upgrade 头。

6. 创建智能体 + 分配第一个任务

流程和 Cloud 一样——见 Cloud 快速上手 → 5-6 步

7. 用量汇总(无需运维操作)

Usage / Runtime 看板读的是派生表 task_usage_hourly,由 rollup_task_usage_hourly() 周期性填充。从 MUL-2957 起,后端通过 DB 后端的调度器在进程内运行该 rollup —— 不再需要 pg_cron,外部 cron / systemd timer 也不再是推荐方案。默认的 pgvector/pgvector:pg17 镜像无需改动即可工作。

进程内调度器每 30 秒 tick 一次,通过 sys_cron_executions 表认领 5 分钟一档的 UTC plan。多 backend 副本同时跑也安全 —— 唯一键 (job_name, scope_kind, scope_id, plan_time) 保证每个 plan 只有一个赢家。新部署不需要任何额外配置。

兼容性 —— 已注册的 pg_cron 任务。 如果你之前用 pg_cron 注册过 rollup(SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', …)),不删也行 —— SQL 函数内部持有 advisory lock 4246,应用调度器和 pg_cron 不会并发双写。要清掉冗余项可以执行:

SELECT cron.unschedule('rollup_task_usage_hourly')
  FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';

v0.3.4 → v0.3.5+ 升级。 上一版要求运维在应用 migration 103 之前手动跑 cmd/backfill_task_usage_hourly,否则 fail-closed 守卫会中止 migrate up。从 MUL-2957 起这一步是自动的:migrate 命令会在应用 migration 103 之前(advisory lock 4246 保护下)运行幂等的按月切片 backfill,然后继续。在繁忙的数据库上你仍可以用 --sleep-between-slices=2s 跑独立 backfill 来限制读压力,但已不是必需。

完整参考(含运维注意事项和 Kubernetes 部署形态)见仓库的 SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup

升级

Docker Compose 部署有两种升级写法。在已有部署上两者结果相同——Makefile 里的 selfhost target 跑的就是同一套 docker compose pull + up -d,只是额外多做了两件事:.env 缺失时生成一份,以及等 /health 就绪后打印一段状态汇总。用哪个都行:

cd multica
git pull
make selfhost
cd multica
git pull
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d

git pull 到底更新了什么

git pull 更新的是 docker-compose.selfhost.yml 本身——新增的环境变量、新增的服务、改过的 healthcheck。它不是拉新版本 Multica 的机制。

真正决定你跑哪个版本的是 docker compose pull:它去 GHCR 查这个 tag 当前指向哪个镜像。所以一个几个月没更新的 checkout 照样能拉到今天的 latest 镜像;反过来,只跑 git pull 而不拉镜像、不重建容器,什么都不会变。

如果你在 .env 里钉死了 MULTICA_IMAGE_TAG,两种写法都升不上去。 两个镜像都写成 ${MULTICA_IMAGE_TAG:-latest}docker-compose.selfhost.yml:42:125),而 .env.example 里默认是 MULTICA_IMAGE_TAG=latest。一旦你把它改成了具体版本,pull 只是把同一个 tag 重拉一遍,版本原地不动——不报错,也没有任何警告。升级前先确认:

grep MULTICA_IMAGE_TAG .env
# MULTICA_IMAGE_TAG=v0.4.5   ← 钉死了:先改成 latest(或你想要的版本)

.env 不会被覆盖

make selfhost 只在 .env 不存在的时候才生成它。在已有部署上重复跑,你的 JWT_SECRET、Postgres 密码、邮件配置、FRONTEND_ORIGIN 都原样保留。

升级前先备份 Postgres

migration 只往前走、不回滚,所以正式环境升级前先导一份:

docker compose -f docker-compose.selfhost.yml exec -T postgres \
  pg_dump -U multica multica > multica-backup.sql && gzip multica-backup.sql

不要把 pg_dump 直接管道接给 gzip shell 取的是管道里最后一条命令的退出码,所以 pg_dump ... | gzip > backup.sql.gz 在导出失败时照样返回 0,留下一个格式完全合法、但里面什么都没有的 20 字节压缩包。先重定向到文件,pg_dump 自己的退出码才算数,&& 也只会压缩真正成功的那份导出。

如果你改过 POSTGRES_USER / POSTGRES_DB,把 multica 换成 .env 里的实际值。数据存在名为 multica_pgdata 的 volume 里,docker compose down 不会删它——但 down -v 会。

migration 自动执行

第 1 步一样,backend 容器启动时会在对外提供服务之前先跑 ./migrate updocker/entrypoint.sh)。没有单独的升级命令——把新镜像起起来,本身就是升级这一步。想看过程:

docker compose -f docker-compose.selfhost.yml logs -f backend

/readyz 验证,不要用 /health

/healthliveness 探针,只要进程还活着就返回 {"status":"ok"},migration 失败了它照样是 ok。/readyzserver/cmd/server/router.go:680/healthz 是它的别名)会真正检查数据库和已应用的 migration 集合,升级出问题能被它拦下来:

curl -s localhost:8080/readyz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}

只要不是 HTTP 200 且两项都是 ok,就说明新版本没把 migration 跑完,先去看 backend 日志,别急着放流量进来。

Kubernetes

Helm 有自己的升级路径:在 values 文件里把 images.backend.tag / images.frontend.tag 设成你要的版本,然后 helm upgrade。改 tag 就改了 pod spec,Kubernetes 会拉新镜像并滚动更新——这是可靠的路径。

kubectl -n multica rollout restart 本身不是升级。chart 默认 pullPolicy: IfNotPresentdeploy/helm/multica/values.yaml),节点上已经缓存过这个 tag 的话,restart 只会用回旧镜像,什么都没变——和钉死 MULTICA_IMAGE_TAG 是同一类坑。真要靠浮动 tag 走这条路,得先把 images.backend.pullPolicy / images.frontend.pullPolicy 设成 Always。见下面的 Kubernetes 部署(替代方案)

Kubernetes 部署(替代方案)

如果你已经在跑 Kubernetes 集群,仓库里也带了一个 Helm chart,路径 deploy/helm/multica/。它就是 k8s 版的 make selfhost——一样的 backend 镜像、frontend 镜像、pgvector/pgvector:pg17 Postgres,封装成 Deployment / Service / Ingress,再加上一个由 values.yaml 渲染出来的 ConfigMap。这套 chart 是按照 k3s + Traefik + local-path 写的,集群里只要有 Ingress controller 和默认的 ReadWriteOnce StorageClass 就能跑,其他类型的集群稍微改一改也能用。

这个 chart 不会模板化任何敏感值。它通过 name 引用一个叫 multica-secrets 的 Secret,所以真实的 JWT / DB / Resend / Google 密钥永远不用进 git,也不用进 values.yaml。先用 kubectl 一次性把命名空间和 Secret 建好:

kubectl create namespace multica

kubectl -n multica create secret generic multica-secrets \
  --from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
  --from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
  --from-literal=RESEND_API_KEY="" \
  --from-literal=GOOGLE_CLIENT_SECRET="" \
  --from-literal=CLOUDFRONT_PRIVATE_KEY="" \
  --from-literal=MULTICA_DEV_VERIFICATION_CODE=""

再装 chart:

git clone https://github.com/multica-ai/multica.git
cd multica
helm install multica deploy/helm/multica -n multica

默认主机名是 multica.dev.lan(web)和 api.multica.dev.lan(backend)。把它们加进 /etc/hosts(或者本地 DNS),指向任意一个 Ingress 可达的节点 IP 就行。要换主机名,就把 deploy/helm/multica/values.yaml 复制一份,改掉 ingress.frontend.host / ingress.backend.host,再把 backend.config.appUrl / frontendOrigin / localUploadBaseUrl / googleRedirectUri 改成相应的地址,然后 helm install ... -f my-values.yaml

冷集群上 backend 可能会 RunningNot Ready 持续几分钟,等 Postgres 起来并跑完 migration——startupProbe 会兜住这一段,pod 不会被 liveness 重启。等它 Ready 之后:

curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}

然后浏览器打开 http://multica.dev.lan,回到上面的 第 4 步——首次登录 继续。命令行连到你的 Ingress 主机:

multica setup self-host \
  --server-url http://api.multica.dev.lan \
  --app-url http://multica.dev.lan

要换到某个 Multica 版本,就在 values 文件里设 images.backend.tag / images.frontend.tag,再 helm upgrade——升级也走这条路,因为 tag 变了才会强制重新拉取并滚动更新。kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend 只有在你同时把 images.backend.pullPolicy / images.frontend.pullPolicy 设成 Always 时才会重新拉浮动 tag;chart 默认是 IfNotPresent,会直接复用节点上已缓存的镜像。helm -n multica uninstall multica 只删工作负载,PVC 和 Secret 都保留;kubectl delete namespace multica 才会全清。

完整参考——三种登录方式、为了绕过 web 镜像 build-time 写死的 REMOTE_API_URL 而加的 backend ExternalName 别名、资源限制、TLS——都在仓库的 SELF_HOSTING.md

常见问题

  • 后端起不来:看容器日志 docker compose -f docker-compose.selfhost.yml logs backend;常见是 .envDATABASE_URLJWT_SECRET 有问题
  • 验证码收不到:没配任何邮件后端(Resend 和 SMTP 都没设) → 从 docker compose logs backend 里找 [DEV] Verification code
  • WebSocket 连不上:公网部署必须设 FRONTEND_ORIGIN 成你真实的前端域名;见 故障排查 → WebSocket 连不上
  • Usage / Runtime 看板一直是 0:没人调度 rollup_task_usage_hourly() —— 见上面的 第 7 步故障排查 → Usage 看板一直是 0
  • migrate uprefusing to drop legacy daily rollupsv0.3.4 → v0.3.5+ 升级路径的 fail-closed guard。从 MUL-2957 起 migrate 命令在应用 migration 103 之前会自动跑 backfill —— 见 第 7 步

下一步

  • 环境变量 —— 完整 env 清单
  • 登录与注册配置 —— Resend / OAuth / 注册白名单详细配置
  • GitHub 集成 —— 连一个 GitHub App,让 PR 自动关联 issue、merge 时自动转 Done
  • 故障排查 —— 遇到问题先来这里
  • 桌面应用 —— 可以通过 ~/.multica/desktop.json 连接 Desktop;Web 前端 + CLI 仍然是最快的自部署路径