SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

How to self-host openGym with Docker Compose

Deploy openGym for VPS with Docker Compose, pin v1.0.0, set TLS before passkey login, find plain JSON data, and run the read-only MCP server.

Wetin you go get when you self-host openGym

You go self-host openGym by cloning the repository, editing two lines for .env, then 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 dey log, 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: nginx container wey dey serve the React build, and Node container wey dey hold the API. E also get one-shot job wey dey download about 140 MB exercise images and GIFs the first time you start am.

The project's 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 for am must exist 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 change wetin you need do when the data dey for VPS.

openGym still young. The first tagged release, v1.0.0, get date 20 July 2026, and v1.2.7 land on 18 August 2026. E get 13 tags for the first month, and 34 by 9 October 2026 when dem tag v1.4.0 that day. This mean say the app still dey change, so check out release tag instead of building anything wey happen to dey for 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), wey be the domain where dem create the credential. Browser dey only create passkeys over HTTPS. The only exception na localhost.

This one get consequence 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, because browser no go create credential for plain HTTP origin or bare IP address. The project own troubleshooting notes talk the same thing: if prompt no show, e mean say you dey on http:// or you dey use IP.

The matter worse because RP ID dey stored inside 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, so nobody go fit sign in. Decide the hostname first, point DNS to the VPS, and 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 into na 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
git checkout v1.2.7
cp .env.example .env

The README dey print a github.com/DuarteSantos8/openGym clone URL. As of 9 October 2026, that repository don resolve again and e describe itself as the project home. But the Gitea repository above still dey updated and e get every tag, so either one go work. Check out the tag before the first build: the default branch don pass the version wey this guide dey describe. For that branch, the exercise animations dey inside the image and the media job below no longer dey exist. The section on pinning explains why.

Edit .env. Three lines matter for VPS.

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

RP_ID na the bare hostname, and ORIGIN na the full URL including the scheme. Dem must match the address bar exactly, or login go fail with verification failed. The WEB_PORT value dey explained for the section on keeping port 8080 private.

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

docker compose ps --all suppose show web and api as running, and media as exited with code 0. Keep the --all flag: without am, Compose go list only running containers, so the completed media job no go show at all. That exit dey correct: the media job don restart: "no" because na one-time download e dey do. Its log dey end with line wey start with ✓ Exercise media ready, and ls media/img | wc -l suppose print 1,324 instead of 0. Na the number of thumbnails wey the upstream exercise dataset ships as of 9 October 2026. Empty directory mean say the download fail, and the app go then render exercise cards with blank images.

Keep the --build flag. The compose file for this tag names prebuilt images on ghcr.io with the latest tag. Those images don publish again as of 9 October 2026, but latest follows the newest release. So docker compose pull go run newer code than the source wey you just checked out. Both services get a build section, and --build builds dem from your checkout instead. If Compose still new to you, start with Docker Compose for VPS and come back.

Pin the version, because this project still young

Registry dey carry versioned images again, including a 1.2.7 tag as of 9 October 2026. But the compose file for v1.2.7 still dey ask for latest, wey point to 1.3.10 that day. The thing wey you pin na the checkout for disk, because na e decide which application version go enter the container.

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

git status now report detached HEAD for that tag, and na so you want am for server. Nothing go change under you until you check out another one.

v1.2.7 na the release wey this guide use. E no longer be the newest. Later releases change the deployment itself. For the default branch, the media job don comot. So read the release notes and compare the compose file before you move the pin.

Then tell Compose make e stop to reach registry at all. Put this inside docker-compose.override.yml. Compose dey load the file automatically and merge am on top the tracked file. Override go replace scalar keys, so git no need any edit and git pull go remain clean. See how Compose dey merge override file for the full merge rules.

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

With this setup, later docker compose up -d go build from the source wey you get instead of pulling anything latest point to that day. Check say the merge don apply, then rebuild for the tag.

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

Terminate TLS with reverse proxy

The containers dey speak plain HTTP. Something for front must hold the certificate. Caddy na the shortest path, 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 dey work too. The project document am, and e no need any inbound port to dey open.

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

That 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 reload .env.

Public internet no make port 8080 dey open

By default, web service dey publish 8080 for every interface. So app fit reach plain HTTP for your public IP, while proxy dey serve HTTPS for the same server. Firewall rule no fit fix this. Docker dey publish port with DNAT rule for the nat table. Then traffic dey enter FORWARD chain, where Docker own rules dey accept am, while ufw rules dey follow INPUT path. sudo ufw deny 8080/tcp therefore no dey block anything.

The fix na to publish only for loopback address. The compose file dey map "${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 there. Na why WEB_PORT=127.0.0.1:8080 dey work.

docker compose config
sudo ss -ltnp | grep 8080

