SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-08-21

Hướng dẫn tự host openGym trên VPS bằng Docker Compose

Triển khai openGym trên VPS với Docker Compose. Bài viết hướng dẫn cách cấu hình TLS trước khi thiết lập Passkey, quản lý dữ liệu JSON và cài đặt MCP server ở chế độ read-only.

Những gì bạn nhận được khi tự host openGym

Bạn tự host openGym bằng cách clone repository, sửa hai dòng trong .env, và chạy docker compose up -d --build phía sau một reverse proxy thực hiện TLS (transport layer security) termination. openGym là một ứng dụng theo dõi tập gym và trọng lượng cơ thể: kế hoạch hàng tuần, hướng dẫn tập luyệ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 trữ mọi dữ liệu dưới dạng file JSON thuần trên ổ đĩa, vì vậy bạn không cần chạy database server.

Stack này gồm hai container chạy thường trực: một container nginx phục vụ bản build React và một container Node chứa API, cộng với một job chạy một lần để tải khoảng 140 MB hình ảnh và GIF bài tập trong lần khởi động đầu tiên.

Có hai điều mà README của dự án ngụ ý nhưng không giải thích rõ cho người triển khai trên server công cộng. Đăng nhập bằng Passkey bị ràng buộc với một hostname, vì vậy domain và chứng chỉ của nó phải tồn tại trước lần đăng nhập đầu tiên, chứ không phải sau đó. Và MCP server tùy chọn là loại chỉ đọc (read-only) và chạy trên máy tính nơi client AI của bạn hoạt động, không phải bên trong stack, điều này làm thay đổi những gì bạn phải làm khi dữ liệu nằm trên một VPS.

openGym còn khá mới. Bản release có gắn tag đầu tiên, v1.0.0, có ngày 20 tháng 7 năm 2026, và v1.2.7 ra mắt vào ngày 18 tháng 8 năm 2026. Mười ba tag trong khoảng một tháng nghĩa là ứng dụng vẫn đang thay đổi liên tục, vì vậy hãy checkout một release tag thay vì build bất cứ thứ gì đang nằm trên nhánh mặc định.

Lên kế hoạch cho domain trước lần đăng nhập đầu tiên

Passkey là cách bạn đăng nhập vào openGym. Một passkey được gắn với một relying party ID (RP ID), chính là 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 dẫn đến một hệ quả mà người dùng thường gặp trên điện thoại. Mở http://203.0.113.10:8080 từ một thiết bị khác và không thấy prompt passkey nào xuất hiện, vì trình duyệt từ chối tạo credential trên origin HTTP thuần hoặc địa chỉ IP trần. Các ghi chú khắc phục sự cố của dự án cũng nêu rõ điều này: không có prompt nghĩa là bạn đang ở trên http:// hoặc đang dùng IP.

Tệ hơn nữa, RP ID được gắn cứng vào mọi credential mà người dùng đã đăng ký. Thay đổi RP_ID sau này sẽ khiến các passkey lưu trên thiết bị của họ không còn khớp nữa, dẫn đến việc không ai có thể đăng nhập. Hãy quyết định hostname trước, trỏ DNS về VPS, và thiết lập chứng chỉ hoạt động ổn định trước khi bất kỳ ai nhấn Create profile.

Triển khai openGym bằng Docker Compose

File compose thực hiện bind-mount ./data./media theo đường dẫn tương đối, vì vậy thư mục bạn clone về chính là cơ sở dữ liệu của bạn. Hãy đặt nó ở một vị trí 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 .env

README vẫn hiển thị URL clone github.com. Địa chỉ đó hiện không còn phân giải được, kho lưu trữ Gitea ở trên mới là nơi lưu trữ chính thức của dự án.

Chỉnh sửa .env. Có ba dòng quan trọng trên một VPS.

RP_ID=gym.example.com
ORIGIN=https://gym.example.com
WEB_PORT=127.0.0.1:8080

