自托管全平台 Todo 系统搭建实录:选型、部署与二次开发
日期:2026-09-02 | 标签:自托管 · Docker · 二次开发 · Go · Vue
本文记录一次完整的自托管待办系统落地过程:从开源选型、容器化部署,到邀请制改造、社区版功能解锁、自定义镜像构建与多端客户端接入。全程跑在一台 2C / 1.6G 内存 的小服务器上,所有源码与构建链路完全自持。
一、背景与需求
需要一个"自己的"待办系统,而不是把数据放在别人家 SaaS 里。硬性要求:
- 五端可用:Web / Android / iOS / 鸿蒙(HarmonyOS NEXT) / macOS
- 数据自持:自建服务器 + 自有域名子域,全站 HTTPS
- 可控可二次开发:源码在自己手里,随时改
二、方案选型:为什么是 Vikunja
先做了两条路线的对比:
| 维度 | 全自研(Flutter 五端 + 自建后端) | 开源 Vikunja + 自研补缺 |
|---|---|---|
| 上线时间 | 10~12 周(单人全栈) | 1 天 |
| Web | 自研 PWA | 官方,功能完整 |
| Android / iOS | 自研 Flutter | 官方 App |
| macOS | 自研 | 官方桌面端已归档 → PWA / CalDAV |
| 鸿蒙 | 自研 ArkTS | 无官方 → PWA 兜底,后续自研 |
| 二次开发 | 完全自主 | AGPLv3,自用/自托管无限制 |
Vikunja 打动我的点:
- 功能完整度在自托管里是第一梯队:清单/看板/甘特/表格四种视图、子任务、标签、优先级、截止日期、重复任务、提醒、附件、评论、CalDAV(可直接喂给系统日历/提醒事项)、Todoist/Trello/微软待办一键导入;
- 部署极轻:API 与前端打进单个容器,支持 SQLite——对 1.6G 内存的服务器非常友好;
- 维护活跃:v2.6.0(2026-08-31 发布)两个月内连发四个版本,380 commits / 45 个新特性;
- 单容器、单二进制:前端通过
go:embed打进 Go 二进制,交付物只有一个文件。
结论:Vikunja 为主,需要什么自研补什么,而不是从零造轮子。
三、部署架构
[Web PWA] [Android/iOS App] [macOS] [鸿蒙 PWA]
\ | | /
\ | | /
todo.example.com ← Caddy 反向代理(自动 Let's Encrypt 证书)
|
┌────────┴────────┐
│ Vikunja 容器 │ ← 127.0.0.1:8090,仅本机可访问
│ (SQLite 存储) │
└────────┬────────┘
每日 cron 备份:SQLite 在线快照 + 附件打包,保留 14 天
几个关键决定:
- Caddy 做反代:配置即代码,新增子域 = Caddyfile 加一段,TLS 证书自动签发续期,零手工;
- SQLite 而非 PostgreSQL:单用户/小团队场景完全够用,省掉一个常驻数据库进程——1.6G 内存的机器经不起 Postgres + Redis 的折腾;
- 备份用
sqlite3 .backup语义的在线快照(Pythonsqlite3模块即可),不用停服务拷文件,避免文件锁导致的数据不一致; - 代码托管到自建 GitLab:主仓库 + 官方移动端/桌面端仓库镜像,全部私有,附带 FORK-NOTES 文档说明基线版本与「同步上游 → 合并 → 推回」的更新工作流。
四、二次开发①:邀请制注册
需求很简单:不要开放注册,只有拿到邀请码的人才能注册。
后端(Go):
- 新增配置项:
service.registrationinvitecode(环境变量注入,留空 = 不校验,向后兼容); - 注册入口 v1/v2 共用同一个实现,改一处即全覆盖:邀请码缺失/错误时返回
400 + invalid_fields: ["inviteCode"]——这个格式能让前端把错误精确显示到输入框下方,而不是顶部一条笼统提示。
前端(Vue 3):
- 注册页新增"邀请码"输入框,空值客户端拦截 + 服务端二次校验双保险;
- i18n 补充中英文案。
验证:无码 → 400;错码 → 400;正确码 → 注册成功。三步全过,改配置即可换邀请码,无需再改代码。
五、二次开发②:解锁社区版管理后台
这里踩到一个"惊喜":Vikunja v2.4.0 起把管理后台(is_admin、/admin 路由、CLI 授权)锁进了付费 Pro 授权。社区版用户即使注册了,也不存在"管理员"——CLI 直接拒绝:
The admin-panel license feature is not active; refusing to change the is_admin flag.
追代码发现门禁很集中,全部收敛在 license 模块两个函数上:
IsFeatureEnabled(FeatureAdminPanel)—— 后端路由中间件与 CLI 都走它;EnabledProFeatures()—— 通过/info接口上报给前端,前端据此决定是否渲染 Admin 菜单。
作为自托管的 AGPL fork,改动就两处:前者对 FeatureAdminPanel 恒返回 true;后者在免费模式下也把 admin_panel 加进上报列表。重新编译部署后:
vikunja user set-admin <用户名> --admin
# → User "<用户名>" is now an instance admin.
前端 /info 返回 enabled_pro_features: ["admin_panel"],Admin 菜单出现,用户管理 / 项目管理 / 实例总览全部可用。同类 Pro 功能(如时间追踪)也留了一模一样的解锁口子,需要时照抄模式即可。
说明:这是在自托管实例上的自用修改,AGPLv3 完全允许。若做对外商业服务则需注意协议义务。
六、自定义镜像构建:低内存服务器上的正确姿势
官方 Dockerfile 面向多架构发布流水线设计,依赖 buildx + xgo(跨平台交叉编译),在小服务器上直接构建会遇到 BUILDPLATFORM 未定义等一堆问题。
于是自写了一个单架构 Dockerfile.custom,几个关键点:
- 国内镜像加速:npm/pnpm 走 npmmirror、Go modules 走 goproxy.cn、apk 换阿里云源——海外源在国内服务器上能卡到你怀疑人生;
- 串行编译:
GOFLAGS=-p=1。并行编译在 1.6G 内存上会内存抖动,单个大包(如 go-redis)能编译 25 分钟不完成;串行后峰值内存可控,速度反而稳定; - cgo 注意:Vikunja 的 SQLite 驱动(mattn/go-sqlite3)是 cgo 实现,需要
gcc + musl-dev并静态链接,CGO_ENABLED=0会直接编译失败; - swap 兜底:临时扩了 8G swapfile,编译期间内存水位平稳很多。
构建命令:
docker build -f Dockerfile.custom -t vikunja-custom:2.6.0 .
七、踩坑清单(都是真金白银换的)
| 坑 | 解法 |
|---|---|
官方镜像默认 uid=1000 对 /app 无写权限,附件目录初始化失败导致容器反复重启 |
compose 里 user: "0:0" |
service.jwtsecret 配置项已弃用 |
改用 service.secret(新版环境变量) |
新版 GitLab CE 拒绝 deploy key 推送(can_push=true 也拒) |
改用 Personal Access Token + HTTPS 推送 |
| buildx 二进制下载损坏(65MB 的文件只下来 5MB),docker 命令 Bus error | 下载后校验文件大小,损坏文件会导致整个 CLI 崩溃 |
| 后台构建任务随终端会话退出被误杀 | 用可跟踪的后台会话管理,不要裸 nohup |
| 编译内存抖动,单包 25 分钟无进展 | -p=1 串行编译 + 扩 swap |
前端 go:embed 要求构建时 frontend/dist 必须存在 |
多阶段构建里先跑前端再编 Go |
八、多端客户端现状
- Android / iOS:官方 App(Flutter)登录页直接填服务器地址即可连自建实例;
- macOS:官方桌面端(electron 封装)已归档停更——用 PWA 完全够用,或把 CalDAV 指给系统"提醒事项",零开发获得原生体验;
- 鸿蒙:官方无客户端,浏览器 PWA 添加到桌面兜底;原生 ArkTS 客户端列入计划,复用同一套 REST API,客户端只做 UI,工作量可控。
九、小结
一套"自己的"全平台 Todo 系统,从零到可用:
- 1 天:Vikunja 容器部署 + 域名反代 + HTTPS + 备份上线;
- 半天:邀请制改造 + 管理后台解锁 + 自定义镜像构建链路打通;
- 长期:源码、数据、构建、部署全链路自持,上游更新随时可合并。
开源 + 自托管 + 克制而精准的二次开发,是个人/小团队获得"私有 SaaS"性价比最高的路径。
本文为技术记录,所有域名、凭据均已脱敏。