For the merged config, under web service ports, you suppose see host_ip: 127.0.0.1. ss suppose show 127.0.0.1:8080, and no be 0.0.0.0:8080. From another machine, curl http://<your-vps-ip>:8080 suppose reject connection or time out now, while the HTTPS hostname still dey work.

Close signup after your profile don register

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=0

Run docker compose up -d again. Settings go now show Admin dashboard where you fit generate and revoke invite codes, so the people wey you dey train with fit register and nobody else fit. openGym no know anything about external identity providers, so those invite codes control only this one app and nothing else for the box; if you prefer give each person one account across everything wey you dey run, put Authentik in front as forward auth proxy go gate the hostname before openGym own passkey login ever load.

Data dey where, and backup wey dey protect am

Everything dey inside the ./data directory, wey mount into the API container for /data. File dey four kinds: db.json get profiles and public passkey credentials, state-<uid>.json get one user routines, workouts and body weight, secret na the session cookie key, and vapid.json get the push notification keys wey generate for first run.

cd /opt/opengym
docker compose stop api
tar czf ~/opengym-$(date +%F).tar.gz data/
docker compose start api

Stop the API first because tar dey copy files while the API fit dey write one, and JSON file wey copy halfway go restore as broken JSON file. The stop and start dey take about two seconds. Then copy the archive comot from the server, because archive wey dey sit for the VPS no go survive if the VPS fail. Leave media/ outside the backup: na 140 MB exercise images wey the media job fit download again free of charge.

To restore, untar am into the same path for host wey dey serve the same domain. Passkey wey store for 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. The same discipline apply to everything else wey you run, and backup and upgrade 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 part of compose file, e no be container, and e no dey listen on any port. Client dey start am as child process and dey talk to am over stdio. Na why README talk say e no ever leave your machine.

Install am where client dey run, no be for server:

cd openGym/mcp
npm install

Then 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 dey optional for single-user install, where server dey detect the only profile wey e find. For current default branch, e expose nine tools: list_routines, get_routine, preview_session, get_week_plan, list_workouts, get_workout, get_bodyweight, estimate_1rm and muscle_balance. preview_session show wetin app go actually put for screen when you start routine, after progression policy and your history don override the plan. v1.2.7 tag ship only the other eight, so server wey run from pinned checkout no get preview_session. All of dem na read-only. 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. That list na short example of one decision wey agent design dey return to again and again: the tools wey you expose na everything wey model fit do. Learning 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 reader need solve one problem. OPENGYM_DATA na filesystem path, and your data dey for VPS while your AI client dey for laptop. Two options wey dey clear about this:

  1. Copy the data come down and point server to the copy: rsync -a --delete user@gym.example.com:/opt/opengym/data/ ~/opengym-data/, then set OPENGYM_DATA to ~/opengym-data. Server only dey read, so copy no go lose anything. Run rsync again whenever you want fresh numbers.
  2. Run server over ssh, with command set to ssh and args set 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 the agent itself dey for your laptop. If you prefer make e run for the same box wey data dey, OneCLI dey give each person sandboxed agent for the server so 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 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 manage apart from passkeys. Na the whole difference be that. If you don ever keep Chatwoot installation running, where backup mean Postgres dump together with uploads directory and every version bump dey run database migrations, you already know the kind maintenance wey wger need.

Run wger if you want track food together with training, or if you need API to build against. Run openGym if you want stack wey small enough to read from beginning to end within one afternoon, plus login wey no get password wey fit leak. The cost of this choice na maturity: as of 19 August 2026, openGym first release don reach one month, while wger get years of releases behind am. Pin your version, keep your 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 explain 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 --tags

Use git checkout v<new> to check out the release wey you want, then run docker compose up -d --build make dem rebuild the containers from that tag. Backup go always happen first, because to restore JSON files from disk na one tar command, and e dey take seconds.

FAQ

Why openGym no dey show passkey prompt for my phone?

Browser dey refuse 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 inside .env, then run docker compose up -d make containers collect the new values. If prompt show but login report verification failed, those two values no match the URL wey dey inside address bar exactly.

Where openGym dey store my data, and how I fit back am up?

E dey inside ./data directory beside compose file, wey mount into API container as /data. E hold db.json for profiles and public passkey credentials, one state-<uid>.json for each user workout 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 na 140 MB of exercise images wey media job dey download again by itself.

Claude fit read my openGym workout history?

Yes, through the optional MCP server inside mcp/ directory, and na reading only. For the current default branch, e expose nine tools (eight for v1.2.7) wey cover routines, preview of wetin the next session go prescribe, week plans, logged workouts, body weight, estimated one-rep max and muscle balance, and none of dem write anything back. E no be container and e no open 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 go either sync copy of data/ down to the machine wey dey run the client, or invoke the 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 without password to manage. As of 19 August 2026, openGym first tagged release na one month old, so check out git tag and back up data/ before every update.