RP_ID là hostname thuần và ORIGIN là URL đầy đủ bao gồm cả scheme. Chúng phải khớp chính xác với địa chỉ trên thanh trình duyệt, nếu không quá trình đăng nhập sẽ thất bại với lỗi verification failed. Giá trị WEB_PORT được giải thích trong phần giữ cổng 8080 ở chế độ riêng tư.

docker compose up -d --build
docker compose ps
docker compose logs media

docker compose ps sẽ hiển thị webapi đang chạy, và media đã thoát với mã 0. Trạng thái thoát này là bình thường: tác vụ media đã restart: "no" vì công việc của nó là tải xuống một lần duy nhất. Log của nó kết thúc bằng một dòng bắt đầu với ✓ Exercise media ready, và ls media/img | wc -l sẽ in ra vài trăm thay vì 0. Một thư mục trống nghĩa là quá trình tải xuống đã thất bại, và ứng dụng sau đó sẽ hiển thị các thẻ bài tập với hình ảnh trống.

Flag --build là bắt buộc trong trường hợp này. File compose chỉ định các image được build sẵn trên ghcr.io vốn không còn được phát hành nữa, vì vậy docker compose pull sẽ thất bại với denied hoặc manifest unknown, và hai dịch vụ này được build từ mã nguồn mà bạn vừa clone. Cả hai đều chứa phần build để thực hiện việc này. Nếu bạn mới làm quen với Compose, hãy bắt đầu với Docker Compose trên VPS rồi quay lại đây.

Ghim phiên bản vì dự án này còn mới

Vì namespace trên registry đã bị xóa, không còn image tag nào để ghim. Thay vào đó, bạn hãy ghim checkout trên đĩa, vì nó quyết định phiên bản ứng dụng nào sẽ được đưa vào container.

cd /opt/opengym
git fetch --tags
git checkout v1.2.7

git status hiện báo trạng thái detached HEAD tại tag đó, đây chính là trạng thái bạn cần trên server. Không có gì thay đổi cho đến khi bạn checkout một tag khác.

Sau đó, hãy yêu cầu Compose ngừng truy cập vào registry. Đưa cấu hình này vào docker-compose.override.yml, file mà Compose tự động tải và gộp đè lên file được track. Các scalar key sẽ được thay thế bởi file override, vì vậy không cần chỉnh sửa gì trong git và git pull vẫn giữ được sự sạch sẽ. Xem cách Compose gộp file override để biết đầy đủ các quy tắc gộp.

services:
  api:
    pull_policy: build
  web:
    pull_policy: build

Với cấu hình này, lệnh docker compose up -d sau đó sẽ build từ source bạn đang có thay vì báo lỗi khi pull. Hãy kiểm tra xem việc gộp cấu hình đã có hiệu lực chưa, sau đó rebuild tại tag đó.

docker compose config | grep pull_policy
docker compose up -d --build

Kết thúc TLS bằng reverse proxy

Các container chỉ giao tiếp bằng HTTP thuần. Cần một thành phần đứng trước để quản lý chứng chỉ. Caddy là giải pháp nhanh nhất vì nó tự động yêu cầu và gia hạn chứng chỉ 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ách tương tự. Cloudflare Tunnel cũng vậy, dự án đã có tài liệu hướng dẫn và bạn không cần mở bất kỳ cổng inbound nào.

curl -sI https://gym.example.com | head -1

Lệnh đó sẽ trả về HTTP/2 200 mà không có cảnh báo chứng chỉ. Bây giờ hãy mở trang web trên trình duyệt và nhấn Create profile. Nếu prompt passkey xuất hiện và sau đó quá trình đăng nhập báo lỗi verification failed, RP_ID hoặc ORIGIN không khớp với URL trên thanh địa chỉ. Hãy sửa .env và chạy lại docker compose up -d, lệnh này sẽ tạo lại các container để chúng đọc các giá trị mới. Lệnh docker compose restart không tải lại .env.

Giữ cổng 8080 khỏi public Internet

