How to self-host openGym with Docker Compose
Deploy openGym for VPS with Docker Compose: use tag v1.0.0, set TLS before your first passkey, store data as JSON, and run the read-only MCP server separately.
Wetin you go get when you self-host openGym
You go self-host openGym by cloning the repository, editing two lines for .env, and running docker compose up -d --build behind reverse proxy wey dey terminate TLS (transport layer security). openGym na gym and body weight tracker: weekly plans, guided workouts, every set logged, and weight over time. AGPL-3.0 license cover am, and e dey store everything for plain JSON files for your disk, so you no need run database server.
The stack na two long-running containers: one nginx container wey dey serve the React build, and one Node container wey dey hold the API. E still get one one-shot job wey dey download about 140 MB exercise images and GIFs the first time you start am.
The project README imply two things but e no explain dem clearly for person wey dey deploy for public server. Passkey login dey tied to hostname, so the domain and certificate must dey ready before the first login, no be after. The optional MCP server na read-only, and e dey run for the machine wey your AI client dey run, no be inside the stack. This one change wetin you need do when the data dey for VPS.
openGym still dey young. The first tagged release, v1.0.0, get date 20 July 2026, and v1.2.7 land on 18 August 2026. Thirteen tags for about one month mean say the app still dey change often, so check out release tag instead of building anything wey dey the default branch.
Plan domain before the first login
Na passkeys you go use sign in to openGym. Passkey dey tied to relying party ID (RP ID). This na the domain wey create the credential. Browsers only create passkeys through HTTPS. The only exception na localhost.
This one dey cause wahala wey people dey see for phone. If you open http://203.0.113.10:8080 from another device, passkey prompt no go show at all. Browser dey refuse to create credential for plain HTTP origin or bare IP address. The project troubleshooting notes talk the same thing: if prompt no show, you dey use http:// or an IP.
The matter worse because RP ID dey built into every credential wey your users don register already. If you change RP_ID later, the passkeys wey dey stored for their devices no go match again. Then nobody go fit sign in. Decide the hostname first. Point DNS to the VPS. Make the certificate work before anybody tap Create profile.
Deploy openGym with Docker Compose
The compose file dey bind-mount ./data and ./media relative to itself, so the directory wey you clone go be your database. Put am for place wey go last.
sudo install -d -o "$USER" -g "$USER" /opt/opengym
git clone https://gitea.com/DuarteSantos/openGym /opt/opengym
cd /opt/opengym
cp .env.example .envThe README still dey show one github.com clone URL. That address no longer dey resolve, and the Gitea repository above na the active home of the project.
Edit .env. Three lines matter for VPS.
RP_ID=gym.example.com
ORIGIN=https://gym.example.com
WEB_PORT=127.0.0.1:8080RP_ID na the bare hostname, while ORIGIN na the full URL together with the scheme. Dem must match wetin dey for address bar exactly, or login go fail with verification failed. The WEB_PORT value dey explained for the section about keeping port 8080 private.
docker compose up -d --build
docker compose ps
docker compose logs mediadocker compose ps suppose show web and api as running, and media as exited with code 0. That exit correct: the media job don restart: "no" because the work na one-time download. Its log dey end with line wey start with ✓ Exercise media ready, and ls media/img | wc -l suppose print few hundred instead of 0. Empty directory mean say download fail, and the app go then render exercise cards with blank images.
The --build flag no be optional here. The compose file names prebuilt images for ghcr.io wey dem no dey publish again, so docker compose pull fails with denied or manifest unknown, and the two services dey build from the source wey you just clone instead. Both of dem get build section exactly for this reason. If Compose still new to you, start with Docker Compose for VPS and come back.
Pin version, because this project still young
Because that registry namespace don disappear, no image tag remain to pin. Wetin you go pin instead na the checkout wey dey disk, because na e decide which application version go finally enter the container.
cd /opt/opengym
git fetch --tags
git checkout v1.2.7git status now dey report detached HEAD for that tag, and na wetin you want for server. Nothing go change under you until you check out another one.
Then tell Compose make e stop to reach the registry at all. Put this for docker-compose.override.yml, wey Compose dey load automatically and merge on top of the tracked file. Override go replace scalar keys, so you no need edit anything for git and git pull remain clean. See how Compose dey merge override file for the complete merge rules.
services:
api:
pull_policy: build
web:
pull_policy: buildWith this setup, later docker compose up -d go build from the source wey you get instead of failing during pull. Check say the merge take effect, then rebuild for the tag.
docker compose config | grep pull_policy
docker compose up -d --buildTerminate TLS with reverse proxy
The containers dey speak plain HTTP. Something for front suppose hold the certificate. Caddy na the shortest way, because e dey request and renew the certificate from Let's Encrypt by itself.
gym.example.com {
reverse_proxy 127.0.0.1:8080
}nginx, Traefik and Nginx Proxy Manager all dey work the same way. Cloudflare Tunnel sef dey work so, and the project documents am. E no need any inbound port to dey open.
curl -sI https://gym.example.com | head -1That one suppose return HTTP/2 200 without certificate warning. Now open the site for browser and tap Create profile. If passkey prompt show and login later report verification failed, RP_ID or ORIGIN no match the URL for address bar. Fix .env and run docker compose up -d again. This one go recreate the containers so dem fit read the new values. A docker compose restart no dey reload .env.
Make port 8080 no dey public internet
By default, web service dey publish 8080 for every interface. So app dey reachable through plain HTTP for your public IP while proxy dey serve HTTPS for the same machine. Firewall rule no dey fix this. Docker dey publish port with DNAT rule for the nat table. Then FORWARD chain dey handle that traffic, where Docker own rules dey accept am. Ufw rules dey for INPUT path. So, sudo ufw deny 8080/tcp no dey block anything.
The fix na to publish only for loopback address. The compose file maps "${WEB_PORT:-8080}:${NGINX_PORT:-80}". So anything wey you set for WEB_PORT go replace the left side of that mapping. Docker short syntax accept an ip:port pair for that place. Na why WEB_PORT=127.0.0.1:8080 dey work.
docker compose config
sudo ss -ltnp | grep 8080For the merged config, under the web service ports, you suppose see host_ip: 127.0.0.1. ss suppose show 127.0.0.1:8080, and e no suppose show 0.0.0.0:8080. From another machine, curl http://<your-vps-ip>:8080 suppose refuse connection or time out now, while the HTTPS hostname still dey work.
Close signup after your profile don finish
Signup dey open by default, and guest mode dey on. For public hostname, anybody wey find the URL fit create profile for your server. Register your own profile first, then find your user ID: ls data/ dey list file wey dem name state-<uid>.json for each user, and that <uid> na the value wey you need.
ADMIN_UIDS=<your-uid>
INVITE_ONLY=1
ALLOW_GUEST=0Run docker compose up -d again. Settings now dey show Admin dashboard wey you fit use generate and revoke invite codes, so people wey you dey train with fit register and nobody else fit. openGym no know anything about external identity providers, so those invite codes dey control only this one app and nothing else for the box; if you prefer give each person one account wey dem go use across everything wey you run, put Authentik in front as forward auth proxy go gate the hostname before openGym own passkey login ever load.
Wia the data dey, and the backup wey dey protect am
Everything dey inside the ./data directory, wey dem mount inside the API container as /data. File get four types: db.json dey keep profiles and public passkey credentials, state-<uid>.json dey keep one user routines, workouts, and body weight, secret na the session cookie key, and vapid.json dey keep the push notification keys wey dem generate for first run.
cd /opt/opengym
docker compose stop api
tar czf ~/opengym-$(date +%F).tar.gz data/
docker compose start apiStop the API first because tar dey copy files while the API fit still dey write one, and JSON file wey dem copy halfway go restore as broken JSON file. The stop and start go take about two seconds. Then copy the archive comot from the server, because archive wey dey sit for VPS no go survive if the VPS spoil. No include media/ for the backup: na 140 MB of exercise images e be, and the media job go download dem again free of charge.
To restore am, untar the files into the same path for host wey dey serve the same domain. Passkey wey dey your phone dey tied to the RP ID wey create am, so if you restore am for new hostname, you go get working database wey nobody fit sign in to. Keep the domain, or plan to register every passkey again. This same discipline apply to everything else wey you run, and backup and upgrade a Docker Compose stack cover the general routine.
MCP server dey read-only, and e dey run for your machine
MCP (model context protocol) na how client like Claude Desktop or Cursor dey talk to local tool server. openGym ships one for mcp/. E no dey inside compose file, e no be container, and e no dey listen on any port. Client start am as child process and talk to am through stdio, na why README talk say e no ever comot your machine.
Install am where client dey run, no be for server:
cd openGym/mcp
npm installThen add am to 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 na optional for single-user install, where server dey detect the only profile wey e find. E expose eight tools: list_routines, get_routine, get_week_plan, list_workouts, get_workout, get_bodyweight, estimate_1rm and muscle_balance. Every one of dem dey read. None of dem dey write, so assistant fit answer wetin you bench last week, but e no fit log set, edit routine, or delete anything. This list na short example of the decision wey agent design dey return to again and again: the tools wey you expose na everything wey model fit do. Learn how agents work by writing the loop yourself na the fastest way to see why read-only tool set na design choice, no be limitation.
Na here VPS user need solve the main issue. OPENGYM_DATA na filesystem path, and your data dey for VPS while your AI client dey for your laptop. These two options dey clear about that.
- Copy the data down and point server to the copy:
rsync -a --delete user@gym.example.com:/opt/opengym/data/ ~/opengym-data/, then setOPENGYM_DATAto~/opengym-data. Server only dey read, so copy no go lose anything. Run rsync again whenever you want fresh numbers. - Run server over ssh, with
commandset tosshandargsset to["-T", "user@gym.example.com", "OPENGYM_DATA=/opt/opengym/data node /opt/opengym/mcp/src/index.js"]. This one need Node installed for VPS, and login wey no print anything for stdout, because stdout na the protocol channel.
Both options assume say agent itself dey for your laptop. If you prefer make e run for the same box wey hold the data, OneCLI gives each person sandboxed agent on the server so the stdio hop go data/ become local one again.
If cat data/db.json return Permission denied, API container write those files as root and your login no fit read dem. Copy dem with sudo, or change the ownership for host. For servers wey suppose listen over network instead of over stdio, see how to run MCP servers on a VPS.
openGym or wger: which one you suppose run?
wger na the established option for this niche, and na much bigger software. Its compose stack dey run gunicorn wey dey serve Django application, PostgreSQL, Redis, and Celery worker behind nginx. In exchange, you get nutrition and ingredient tracking, documented REST API, large community exercise database, and features for trainers wey dey manage other people's plans.
openGym na two containers, one folder of JSON files, and no accounts to administer apart from passkeys. Na the whole difference be that. If you don ever keep Chatwoot install running, where backup mean Postgres dump together with uploads directory and every version bump dey run database migrations, you already know the kind upkeep wey wger need.
Run wger if you want track food together with training, or if you need API to build on. Run openGym if you want stack wey small enough to read from beginning to end within one afternoon, and login wey no get password wey fit leak. The cost of this choice na maturity: as of 19 August 2026, openGym first release don only reach one month, while wger get years of releases behind am. Pin your version, keep the backups, and read release notes before every update.
If you still dey decide wetin deserve space for the box, wetin worth self-hosting for 2026 cover the trade-offs, and this app fit sit well beside Mealie for recipes or Actual Budget for money for the same small VPS.
Update without losing anything
cd /opt/opengym
docker compose stop api
tar czf ~/opengym-$(date +%F).tar.gz data/
docker compose start api
git fetch --tagsUse git checkout v<new> check out the release wey you want, then run docker compose up -d --build make the containers rebuild from that tag. Backup dey come first every time, because restore path for JSON files wey dey disk na one tar command and e dey take seconds.
FAQ
Why openGym no dey show passkey prompt for my phone?
Browser dey refuse to create credential because you dey on http:// or bare IP address, like http://192.168.1.20:8080. Browsers only allow passkeys for HTTPS origins, with localhost as the only exception. Put openGym behind reverse proxy wey get real certificate for real hostname, set RP_ID=gym.example.com and ORIGIN=https://gym.example.com for .env, then run docker compose up -d so containers go pick the new values. If prompt show but login report verification failed, those two values no match the URL for address bar exactly.
Where openGym dey store my data, and how I fit back am up?
E dey for ./data directory beside compose file, wey dem mount inside API container as /data. E hold db.json for profiles and public passkey credentials, one state-<uid>.json for each user for workouts and body weight, secret for session cookie key, and vapid.json for push notification keys. Back am up with docker compose stop api, then tar czf ~/opengym-$(date +%F).tar.gz data/, then docker compose start api, and copy the archive comot from server. Skip media/, wey be 140 MB of exercise images wey media job go download again by itself.
Claude fit read my openGym workout history?
Yes, through the optional MCP server for mcp/ directory, and na read-only access. E expose eight tools wey cover routines, week plans, logged workouts, body weight, estimated one-rep max and muscle balance, and none of dem fit write back. E no be container and e no open any port: your client start am over stdio and e read the JSON files directly for OPENGYM_DATA. Because na filesystem path, if you run openGym for VPS, you need either sync copy of data/ go the machine wey dey run client, or invoke server through ssh from client config.
I suppose self-host openGym or wger?
Choose wger if you want food and nutrition tracking beside your training log, or documented REST API wey you fit build on. E run bigger stack: Django under gunicorn, PostgreSQL, Redis and Celery worker behind nginx. Choose openGym if you want two containers, JSON files wey you fit read with cat, and passkey login wey no require password to manage. As of 19 August 2026, first tagged release for openGym na one month old, so check out git tag and back up data/ before every update.