Self-host openGym, a workout tracker
Deploy openGym on a VPS with Docker Compose: a pinned git tag, TLS before the first passkey, where the data lives, and the read-only MCP server.
What you get when you self-host openGym
You self-host openGym by cloning the repository, editing two lines in .env, and running docker compose up -d --build behind a reverse proxy that terminates TLS (transport layer security). openGym is a gym and body weight tracker: weekly plans, guided workouts, every set logged, weight over time. It is licensed AGPL-3.0 and it stores everything in plain JSON files on your disk, so there is no database server to run.
The stack is two long-running containers, an nginx container that serves the React build and a Node container that holds the API, plus a one-shot job that downloads about 140 MB of exercise images and GIFs the first time you start it.
Two things the project's README implies but does not spell out for someone deploying on a public server. Passkey login is bound to a hostname, so the domain and its certificate have to exist before the first login, not after. And the optional MCP server is read-only and runs on the machine where your AI client runs, not inside the stack, which changes what you have to do when the data sits on a VPS.
openGym is young. The first tagged release, v1.0.0, is dated 20 July 2026, and v1.2.7 landed on 18 August 2026. Thirteen tags in about a month means the app is still moving, so check out a release tag rather than building whatever happens to be on the default branch.
Plan the domain before the first login
Passkeys are how you sign in to openGym. A passkey is bound to a relying party ID (RP ID), which is the domain the credential was created on, and browsers only create passkeys over HTTPS. The one exception is localhost.
This has a consequence people meet on their phone. Open http://203.0.113.10:8080 from another device and no passkey prompt appears at all, because the browser refuses to create a credential on a plain HTTP origin or a bare IP address. The project's own troubleshooting notes say the same thing: no prompt means you are on http:// or on an IP.
Worse, the RP ID is baked into every credential your users have already registered. Change RP_ID later and the passkeys stored on their devices no longer match, so nobody can sign in. Decide the hostname first, point DNS at the VPS, and get the certificate working before anyone taps Create profile.
Deploy openGym with Docker Compose
The compose file bind-mounts ./data and ./media relative to itself, so the directory you clone into is your database. Put it somewhere durable.
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 prints a github.com clone URL. That address no longer resolves, and the Gitea repository above is the live home of the project.
Edit .env. Three lines matter on a VPS.
RP_ID=gym.example.com
ORIGIN=https://gym.example.com
WEB_PORT=127.0.0.1:8080RP_ID is the bare hostname and ORIGIN is the full URL including the scheme. They must match the address bar exactly, or login fails with verification failed. The WEB_PORT value is explained in the section on keeping port 8080 private.
docker compose up -d --build
docker compose ps
docker compose logs mediadocker compose ps should show web and api as running, and media as exited with code 0. That exit is correct: the media job has restart: "no" because its work is a one-time download. Its log ends with a line starting ✓ Exercise media ready, and ls media/img | wc -l should print a few hundred rather than 0. An empty directory means the download failed, and the app then renders exercise cards with blank images.
The --build flag is not optional here. The compose file names prebuilt images on ghcr.io that are no longer published, so docker compose pull fails with denied or manifest unknown, and the two services are built from the source you just cloned instead. Both of them carry a build section for exactly that. If Compose itself is new to you, start with Docker Compose on a VPS and come back.
Pin the version, because this project is young
Because that registry namespace has gone away, there is no image tag left to pin. What you pin instead is the checkout on disk, since it decides which version of the application ends up in the container.
cd /opt/opengym
git fetch --tags
git checkout v1.2.7git status now reports a detached HEAD at that tag, which is what you want on a server. Nothing moves under you until you check out a different one.
Then tell Compose to stop reaching for the registry at all. Put this in docker-compose.override.yml, which Compose loads automatically and merges on top of the tracked file. Scalar keys are replaced by the override, so nothing in git needs editing and git pull stays clean. See how Compose merges an override file for the full merge rules.
services:
api:
pull_policy: build
web:
pull_policy: buildWith that in place, a later docker compose up -d builds from the source you have rather than failing on a pull. Check the merge took effect, then rebuild at the tag.
docker compose config | grep pull_policy
docker compose up -d --buildTerminate TLS with a reverse proxy
The containers speak plain HTTP. Something in front has to hold the certificate. Caddy is the shortest path, because it requests and renews the certificate from Let's Encrypt on its own.
gym.example.com {
reverse_proxy 127.0.0.1:8080
}nginx, Traefik and Nginx Proxy Manager all work the same way. So does a Cloudflare Tunnel, which the project documents and which needs no inbound port open at all.
curl -sI https://gym.example.com | head -1That should return HTTP/2 200 with no certificate warning. Now open the site in a browser and tap Create profile. If the passkey prompt appears and then login reports verification failed, RP_ID or ORIGIN does not match the URL in the address bar. Fix .env and run docker compose up -d again, which recreates the containers so they read the new values. A docker compose restart does not reload .env.
Keep port 8080 off the public internet
By default the web service publishes 8080 on every interface, so the app is reachable on plain HTTP at your public IP while the proxy serves HTTPS on the same box. A firewall rule does not fix this. Docker publishes a port with a DNAT rule in the nat table, and that traffic is then handled in the FORWARD chain where Docker's own rules accept it, while ufw's rules sit on the INPUT path. sudo ufw deny 8080/tcp therefore blocks nothing.
The fix is to publish on the loopback address only. The compose file maps "${WEB_PORT:-8080}:${NGINX_PORT:-80}", so whatever you set in WEB_PORT is substituted on the left of that mapping, and Docker's short syntax accepts an ip:port pair there. That is why WEB_PORT=127.0.0.1:8080 works.
docker compose config
sudo ss -ltnp | grep 8080In the merged config, under the web service's ports, you want to see host_ip: 127.0.0.1. ss should show 127.0.0.1:8080 and not 0.0.0.0:8080. From another machine, curl http://<your-vps-ip>:8080 should now be refused or time out, while the HTTPS hostname keeps working.
Close signup once your profile exists
Signup is open by default, and guest mode is on. On a public hostname that means anyone who finds the URL can create a profile on your server. Register your own profile first, then find your user ID: ls data/ lists a file named state-<uid>.json for each user, and that <uid> is the value you need.
ADMIN_UIDS=<your-uid>
INVITE_ONLY=1
ALLOW_GUEST=0Run docker compose up -d again. Settings now shows an Admin dashboard where you generate and revoke invite codes, so the people you train with can register and nobody else can. openGym knows nothing about external identity providers, so those invite codes govern this one app and nothing else on the box; if you would rather hand out a single account per person across everything you run, putting Authentik in front as a forward auth proxy gates the hostname before openGym's own passkey login ever loads.
Where the data lives, and the backup that protects it
Everything is in the ./data directory, mounted into the API container at /data. There are four kinds of file: db.json holds profiles and public passkey credentials, state-<uid>.json holds one user's routines, workouts and body weight, secret is the session cookie key, and vapid.json holds the push notification keys generated on 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 copies files while the API may be writing one, and a half-copied JSON file restores as a broken JSON file. The stop and start take about two seconds. Then copy the archive off the server, because an archive sitting on the VPS does not survive the VPS. Leave media/ out of the backup: it is 140 MB of exercise images that the media job downloads again for free.
Restoring means untarring into the same path on a host serving the same domain. A passkey stored on your phone is scoped to the RP ID it was created on, so a restore onto a new hostname gives you a working database that nobody can sign in to. Keep the domain, or plan on re-registering every passkey. The same discipline applies to everything else you run, and backing up and upgrading a Docker Compose stack covers the general routine.
The MCP server is read-only, and it runs on your machine
MCP (model context protocol) is how a client such as Claude Desktop or Cursor talks to a local tool server. openGym ships one in mcp/. It is not part of the compose file, it is not a container, and it listens on no port. The client starts it as a child process and talks to it over stdio, which is why the README says it never leaves your machine.
Install it where the client runs, not on the server:
cd openGym/mcp
npm installThen add it 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 is optional on a single-user install, where the server detects the only profile it finds. It exposes eight tools: list_routines, get_routine, get_week_plan, list_workouts, get_workout, get_bodyweight, estimate_1rm and muscle_balance. Every one of them reads. None of them writes, so an assistant can answer what you benched last week and cannot log a set, edit a routine, or delete anything.
Here is the part a VPS reader has to solve. OPENGYM_DATA is a filesystem path, and your data is on the VPS while your AI client is on your laptop. Two options that are honest about that.
- Copy the data down and point the server at the copy:
rsync -a --delete user@gym.example.com:/opt/opengym/data/ ~/opengym-data/, then setOPENGYM_DATAto~/opengym-data. The server only reads, so a copy loses nothing. Re-run the rsync when you want fresh numbers. - Run the 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 needs Node installed on the VPS, and a login that prints nothing on stdout, because stdout is the protocol channel.
If cat data/db.json returns Permission denied, the API container wrote those files as root and your login cannot read them. Copy them with sudo, or change the ownership on the host. For servers that are meant to listen over the network instead of over stdio, see running MCP servers on a VPS.
openGym or wger: which should you run?
wger is the established option in this niche, and it is a much larger piece of software. Its compose stack runs gunicorn serving a Django application, PostgreSQL, Redis, and a Celery worker behind nginx. In exchange you get nutrition and ingredient tracking, a documented REST API, a large community exercise database, and features for trainers managing other people's plans.
openGym is two containers, a folder of JSON files, and no accounts to administer beyond passkeys. That is the whole difference.
Run wger if you want to track food alongside training, or if you need an API to build against. Run openGym if you want a stack small enough to read end to end in an afternoon, and a login with no password to leak. The cost of that choice is maturity: as of 19 August 2026 openGym's first release is a month old, while wger has years of releases behind it. Pin your version, keep the backups, and read the release notes before each update.
If you are still deciding what deserves space on the box, what is worth self-hosting in 2026 covers the trade-offs, and this app sits comfortably next to Mealie for recipes or Actual Budget for money on the same small VPS.
Updating without losing anything
cd /opt/opengym
docker compose stop api
tar czf ~/opengym-$(date +%F).tar.gz data/
docker compose start api
git fetch --tagsCheck out the release you want with git checkout v<new>, then run docker compose up -d --build so the containers are rebuilt from that tag. The backup comes first every time, because the restore path for JSON files on disk is one tar command and takes seconds.
FAQ
Why does openGym never show a passkey prompt on my phone?
The browser is refusing to create a credential because you are on http:// or on a bare IP address, such as http://192.168.1.20:8080. Browsers only allow passkeys on HTTPS origins, with localhost as the single exception. Put openGym behind a reverse proxy holding a real certificate for a real hostname, set RP_ID=gym.example.com and ORIGIN=https://gym.example.com in .env, and run docker compose up -d so the containers pick up the new values. If the prompt appears but login reports verification failed, those two values do not match the URL in the address bar exactly.
Where does openGym store my data, and how do I back it up?
In the ./data directory next to the compose file, mounted into the API container as /data. It holds db.json for profiles and public passkey credentials, one state-<uid>.json per user for workouts and body weight, secret for the session cookie key, and vapid.json for push notification keys. Back it up with docker compose stop api, then tar czf ~/opengym-$(date +%F).tar.gz data/, then docker compose start api, and copy the archive off the server. Skip media/, which is 140 MB of exercise images the media job downloads again on its own.
Can Claude read my openGym workout history?
Yes, through the optional MCP server in the mcp/ directory, and only for reading. It exposes eight tools covering routines, week plans, logged workouts, body weight, estimated one-rep max and muscle balance, and none of them write back. It is not a container and opens no port: your client starts it over stdio and it reads the JSON files at OPENGYM_DATA directly. Because that is a filesystem path, running openGym on a VPS means either syncing a copy of data/ down to the machine running the client, or invoking the server through ssh from the client config.
Should I self-host openGym or wger?
Choose wger if you want food and nutrition tracking beside your training log, or a documented REST API to build on. It runs a larger stack: Django under gunicorn, PostgreSQL, Redis and a Celery worker behind nginx. Choose openGym if you want two containers, JSON files you can read with cat, and passkey login with no password to manage. As of 19 August 2026, openGym's first tagged release is one month old, so check out a git tag and back up data/ before every update.