Self-host openGym trên VPS với Docker Compose
Triển khai openGym trên VPS với Docker Compose: pin git tag, cấu hình TLS trước passkey đầu tiên, xác định nơi lưu dữ liệu và chạy MCP server read-only.
Bạn nhận được gì khi self-host openGym
Bạn self-host openGym bằng cách clone repository, chỉnh sửa 2 dòng trong .env và chạy docker compose up -d --build phía sau một reverse proxy thực hiện TLS termination (transport layer security). openGym là ứng dụng theo dõi việc tập gym và cân nặng: kế hoạch theo tuần, bài tập có hướng dẫn, ghi lại từng set và theo dõi cân nặng theo thời gian. Ứng dụng được cấp phép theo AGPL-3.0 và lưu toàn bộ dữ liệu trong các file JSON dạng văn bản thuần trên disk, nên bạn không cần chạy database server.
Stack này gồm 2 container chạy lâu dài: một container nginx phục vụ bản build React và một container Node chứa API. Ngoài ra còn có một job chạy một lần để tải khoảng 140 MB ảnh và GIF bài tập trong lần khởi động đầu tiên.
README của project ngầm cho biết 2 điểm nhưng không nói rõ cho người triển khai trên public server. Đăng nhập bằng Passkey gắn với hostname, nên domain và certificate phải tồn tại trước lần đăng nhập đầu tiên, không phải sau đó. MCP server tùy chọn chỉ có quyền read-only và chạy trên máy nơi AI client của bạn chạy, không chạy bên trong stack. Điều này làm thay đổi các bước cần thực hiện khi dữ liệu nằm trên VPS.
openGym còn mới. Bản release đầu tiên được gắn tag là v1.0.0, phát hành vào 20 July 2026, còn v1.2.7 được phát hành vào 18 August 2026. 13 tag trong khoảng 1 tháng cho thấy ứng dụng vẫn đang thay đổi. Vì vậy, hãy checkout một release tag thay vì build từ trạng thái hiện có trên default branch.
Lập kế hoạch domain trước lần đăng nhập đầu tiên
Passkey là cách bạn đăng nhập vào openGym. Passkey được gắn với relying party ID (RP ID), tức domain nơi credential được tạo, và trình duyệt chỉ tạo passkey qua HTTPS. Ngoại lệ duy nhất là localhost.
Điều này thường gây nhầm lẫn khi truy cập bằng điện thoại. Mở http://203.0.113.10:8080 từ thiết bị khác nhưng không hề xuất hiện prompt tạo passkey, vì trình duyệt từ chối tạo credential trên origin HTTP thuần hoặc địa chỉ IP đơn lẻ. Ghi chú xử lý sự cố của dự án cũng nêu rõ điều tương tự: không có prompt nghĩa là bạn đang dùng http:// hoặc một địa chỉ IP.
Tệ hơn, RP ID được ghi cố định vào mọi credential mà người dùng đã đăng ký. Nếu sau đó đổi RP_ID, các passkey lưu trên thiết bị của họ sẽ không còn khớp, nên không ai có thể đăng nhập. Hãy quyết định hostname trước, trỏ DNS đến VPS và cấu hình certificate hoạt động trước khi bất kỳ ai nhấn Create profile.
Triển khai openGym bằng Docker Compose
File compose bind-mount ./data và ./media theo đường dẫn tương đối với chính file đó. Vì vậy, thư mục bạn clone vào chính là database của ứng dụng. Hãy đặt thư mục này ở nơi lưu trữ bền vững.
sudo install -d -o "$USER" -g "$USER" /opt/opengym
git clone https://gitea.com/DuarteSantos/openGym /opt/opengym
cd /opt/opengym
cp .env.example .envREADME vẫn hiển thị URL clone github.com. Địa chỉ đó không còn truy cập được. Repository Gitea ở trên mới là nơi lưu trữ hiện tại của project.
Chỉnh sửa .env. Trên VPS, có 3 dòng cần chú ý.
RP_ID=gym.example.com
ORIGIN=https://gym.example.com
WEB_PORT=127.0.0.1:8080RP_ID là hostname thuần, còn ORIGIN là URL đầy đủ có scheme. Hai giá trị này phải khớp chính xác với địa chỉ trên thanh địa chỉ. Nếu không, đăng nhập sẽ fail với verification failed. Giá trị WEB_PORT được giải thích trong phần giữ port 8080 ở chế độ private.
docker compose up -d --build
docker compose ps
docker compose logs mediadocker compose ps phải hiển thị web và api đang chạy, còn media phải ở trạng thái exited với code 0. Trạng thái exit này là đúng: media job đã restart: "no" vì công việc của nó là download một lần. Log của job kết thúc bằng một dòng bắt đầu với ✓ Exercise media ready, và ls media/img | wc -l phải in ra vài trăm thay vì 0. Thư mục rỗng nghĩa là quá trình download đã fail. Khi đó app sẽ render các exercise card nhưng không có ảnh.
Flag --build là bắt buộc trong trường hợp này. File compose tham chiếu các image dựng sẵn trên ghcr.io, nhưng các image đó không còn được publish. Vì vậy, docker compose pull fail với denied hoặc manifest unknown, và hai service sẽ được build từ source bạn vừa clone. Cả hai service đều có section build cho đúng mục đích này. Nếu bạn chưa quen với Compose, hãy bắt đầu từ Docker Compose trên VPS rồi quay lại.
Cố định phiên bản vì project này còn mới
Vì namespace trên registry đó đã biến mất, không còn image tag nào để cố định. Thay vào đó, bạn cố định checkout trên disk, vì nó quyết định phiên bản ứng dụng được đưa vào container.
cd /opt/opengym
git fetch --tags
git checkout v1.2.7git status hiện báo HEAD detached tại tag đó. Đây là trạng thái phù hợp trên server. Mã nguồn sẽ không tự thay đổi cho đến khi bạn checkout một phiên bản khác.
Tiếp theo, yêu cầu Compose không truy cập registry nữa. Đặt nội dung này vào docker-compose.override.yml. Compose tự động tải file này và merge nó lên trên file đang được theo dõi. Các scalar key sẽ được file override thay thế, nên không cần sửa gì trong git và git pull vẫn sạch. Xem cách Compose merge file override để biết đầy đủ quy tắc merge.
services:
api:
pull_policy: build
web:
pull_policy: buildSau đó, lần docker compose up -d tiếp theo sẽ build từ source hiện có thay vì lỗi khi pull. Kiểm tra việc merge đã có hiệu lực, rồi build lại tại tag đó.
docker compose config | grep pull_policy
docker compose up -d --buildKết thúc TLS bằng reverse proxy
Các container giao tiếp bằng HTTP thuần. Phía trước phải chịu trách nhiệm lưu trữ certificate. Caddy là cách ngắn gọn nhất vì nó tự yêu cầu và gia hạn certificate từ Let's Encrypt.
gym.example.com {
reverse_proxy 127.0.0.1:8080
}nginx, Traefik và Nginx Proxy Manager đều hoạt động theo cùng cách này. Cloudflare Tunnel cũng vậy. Project có tài liệu hướng dẫn cho lựa chọn này và không cần mở inbound port nào.
curl -sI https://gym.example.com | head -1Lệnh đó phải trả về HTTP/2 200 mà không có cảnh báo certificate. Bây giờ mở site trong trình duyệt và nhấn Create profile. Nếu prompt passkey xuất hiện nhưng quá trình đăng nhập báo verification failed, thì RP_ID hoặc ORIGIN không khớp với URL trên thanh địa chỉ. Sửa .env rồi chạy lại docker compose up -d. Lệnh này tạo lại các container để chúng đọc các giá trị mới. Một docker compose restart không reload .env.
Giữ cổng 8080 ngoài Internet công cộng
Theo mặc định, web service publish 8080 trên mọi interface. Vì vậy, app có thể truy cập bằng HTTP thuần tại public IP của bạn, trong khi proxy phục vụ HTTPS trên cùng máy. Rule của firewall không khắc phục được việc này. Docker publish một cổng bằng rule DNAT trong table nat. Sau đó, traffic này được xử lý trong chain FORWARD, nơi các rule riêng của Docker chấp nhận traffic. Trong khi đó, các rule của ufw nằm trên đường đi INPUT. Vì vậy, sudo ufw deny 8080/tcp không chặn được gì.
Cách sửa là chỉ publish trên địa chỉ loopback. File compose ánh xạ "${WEB_PORT:-8080}:${NGINX_PORT:-80}", nên giá trị bạn đặt trong WEB_PORT sẽ được thay vào phía bên trái của ánh xạ đó. Cú pháp rút gọn của Docker chấp nhận một cặp ip:port tại vị trí này. Vì vậy, WEB_PORT=127.0.0.1:8080 hoạt động.
docker compose config
sudo ss -ltnp | grep 8080Trong config đã merge, bên dưới ports của web service, bạn cần thấy host_ip: 127.0.0.1. ss phải hiển thị 127.0.0.1:8080, không phải 0.0.0.0:8080. Từ một máy khác, curl http://<your-vps-ip>:8080 lúc này phải bị từ chối kết nối hoặc timeout, trong khi hostname HTTPS vẫn hoạt động.
Đóng đăng ký sau khi tạo profile
Mặc định, signup được mở và guest mode đang bật. Trên một hostname public, điều đó có nghĩa là bất kỳ ai tìm thấy URL đều có thể tạo profile trên server của bạn. Trước tiên, hãy đăng ký profile của bạn, rồi tìm user ID: ls data/ liệt kê một file có tên state-<uid>.json cho mỗi user, và <uid> đó là giá trị bạn cần.
ADMIN_UIDS=<your-uid>
INVITE_ONLY=1
ALLOW_GUEST=0Chạy lại docker compose up -d. Settings hiện có Admin dashboard, nơi bạn tạo và thu hồi invite code để những người cùng tập với bạn có thể đăng ký, còn người khác thì không. openGym không biết gì về external identity provider, nên các invite code này chỉ áp dụng cho app này và không ảnh hưởng đến phần nào khác trên máy; nếu muốn cấp một account duy nhất cho mỗi người trên toàn bộ các dịch vụ bạn chạy, đặt Authentik phía trước làm forward auth proxy sẽ kiểm soát hostname trước khi màn hình đăng nhập bằng passkey của openGym được tải.
Dữ liệu nằm ở đâu và bản backup bảo vệ dữ liệu đó
Mọi thứ nằm trong thư mục ./data, được mount vào API container tại /data. Có 4 loại file: db.json lưu profile và credential passkey công khai, state-<uid>.json lưu routine, workout và cân nặng của một người dùng, secret là session cookie key, còn vapid.json lưu các push notification key được tạo trong lần chạy đầu tiên.
cd /opt/opengym
docker compose stop api
tar czf ~/opengym-$(date +%F).tar.gz data/
docker compose start apiHãy stop API trước vì tar sao chép file trong khi API có thể đang ghi file đó. Một file JSON bị sao chép dở sẽ được restore thành file JSON bị hỏng. Việc stop và start mất khoảng 2 giây. Sau đó copy archive ra khỏi server vì archive nằm trên VPS sẽ không tồn tại nếu VPS gặp sự cố. Không đưa media/ vào backup: thư mục này chứa 140 MB ảnh bài tập, và media job sẽ tải lại miễn phí.
Restore nghĩa là untar vào đúng path trên một host phục vụ cùng domain. Passkey lưu trên điện thoại được gắn với RP ID nơi nó được tạo. Vì vậy, restore sang hostname mới sẽ cho bạn một database hoạt động nhưng không ai có thể đăng nhập. Hãy giữ nguyên domain hoặc chuẩn bị đăng ký lại toàn bộ passkey. Nguyên tắc này cũng áp dụng cho mọi thứ khác bạn vận hành. Backup và nâng cấp Docker Compose stack trình bày quy trình tổng quát.
MCP server chỉ có quyền đọc và chạy trên máy của bạn
MCP (model context protocol) là cách một client như Claude Desktop hoặc Cursor giao tiếp với một tool server cục bộ. openGym cung cấp một MCP server trong mcp/. Nó không nằm trong compose file, không phải là container và không listen trên cổng nào. Client khởi động nó dưới dạng child process rồi giao tiếp qua stdio. Vì vậy README nói rằng nó không bao giờ rời khỏi máy của bạn.
Cài đặt nó ở nơi client chạy, không phải trên server:
cd openGym/mcp
npm installSau đó thêm nó vào claude_desktop_config.json:
{
"mcpServers": {
"opengym": {
"command": "node",
"args": ["/absolute/path/to/openGym/mcp/src/index.js"],
"env": {
"OPENGYM_DATA": "/absolute/path/to/openGym/data",
"OPENGYM_UID": "<your-uid>"
}
}
}
}OPENGYM_UID là tùy chọn khi cài đặt cho một người dùng, vì server sẽ tự phát hiện profile duy nhất mà nó tìm thấy. Nó cung cấp 8 tool: list_routines, get_routine, get_week_plan, list_workouts, get_workout, get_bodyweight, estimate_1rm và muscle_balance. Tất cả đều chỉ đọc. Không tool nào ghi dữ liệu. Vì vậy assistant có thể trả lời tuần trước bạn đã bench gì, nhưng không thể log một set, sửa routine hoặc xóa bất cứ thứ gì. Danh sách này là ví dụ ngắn gọn cho một nguyên tắc mà thiết kế agent luôn quay lại: các tool bạn expose quyết định toàn bộ những gì model có thể làm. Tự viết vòng lặp để hiểu agent hoạt động thế nào là cách nhanh nhất để thấy vì sao một bộ tool chỉ đọc là lựa chọn thiết kế, không phải giới hạn.
Đây là phần mà người dùng VPS phải giải quyết. OPENGYM_DATA là filesystem path, còn dữ liệu nằm trên VPS trong khi AI client chạy trên laptop của bạn. Có 2 lựa chọn phù hợp với thực tế:
- Copy dữ liệu xuống rồi trỏ server vào bản copy:
rsync -a --delete user@gym.example.com:/opt/opengym/data/ ~/opengym-data/, sau đó đặtOPENGYM_DATAthành~/opengym-data. Server chỉ đọc dữ liệu nên việc copy không làm mất gì. Chạy lại rsync khi bạn muốn cập nhật số liệu. - Chạy server qua SSH, đặt
commandthànhsshvàargsthành["-T", "user@gym.example.com", "OPENGYM_DATA=/opt/opengym/data node /opt/opengym/mcp/src/index.js"]. Cách này yêu cầu Node đã được cài trên VPS và tài khoản đăng nhập không in gì ra stdout, vì stdout là kênh protocol.
Cả hai tùy chọn đều giả định agent chạy trên laptop của bạn. Nếu muốn agent chạy trên cùng máy với dữ liệu, OneCLI cung cấp cho mỗi người một agent được sandbox trên server, nên chặng stdio quay lại data/ một lần nữa là kết nối cục bộ.
Nếu cat data/db.json trả về Permission denied, API container đã ghi các file đó với quyền root nên tài khoản đăng nhập của bạn không thể đọc chúng. Hãy copy chúng bằng sudo hoặc thay đổi ownership trên host. Với các server cần listen trên network thay vì qua stdio, xem chạy MCP server trên VPS.
openGym hay wger: nên chạy cái nào?
wger là lựa chọn đã được sử dụng rộng rãi trong nhóm này và có quy mô phần mềm lớn hơn nhiều. Stack compose của wger chạy gunicorn để phục vụ ứng dụng Django, PostgreSQL, Redis và một Celery worker phía sau nginx. Đổi lại, bạn có tính năng theo dõi dinh dưỡng và nguyên liệu, REST API có tài liệu, cơ sở dữ liệu bài tập lớn do cộng đồng xây dựng, cùng các tính năng dành cho trainer quản lý kế hoạch của người khác.
openGym chỉ gồm hai container, một thư mục chứa các file JSON và không có tài khoản nào cần quản trị ngoài passkey. Đó là toàn bộ khác biệt. Nếu bạn từng duy trì một cài đặt Chatwoot, trong đó backup là một bản dump Postgres đi kèm thư mục uploads và mỗi lần nâng version đều chạy database migration, thì bạn đã biết wger yêu cầu công việc bảo trì như thế nào.
Chạy wger nếu bạn muốn theo dõi thực phẩm cùng với quá trình tập luyện hoặc cần một API để xây dựng ứng dụng dựa trên đó. Chạy openGym nếu bạn muốn một stack đủ nhỏ để đọc toàn bộ trong một buổi chiều và có cơ chế đăng nhập không dùng password dễ bị lộ. Đổi lại, lựa chọn này có độ trưởng thành thấp hơn: tính đến 19 August 2026, bản release đầu tiên của openGym mới được một tháng, còn wger đã có nhiều năm phát hành. Hãy pin version, duy trì backup và đọc release notes trước mỗi lần update.
Nếu bạn vẫn đang cân nhắc ứng dụng nào xứng đáng chiếm chỗ trên máy chủ, phần nào đáng tự host trong 2026 phân tích các đánh đổi, và app này có thể chạy cùng Mealie để quản lý công thức nấu ăn hoặc Actual Budget để quản lý tiền bạc trên cùng một VPS nhỏ.
Cập nhật mà không mất dữ liệu
cd /opt/opengym
docker compose stop api
tar czf ~/opengym-$(date +%F).tar.gz data/
docker compose start api
git fetch --tagsCheckout release bạn muốn bằng git checkout v<new>, sau đó chạy docker compose up -d --build để build lại các container từ tag đó. Luôn backup trước, vì khôi phục các file JSON trên disk chỉ cần một lệnh tar và mất vài giây.
FAQ
Vì sao openGym không bao giờ hiển thị prompt passkey trên điện thoại của tôi?
Trình duyệt từ chối tạo credential vì bạn đang dùng http:// hoặc một địa chỉ IP thuần, chẳng hạn http://192.168.1.20:8080. Trình duyệt chỉ cho phép passkey trên các origin HTTPS, ngoại lệ duy nhất là localhost. Đặt openGym sau một reverse proxy có chứng chỉ hợp lệ cho hostname thực, đặt RP_ID=gym.example.com và ORIGIN=https://gym.example.com trong .env, rồi chạy docker compose up -d để các container nhận giá trị mới. Nếu prompt xuất hiện nhưng đăng nhập báo verification failed, hai giá trị đó không khớp chính xác với URL trên thanh địa chỉ.
openGym lưu dữ liệu của tôi ở đâu và tôi sao lưu dữ liệu như thế nào?
Dữ liệu nằm trong thư mục ./data cạnh file compose và được mount vào API container tại /data. Thư mục này chứa db.json cho profile và credential passkey công khai, một state-<uid>.json cho mỗi user để lưu bài tập và cân nặng, secret cho session cookie key, và vapid.json cho push notification key. Sao lưu bằng docker compose stop api, sau đó chạy tar czf ~/opengym-$(date +%F).tar.gz data/, rồi chạy docker compose start api và chép archive ra khỏi server. Bỏ qua media/. Đây là 140 MB ảnh bài tập mà media job sẽ tự tải lại.
Claude có thể đọc lịch sử tập luyện openGym của tôi không?
Có, thông qua MCP server tùy chọn trong thư mục mcp/, và chỉ có quyền đọc. Server cung cấp tám tool để truy vấn routine, kế hoạch trong tuần, bài tập đã ghi nhận, cân nặng, one-rep max ước tính và cân bằng cơ; không tool nào ghi ngược dữ liệu. Đây không phải container và không mở port. Client khởi động server qua stdio, rồi server đọc trực tiếp các file JSON tại OPENGYM_DATA. Vì đây là một filesystem path, khi chạy openGym trên VPS, bạn phải đồng bộ một bản sao của data/ xuống máy chạy client hoặc gọi server thông qua ssh trong cấu hình client.
Tôi nên tự host openGym hay wger?
Chọn wger nếu bạn muốn theo dõi thực phẩm và dinh dưỡng bên cạnh log tập luyện, hoặc muốn có REST API được tài liệu hóa để phát triển thêm. wger chạy một stack lớn hơn: Django trên gunicorn, PostgreSQL, Redis và một Celery worker phía sau nginx. Chọn openGym nếu bạn muốn hai container, các file JSON có thể đọc bằng cat, và đăng nhập bằng passkey mà không cần quản lý password. Tính đến 19 August 2026, bản release đầu tiên có git tag của openGym mới được một tháng. Vì vậy, hãy checkout một git tag và sao lưu data/ trước mỗi lần update.