Theo mặc định, web service publish 8080 trên mọi interface, vì vậy ứng dụng có thể truy cập qua HTTP thường tại IP công cộng của bạn trong khi proxy phục vụ HTTPS trên cùng một máy chủ. Một rule firewall không giải quyết được vấn đề này. Docker publish một cổng bằng rule DNAT trong bảng nat, và lưu lượng đó sau đó được xử lý trong chain FORWARD nơi các rule của chính Docker chấp nhận nó, trong khi các rule của ufw nằm trên đường dẫn INPUT. Do đó, sudo ufw deny 8080/tcp không chặn được gì cả.

Cách khắc phục là chỉ publish trên địa chỉ loopback. File compose map "${WEB_PORT:-8080}:${NGINX_PORT:-80}", vì vậy bất cứ thứ gì bạn đặt trong WEB_PORT sẽ được thay thế ở bên trái của mapping đó, và cú pháp ngắn gọn của Docker chấp nhận một cặp ip:port ở đó. Đó là lý do tại sao WEB_PORT=127.0.0.1:8080 hoạt động.

docker compose config
sudo ss -ltnp | grep 8080

Trong cấu hình đã merge, bên dưới ports của web service, bạn cần thấy host_ip: 127.0.0.1. ss sẽ hiển thị 127.0.0.1:8080 chứ không phải 0.0.0.0:8080. Từ một máy khác, curl http://<your-vps-ip>:8080 bây giờ sẽ bị từ chối hoặc timeout, trong khi hostname HTTPS vẫn hoạt động bình thường.

Đóng đăng ký sau khi đã tạo profile

Tính năng đăng ký mặc định được mở và chế độ khách (guest mode) đang bật. Trên một hostname công khai, điều này 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. Hãy đăng ký profile của riêng bạn trước, sau đó tìm user ID: ls data/ liệt kê một file tên là state-<uid>.json cho mỗi người dùng, và <uid> đó chính là giá trị bạn cần.

ADMIN_UIDS=<your-uid>
INVITE_ONLY=1
ALLOW_GUEST=0

Chạy lại docker compose up -d. Phần Settings giờ đây hiển thị bảng điều khiển Admin, nơi bạn tạo và thu hồi mã mời, để những người bạn huấn luyện cùng có thể đăng ký và không ai khác làm được điều đó. openGym không biết về các nhà cung cấp danh tính bên ngoài, vì vậy các mã mời đó chỉ quản lý ứng dụng này và không ảnh hưởng đến bất kỳ thứ gì khác trên máy chủ; nếu bạn muốn cấp một tài khoản duy nhất cho mỗi người trên tất cả các dịch vụ bạn chạy, đặt Authentik làm forward auth proxy sẽ chặn hostname trước khi màn hình đăng nhập passkey của openGym kịp tải.

Nơi lưu trữ dữ liệu và bản sao lưu bảo vệ dữ liệu đó

Mọi thứ nằm trong thư mục ./data, được mount vào container API tại /data. Có bốn loại tệp: db.json chứa hồ sơ và thông tin xác thực passkey công khai, state-<uid>.json chứa các thói quen, bài tập và cân nặng của một người dùng, secret là khóa cookie phiên, và vapid.json chứa các khóa thông báo đẩy đượ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 api

Hãy dừng API trước vì tar sẽ sao chép các tệp trong khi API có thể đang ghi vào một tệp, và một tệp JSON bị sao chép dở dang sẽ dẫn đến lỗi JSON khi khôi phục. Việc dừng và khởi động chỉ mất khoảng hai giây. Sau đó, hãy sao chép bản lưu trữ ra khỏi máy chủ, vì bản lưu trữ nằm trên VPS sẽ không tồn tại nếu VPS gặp sự cố. Hãy loại bỏ media/ khỏi bản sao lưu: đây là 140 MB hình ảnh bài tập mà tác vụ media có thể tải xuống lại miễn phí.

