openGym 自托管实战指南:4 条命令搭好自己的健身记录 App,含手机配对与历史迁移
【免费下载链接】openGymOpen-source gym & body-weight tracker, now in the App Store (iPhone + Apple Watch) and as an Android APK. Plan routines, log workouts, see which muscles are trained, import from Strong/Hevy/FitNotes. Self-host it or use it on the phone alone.项目地址: https://gitcode.com/GitHub_Trending/op/openGym
openGym 是一款开源、可自托管的健身房与体重训练记录 App:按周计划常规动作、逐组记录训练、查看肌肉训练与恢复情况,并支持从 FitNotes、Strong、Hevy 导入历史数据。数据存放在你自己服务器上的一个文件夹里,没有订阅、没有广告、没有遥测。本文以仓库源码与官方文档为据,把自托管部署、三种使用形态的取舍、手机 App 配对、历史迁移、数据备份与常见故障排障各讲一遍,每一步都能在仓库里查证。
读完这篇,你可以做到:
- 用 4 条命令完成 Docker 自托管部署,创建第一个 Passkey(指纹/面容登录)档案
- 分清"浏览器 PWA / 手机纯本地 / 手机配对服务器"三种形态,选对适合自己的那种
- 完成"服务器地址 + 配对码"的手机配对,并处理 401、409、CORS、离线四类故障状态
- 把 FitNotes / Strong / Hevy / Apple Health 的历史数据迁进来,并用一条
tar命令备份全部数据
1. 从零开始:4 条命令跑起自托管 openGym
前提只有一台装了 Docker(含 Compose 插件)的机器。
git clone https://gitcode.com/GitHub_Trending/op/openGym cd openGym cp .env.example .env docker compose pull docker compose up -d仓库自带的 docker-compose.yml 里只有两个容器:api(纯node:http的 Node 服务,负责 Passkey 认证与每用户数据)和web(nginx,构建并托管前端,同时把/api代理到 api 容器)。动作动画(180 px,约 80 MB)打包在 web 镜像内,首次启动没有任何额外下载。
验证部署:
docker compose ps curl http://localhost:8080/api/health # 返回 {"ok":true,...}浏览器打开http://localhost:8080,点Create profile即可创建档案——跑 Docker 的这台机器本身就是localhost,Passkey 可以直接用。
为什么 nginx 必须把
/api代理到同一域名?Passkey 绑定"单一来源"(应用所在域名),浏览器只认这一个域。官方把前端与 API 放在同一 origin 下,正是为了满足这条硬性要求(见 README.md "How it works")。实用推论:以后你换反向代理时,也要保证前端页面和/api/从同一个 origin 出去,否则登录直接失效。
2. 三种使用形态怎么选:浏览器 PWA、手机本地、配对模式
openGym 从同一份代码构建出三种形态:自托管版是默认形态,手机原生版则靠VITE_MOBILE=1开关构建(见 frontend/package.json 的build:mobile脚本,该脚本执行 Vite 构建后自动cap sync进android/与ios/两个原生工程)。差异汇总自 docs/MOBILE.md:
| 浏览器 PWA(自托管) | 手机纯本地模式 | 手机配对模式 | |
|---|---|---|---|
| 登录方式 | Passkey,每人一个档案 | 无账号,手机即账号 | 一次性配对码 + bearer token |
| 数据存放 | 服务器./data文件夹 | 手机私有存储里的文件 | 服务器(手机保留本地镜像) |
| 训练日提醒 | 服务器发 Web Push | 原生本地通知,完全不经过服务器 | 原生本地通知(配后台核对) |
| 适用场景 | 多设备同步、家人共用实例 | 不想装服务器 | 想要原生体验又想要同步 |
手机形态默认不与任何后端通信:没有登录页、没有同步、没有遥测。为对抗 iOS 在存储压力下清空 WebViewlocalStorage的行为,每次状态变更都会再写一份opengym-state.json到 App 私有目录,启动时从该文件恢复(机制见 frontend/src/lib/mobile.js 头部注释与nativeSave)。实用推论:纯本地模式下手机是唯一副本——建议打开 Settings → Data & backup →Auto-backup on changes,之后每完成一次训练就在Documents/openGym/下生成当天日期的 JSON 副本(每个安装只保留最新 14 个),可以直接把 Syncthing 或云文件夹指向它。
不想装服务器也有路:Android 的官方签名 APK 随版本发布(每个文件旁附.sha256,应用内更新器会保持在同一规格内升级);iPhone 可用 App Store 版,或自托管后从 Safari"添加到主屏幕"。
3. Passkey 的硬性要求:先定死 .env 里三件事
浏览器对 WebAuthn 只有两条规则:Passkey 绑定到一个精确的主机名(RP_ID),且只在HTTPS下生效——唯一例外是http://localhost。由此推出两个结论:
- 跑 Docker 的机器上,
http://localhost:8080开箱即用; - 手机走
http://<局域网IP>:8080时 Passkey 弹不出来——它既不是 localhost 也不是 HTTPS。要从手机用 Passkey,必须上真实 HTTPS 域名。
官方给出的做法(.env.example 注释与 docs/SELF_HOSTING.md):把实例放到 Cloudflare Tunnel / Caddy / Traefik / nginx 之后做 TLS 终结,然后只改两行:
RP_ID=gym.example.com ORIGIN=https://gym.example.com WEB_PORT=8080这两个值的写法是最常见的登录故障来源,要求如下:
| 变量 | 正确 | 常见错误 |
|---|---|---|
RP_ID | gym.example.com | https://gym.example.com、带端口、带尾斜杠 |
ORIGIN | https://gym.example.com | 裸域名、带尾斜杠 |
RP_ID是裸主机名,ORIGIN是带协议的完整来源,两者都必须与浏览器地址栏逐字一致。还有两条容易踩:
改了
RP_ID之后,已有 Passkey 全部失效(它们绑定在旧主机名上)——先定好域名再邀请别人注册。
docker compose restart不会重新读取.env,改完配置必须用docker compose up -d。
.env里其余值得知道的开关(默认值出自 README.md 配置表):
| 变量 | 默认 | 作用 |
|---|---|---|
SESSION_DAYS | 90 | 登录 / 手机配对的有效期(天) |
FIRST_USER_ADMIN | 关 | 1:空实例上第一个注册的档案成为管理员 |
INVITE_ONLY | 关 | 1:新建档案需要邀请码 |
ALLOW_GUEST | 开 | "Continue without account" 访客模式(数据只留在那个浏览器) |
PASSWORD_LOGIN | 关 | 1:在 Passkey 旁启用用户名 + 密码登录 |
4. 手机为什么不能用 Passkey 登录:配对码 + 半寿 token 的完整链路
这是整套系统里设计含量最高的部分。App 的 WebView 运行在自己的 origin 上,永远匹配不上 WebAuthn 需要的真实主机名,所以 Passkey 在物理上走不通(docs/SELF_HOSTING.md 第 2 节明确说明了这一点)。官方替代方案是"从一个已登录的浏览器完成配对":
- 浏览器里打开 Settings → Account →"Pair the mobile app",生成一个 8 位一次性码:有效期 5 分钟,字母表刻意去掉
0/O/1/I(方便对着屏幕念出来)——生成逻辑见 api/server.js 的makePairCode; - 在手机(首次启动界面,或之后 Settings →"Connect to my server")输入服务器地址与该码;
- 服务端
POST /api/pair/redeem不需要会话——码本身就是凭据,取出后立即删除(防重放),校验未过期且账号未被禁用后,签发一枚与浏览器会话同类的 session token 返回。
手机端的地址输入很宽容:裸主机名、缺协议、尾斜杠、多余空白都接受,默认补https://(见 frontend/src/lib/remote.js 的normalizeServerUrl)。
Token 半寿续期:用过的手机永不掉线
配对拿到的 token 寿命为SESSION_DAYS(默认 90 天),但它会自我续期:手机每次启动都请求GET /api/me,服务器发现 bearer token 剩余寿命不足一半时,就在响应里附一枚新 token(见 api/server.js);客户端的renewToken把新 token 落回配对文件(见 frontend/src/lib/remote.js)。api/test/server-token-refresh.test.js 用一个SESSION_DAYS=10的实例把这条行为钉死在测试里。
实用推论:
- 手机只要还在被使用就永远不会过期;只有超过 90 天完全没打开过才需要重新配对;
- 浏览器里的"Sign out everywhere" 会连带杀掉已配对手机——它们用的是同一枚签名会话 token,只是经请求头而不是 cookie 交付。手机没有 Passkey 可以重登,只能重新配对;期间手机上的数据分毫不动,重新配对同账号后自动合并。
配对模式下的三个注意点
- 尽量用
https://:传输的是 bearer token 而不是 cookie,明文 http 下 token 会裸奔过网。Android 自 v1.3.11 起也接受http://<局域网地址>,仅限可信网络。 - 不要在 openGym 前面自己加 CORS 处理:App 的 origin 是
https://localhost(Android)/capacitor://localhost(iOS),每个请求前都有 preflight;openGym 自己应答这个 preflight。如果反向代理抢先应答、或额外加了一个Access-Control-Allow-Origin(出现两个同名字段浏览器也拒收),配对会直接失败而 openGym 容器日志一片干净。任意机器上可以自检:
curl -si -X OPTIONS https://HOST/api/pair/redeem \ -H 'Origin: https://localhost' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type'看到204且恰好一行access-control-allow-origin即正常;200、重定向或登录页说明代理在捣乱。
- 断开前先核对同步状态:Settings 里的 Disconnect 会先检查服务器是否已拥有手机的全部变更。若还没有,它会告诉你还差几条,并给出Try again / Export backup / Disconnect anyway——最后一项把未同步变更留在手机(
opengym-stash.json),下次与同一服务器、同一账号重新配对时自动补回。
5. 迁移训练历史与数据备份:数据在哪、怎么备
多设备同时编辑:两台设备为什么不会互相覆盖
服务器上每个档案的数据是一份文档 + 一个 rev 修订号。设备保存时把自己最后见过的baseRev一起提交;若期间另一台设备写过,服务器以409拒绝并回传当前文档,设备按字段合并后重试——每条变更(细到单个设置项、单个常规字段)都带自己的时间戳,合并时逐条取最新,删除保持删除(机制见 README.md "How sync works"):
实用推论:手机上记组数的同时用笔记本改计划是安全的——字段级的后编辑者胜,而不是整文档覆盖;离线期间的变更永不丢弃,恢复连接后自动补上,App 会在离线时挂出提示横幅。
从 FitNotes、Strong、Hevy 迁入历史
入口都在Settings → Data & backup。从旧 App 导出 CSV 再导入:动作名与内置库(5600+ 动作)匹配,不认识的自动变成"你自己的动作",文件里没有任何一条会被丢掉;已有记录的日子保持不动,所以同一个文件导入两次也不会重复。以下 App 的导出无需任何编辑即可识别(对照表见 docs/DATA_IMPORTS.md):
| 旧 App | 导出位置 |
|---|---|
| FitNotes(Android) | Settings → Backup/Export → Spreadsheet Export |
| FitNotes 2(iOS) | Export workouts as CSV |
| Strong | Settings → Export Data |
| Hevy | Profile → Settings → Export & Import Data |
| Apple Health(体重) | 健康 App → 头像 → Export All Health Data,导入其中的export.xml |
Hevy Pro 可以跳过 CSV:把 API key 直接粘进Import from Hevy(仅本次导入使用,不保存),常规会作为新计划导入、不覆盖已有内容。自己的 CSV 也行,最小要求是"日期 + 动作名 + 一个测量值":
workout name,exercise,date,weight kg,reps Leg Day,Squat,2026-08-21,120,5注意:历史与计划是分开的两条线——导入训练记录不会创建或改动你的常规。
数据在哪里,怎么备份
服务器侧一切都在宿主机的./data(docker-compose.yml 把它挂进容器作为/data):db.json(档案 + Passkey 公钥)、state-<user>.json(每人的计划/训练/体重/设置)、secret(会话签名密钥)、audit.log(活动日志),以及uploads/(自建动作的照片与视频——上传前手机已完成脱敏:照片重编码、EXIF/GPS 不存活,按 SHA-256 命名)。一条命令完成全量备份:
tar czf opengym-backup-$(date +%F).tar.gz data/
secret是一票否决项:迁栈时丢了或替换了data/secret,所有会话与手机配对立即失效(症状就是手机提示 "Your server no longer accepts this phone")。整包备份data/是唯一保险。 备份要送第三方云时留意两点:audit.log里有所有人的登录时间记录;uploads/占大头,不送出去可以tar czf opengym-backup.tar.gz --exclude=data/uploads data/。
手机侧三个文件值得知道(详见 docs/MOBILE.md):
opengym-state.json——手机上一切数据的持久副本,每次变更即写;opengym-remote.json——配对凭据。为什么单独存:opengym-state.json的内容就是原样PUT给服务器的同步体,设备自己的连接秘密绝不能伪装成训练数据被同步出去;opengym-stash.json——"Disconnect anyway"或换账号配对时留下的变更,等待它们各自的服务器与账号。
6. 排障对照表:自托管与手机配对的常见故障状态,以及自己构建 APK
排障对照表
综合 docs/SELF_HOSTING.md 排障节与 docs/MOBILE.md 连接状态表:
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 手机上没有 Passkey 提示 | 走http://局域网 IP 打开,不是 HTTPS | 上 HTTPS 域名(或用访客模式 /PASSWORD_LOGIN=1) |
| 登录时 "verification failed" | RP_ID/ORIGIN与地址栏不一致 | 先查docker compose logs api \| grep 'gym-api on'看服务器实际加载的值,再up -d重启 |
改了.env没生效 | 容器没有重读环境 | docker compose restart不重载.env,用docker compose up -d |
| 端口 8080 被占用 | 端口冲突 | .env里设WEB_PORT=9090(本地测试时同步改ORIGIN) |
docker compose pull报 denied | 预构建镜像不可用 | 改从源码构建:docker compose up -d --build |
| 照片/视频上传 "refused as too large" | 前置代理的 body 上限或超时太小 | 提高/api/media/的 body 限制与读超时(内置 web 镜像已按 48 MB 放行) |
| 手机:"answered with something other than openGym (HTTP 200)" | 代理登录页或兜底路由替/api/*应答 | 让/api/*原样透传到 API,包括Authorization头 |
| 手机:"Your server no longer accepts this phone" | 服务器回 401:"sign out everywhere"、账号被禁用、secret被换、token 超期、代理自带登录 | 在已登录浏览器重新配对;手机本地数据不丢 |
| 手机配对被拒(CORS)但浏览器正常 | 代理自己应答了 preflight / 多了一个Access-Control-Allow-Origin | 移除代理的 CORS 处理,用第 4 节的 curl preflight 自检 |
自己构建并签名 Android APK
前提:Node 20+、Android Studio(自带 SDK)、Java 21。
cd frontend && npm install npm run build:mobile # VITE_MOBILE=1 构建 + cap sync 进原生工程 cd android && ./gradlew assembleSideloadRelease # 产物:app/build/outputs/apk/sideload/release/app-sideload-release-unsigned.apk # 一次性:创建 keystore。务必保管好——换密钥签名的新版本装不到旧版本之上 keytool -genkeypair -keystore my.keystore -alias opengym -keyalg RSA -validity 10950 # 对齐 + 签名(zipalign / apksigner 随 Android SDK build-tools 附带) zipalign -f -p 4 app-sideload-release-unsigned.apk aligned.apk apksigner sign --ks my.keystore --ks-key-alias opengym --out openGym.apk aligned.apk
build:mobile之后,frontend/dist里装的是手机 bundle:如果你要拿这个dist部署到服务器,先重新跑一次普通的npm run build,否则网站会带上手机版。 Release APK 只带 ARM 原生代码(arm64-v8a、armeabi-v7a),覆盖所有手机;x86 构建只服务模拟器,会多约 12 MB。每次发布必须严格递增versionCode(当前为 27,见 frontend/android/app/build.gradle),否则更新装不到现有 APK 之上。
结语
openGym 把"你的数据,你的服务器"落到了每一层:./data一个 tar 全量可备、Passkey 私钥永不出设备、手机文件镜像扛住 iOS 存储清理、配对凭据与训练数据刻意隔离存储。抓住三条主线——RP_ID/ORIGIN决定你能不能登录,配对码 + 半寿 token 决定手机能不能一直连着,rev/409 合并决定多设备如何不打架——你就能完整地部署、配对、迁移并备份这套应用,后续升级也只需git pull && docker compose pull && docker compose up -d,./data原封不动。
【免费下载链接】openGymOpen-source gym & body-weight tracker, now in the App Store (iPhone + Apple Watch) and as an Android APK. Plan routines, log workouts, see which muscles are trained, import from Strong/Hevy/FitNotes. Self-host it or use it on the phone alone.项目地址: https://gitcode.com/GitHub_Trending/op/openGym
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考