Khôi phục nghĩa là giải nén vào cùng đường dẫn trên một máy chủ đang phục vụ cùng tên miền. Một passkey được lưu trên điện thoại của bạn bị giới hạn trong RP ID mà nó được tạo ra, vì vậy việc khôi phục sang một hostname mới sẽ cho bạn một cơ sở dữ liệu hoạt động nhưng không ai có thể đăng nhập được. Hãy giữ nguyên tên miền, hoặc lên kế hoạch đăng ký lại mọi passkey. Kỷ luật tương tự áp dụng cho mọi thứ khác mà bạn chạy, và sao lưu và nâng cấp một Docker Compose stack bao gồm quy trình chung cho việc này.

MCP server ở chế độ chỉ đọ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 local tool server. openGym cung cấp một server trong mcp/. Nó không nằm trong file compose, không phải là container và không lắng nghe trên bất kỳ cổng nào. Client khởi chạy nó như một tiến trình con và giao tiếp qua stdio, đó là lý do README ghi rằng nó không bao giờ rời khỏi máy của bạn.

Cài đặt nó tại nơi client chạy, không phải trên server:

cd openGym/mcp
npm install

Sau đó 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 đối với cài đặt đơn người dùng, nơi server tự phát hiện profile duy nhất mà nó tìm thấy. Nó cung cấp tám công cụ: list_routines, get_routine, get_week_plan, list_workouts, get_workout, get_bodyweight, estimate_1rmmuscle_balance. Tất cả đều chỉ đọc. Không công cụ nào có quyền ghi, vì vậy trợ lý có thể trả lời bạn đã tập gì vào tuần trước nhưng không thể ghi lại một set, chỉnh sửa lịch tập hay xóa bất cứ thứ gì.

Đây là phần mà người dùng VPS cần giải quyết. OPENGYM_DATA là một đường dẫn filesystem, và dữ liệu của bạn nằm trên VPS trong khi AI client nằm trên laptop. Có hai phương án khả thi cho việc này.

  1. Copy dữ liệu về máy và trỏ server vào bản copy đó: rsync -a --delete user@gym.example.com:/opt/opengym/data/ ~/opengym-data/, sau đó đặt OPENGYM_DATA thành ~/opengym-data. Server chỉ đọc nên việc copy không làm mất dữ liệu. Chạy lại rsync khi bạn muốn cập nhật số liệu mới.
  2. Chạy server qua ssh, với command được đặt thành sshargs được đặt thà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 cài đặt Node trên VPS, và một tài khoản đăng nhập không in bất kỳ thông tin nào ra stdout, vì stdout là kênh giao tiếp của giao thức.

Nếu cat data/db.json trả về Permission denied, container API đã ghi các file đó dưới quyền root và tài khoản của bạn không thể đọc chúng. Hãy copy chúng bằng sudo, hoặc thay đổi quyền sở hữu trên host. Đối với các server cần lắng nghe qua mạng thay vì qua stdio, hãy xem chạy MCP server trên VPS.

openGym hay wger: bạn nên chạy cái nào?

wger là lựa chọn đã có tên tuổi trong phân khúc này và là một phần mềm quy mô lớn hơn nhiều. Stack Docker Compose của nó chạy gunicorn để phục vụ ứng dụng Django, PostgreSQL, Redis và một Celery worker nằm sau nginx. Đổi lại, bạn có tính năng theo dõi dinh dưỡng và thành phần thực phẩm, một REST API có tài liệu đầy đủ, cơ sở dữ liệu bài tập cộng đồng lớn và các tính năng dành cho huấn luyện viên 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ộ sự khác biệt.

Hãy chạy wger nếu bạn muốn theo dõi thực phẩm song song với việc tập luyện, hoặc nếu bạn cần một API để phát triển ứng dụng dựa trên đó. Hãy chạy openGym nếu bạn muốn một stack đủ nhỏ để đọc hiểu toàn bộ trong một buổi chiều và một cơ chế đăng nhập không có mật khẩu để lộ. Cái giá của lựa chọn đó là sự trưởng thành: tính đến ngày 19 tháng 8 năm 2026, bản release đầu tiên của openGym mới chỉ được một tháng tuổi, trong khi wger đã có nhiều năm phát triển với hàng loạt bản release. Hãy ghim phiên bản (pin version), duy trì các bản backup và đọc kỹ release notes trước mỗi lần cập nhật.

Nếu bạn vẫn đang quyết định xem cái gì xứng đáng chiếm dung lượng trên máy chủ, những gì đáng để tự host trong năm 2026 sẽ bao quát các đánh đổi, và ứng dụng này hoàn toàn có thể chạy ổn định bên cạnh Mealie cho công thức nấu ăn hoặc Actual Budget cho quản lý tài chính 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 --tags

Chuyển sang bản release bạn muốn bằng git checkout v<new>, sau đó chạy docker compose up -d --build để các container được build lại từ tag đó. Luôn thực hiện backup trước tiên, vì quy trình khôi phục các file JSON trên ổ cứng chỉ cần một lệnh tar và mất vài giây.

FAQ

Tại sao openGym không bao giờ hiển thị prompt nhập 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 sử dụng http:// hoặc địa chỉ IP thuần, ví dụ như http://192.168.1.20:8080. Trình duyệt chỉ cho phép dùng passkey trên các origin HTTPS, ngoại trừ localhost là trường hợp duy nhất. Hãy đặt openGym sau một reverse proxy có chứng chỉ hợp lệ cho một hostname thực, thiết lập RP_ID=gym.example.comORIGIN=https://gym.example.com trong .env, sau đó chạy docker compose up -d để các container nhận giá trị mới. Nếu prompt xuất hiện nhưng quá trình đăng nhập báo lỗi verification failed, nghĩa là hai giá trị đó không khớp chính xác với URL trên thanh địa chỉ.

openGym lưu trữ dữ liệu của tôi ở đâu và làm thế nào để sao lưu?

Dữ liệu nằm trong thư mục ./data cạnh file compose, được mount vào container API tại /data. Nó chứa db.json cho hồ sơ và credential passkey công khai, mỗi người dùng có một state-<uid>.json riêng cho lịch sử tập luyện và cân nặng, secret cho khóa session cookie, và vapid.json cho các khóa thông báo đẩy. Hãy sao lưu bằng cách chạy docker compose stop api, sau đó tar czf ~/opengym-$(date +%F).tar.gz data/, rồi docker compose start api, và copy file lưu trữ ra khỏi server. Bạn có thể bỏ qua media/, vì đây là 140 MB hình ảnh bài tập mà media job có thể 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ỉ ở chế độ đọc. Nó cung cấp tám công cụ bao gồm các bài tập, kế hoạch tuần, lịch sử tập luyện, cân nặng, ước tính mức tạ tối đa (one-rep max) và sự cân bằng cơ bắp; không công cụ nào có khả năng ghi dữ liệu. Đây không phải là một container và không mở cổng: client của bạn khởi chạy nó qua stdio và nó đọc trực tiếp các file JSON tại OPENGYM_DATA. Vì đó là đường dẫn filesystem, việc chạy openGym trên VPS đồng nghĩa với việc bạn phải đồng bộ một bản sao của data/ về máy đang chạy client, hoặc gọi server thông qua ssh từ cấu hình client.

Tôi nên tự host openGym hay wger?

Hãy chọn wger nếu bạn muốn theo dõi dinh dưỡng và thực phẩm bên cạnh nhật ký tập luyện, hoặc cần một REST API có tài liệu đầy đủ để phát triển thêm. Nó chạy một stack lớn hơn: Django dưới gunicorn, PostgreSQL, Redis và một Celery worker nằm sau nginx. Hãy chọn openGym nếu bạn muốn một hệ thống chỉ gồm hai container, các file JSON có thể đọc bằng cat, và đăng nhập bằng passkey không cần quản lý mật khẩu. Tính đến ngày 19 tháng 8 năm 2026, bản release có gắn tag đầu tiên của openGym mới chỉ đượ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 cập nhật.