openGym is a free, AGPL-licensed workout and body-weight tracker that runs on your own machine instead of a company's cloud: you plan routines, log sets, and it syncs between your phone and laptop behind a passkey login. It added 1,433 GitHub stars today, taking it past 4,300, and with Docker already installed the first local run takes about five minutes.

The pitch is aimed squarely at people tired of Strong or Hevy subscriptions. Your history sits in a ./data folder of JSON files you can back up with one tar command, and the app still behaves like a modern one: installable on the home screen, works offline, signs you in with Face ID or a fingerprint. The catch is the passkey part, which needs HTTPS before your phone will cooperate. Most of this guide is about getting that right.

RelatedOpenStock Setup: Self-Host a Free Stock Tracker in 20 Minutes

  • Two containers and a folder: nginx serves the React app and proxies /api to a plain Node API that stores everything as JSON under ./data.
  • Current version: v1.3.9, released 28 September 2026, with prebuilt images for amd64 and arm64 and a 16 MB signed Android APK.
  • Brings your history with it: imports from FitNotes, Strong, Hevy and Apple Health weight exports, and exports everything as one JSON file.
  • No AI unless you ask: the AI coach and the MCP server are opt-in and off in a default install.

The exact steps, start to finish

  1. Check your prerequisites. You need Git and Docker with the Compose plugin. Node is not required on the host.
    git --version
    docker --version
    docker compose version
  2. Clone the repo and create your config.
    git clone https://github.com/DuarteSantos8/openGym
    cd openGym
    cp .env.example .env
  3. Pull the prebuilt images and start the stack. The first start also downloads about 140 MB of exercise images and animations, once.
    docker compose pull
    docker compose up -d
  4. Confirm it is healthy. The health endpoint should answer with {"ok":true,...}.
    docker compose ps
    curl http://localhost:8080/api/health
  5. Create your profile. Open http://localhost:8080 on the same machine, tap Create profile and register a passkey. No account anywhere else is needed.
  6. Put it on your own HTTPS domain for your phone. Route a hostname such as gym.example.com to port 8080 through Cloudflare Tunnel, Caddy, Traefik or nginx, then edit .env:
    RP_ID=gym.example.com
    ORIGIN=https://gym.example.com
    WEB_PORT=8080
    RP_NAME=openGym
  7. Apply the new settings. Use up -d, not restart, so the containers re-read .env.
    docker compose up -d
  8. Install it on your phone and train. Visit https://gym.example.com, create your profile, add it to the home screen (iOS: Share, then Add to Home Screen; Android: the menu, then Add to Home screen), load one of the four starter plans and start today's workout.
How an openGym install fits togetherA phone or laptop reaches openGym over HTTPS through a reverse proxy or Cloudflare Tunnel. The proxy points at the web container, nginx on port 8080, which serves the React app and forwards /api to the Node API on port 3000. The API stores profiles and workouts as JSON files in the ./data folder on the host. A one-shot media service downloads about 140 MB of exercise media on first start. The AI coach and MCP server are optional extras. ONE ORIGIN, ONE DATA FOLDER Phone / laptop PWA + passkey HTTPS proxy Tunnel, Caddy, nginx WEB :8080 nginx + React proxies /api API :3000 node:http 2 dependencies ./data on the host db.json, state-<user>.json secret, vapid.json media service one-shot, ~140 MB of demos downloaded on first start Optional extras AI coach (your own key) MCP server, read-only Passkeys need RP_ID and ORIGIN to match the HTTPS address in the browser exactly genztech.blog
Fig 1 Everything sits on one origin because passkeys require it. Back up ./data and you have backed up the whole instance.

Try the openGym demo before installing anything

The project hosts an in-browser demo that is the real app loaded with example data. Spend two minutes there first. Open today's workout, tick off a set and watch the rest timer start, then flip to the stats screen for the year-long heatmap and the muscle map, which shows where your volume went, what is still recovering and what has gone untrained. If the guided-workout screen does not click for you, nothing about self-hosting will change that.

The feature list is long for a project created in July. There are 1,324 exercises with animated demos, filterable by the equipment you own, plus supersets, drop sets, warm-ups, timed holds and cardio. Progression rules include linear, Greyskull LP and double progression, and each suggested weight explains why it is that number. A plate calculator works from the plates you actually have. The app ships in 17 languages, including right-to-left Arabic.

Running openGym with Docker Compose on Windows, macOS and Linux

The commands are identical on all three because everything happens inside containers. On Windows and macOS that means Docker Desktop running in the background; on Linux, Docker Engine with the Compose plugin. The README's quick start is five lines:

git clone https://github.com/DuarteSantos8/openGym
cd openGym
cp .env.example .env
docker compose pull      # prebuilt images, amd64 + arm64 (skip this to build from source)
docker compose up -d

On Windows, run those from Git Bash or WSL so cp works as written. In plain cmd.exe the equivalent copy is copy .env.example .env. In our clone the compose file pulled from ghcr.io/duartesantos8/opengym-api and opengym-web, and the same tags are published to GitLab's registry at registry.gitlab.com/duartesantos8/opengym if you prefer that mirror. If you want to build from source instead, skip the pull and run docker compose up -d --build.

The default .env is already set for local testing: RP_ID=localhost, ORIGIN=http://localhost:8080, WEB_PORT=8080. Logs come from docker compose logs -f and docker compose down stops everything. Updating later is git pull && docker compose pull && docker compose up -d, and your ./data folder is left alone.

For this guide's video we drove the hosted demo rather than a local stack, because the test laptop was short on free memory for Docker Desktop alongside our recording tools. The commands above are copied from the project's README and self-hosting guide, not shortened or rewritten.

Getting passkeys to work on your phone: RP_ID, ORIGIN and HTTPS

This is where most first installs stall. Browsers only offer passkeys over HTTPS, with one exception: http://localhost. So the instance works on the computer running Docker, but your phone pointed at http://192.168.1.20:8080 will never show a passkey prompt. It is neither localhost nor HTTPS.

The fix is a real hostname with a certificate. The self-hosting guide covers three routes. Cloudflare Tunnel needs no open ports: create a tunnel and route gym.example.com to http://<docker-host>:8080. Caddy gets a Let's Encrypt certificate on its own with a two-line site block:

gym.example.com {
    reverse_proxy localhost:8080
}

Traefik, nginx or Nginx Proxy Manager work too. If your proxy caps request bodies, allow at least 5 MiB on /api/, because the app syncs its whole history in one PUT. Then set RP_ID to the bare hostname and ORIGIN to the full URL, as in step 6 above. Pick the domain before anyone registers: changing RP_ID later invalidates every passkey bound to the old one.

Two escape hatches exist if HTTPS is not an option yet. Guest mode keeps data only in that browser, and PASSWORD_LOGIN=1 adds name-and-password sign-in next to passkeys. There is also a standalone Android app that needs no server at all, and it can pair with your instance through a one-time code from Settings.

Locking an openGym instance down for family or a small gym

Out of the box anyone who can reach the URL can create a profile, and each profile sees only its own data. For a shared instance, register your own profile first, copy your id from Settings, Account, Account ID, and add three lines to .env:

ADMIN_UIDS=youruserid      # comma-separated; these users get the admin dashboard
INVITE_ONLY=1              # new profiles need an invite code
ALLOW_GUEST=0              # remove "Continue without account"

You get an admin dashboard for invite codes, disabling accounts and an activity log. Backups are a single archive of the data folder, which includes passkey public data and everyone's workout history:

RelatedPaperclip Setup: Self-Host an Org Chart for AI Agents

tar czf opengym-backup-$(date +%F).tar.gz data/

Guard the data/secret file in particular. It signs every session cookie, so losing it signs everyone out and un-pairs every phone.

openGym next to Strong, Hevy and wger

OptionopenGymStrong / Hevywger
Where your data livesJSON files in ./data on your serverThe vendor's cloud accountYour server's database
LicenseAGPL-3.0ProprietaryOpen source, self-hostable
Sign-inPasskeys, optional passwordVendor accountAccount on your instance
Imports history fromFitNotes, Strong, Hevy, Apple Health weightTheir own formatsIts own formats
MaturityRepo created July 2026, releases about every two weeksYears of polish, app-store appsLong-running community project

Our read: if you already run a home server or a Cloudflare Tunnel, openGym is worth installing today. The import path means trying it costs you nothing, and the guided workout flow aims at the polish of Strong, not the spreadsheet feel of older self-hosted trackers. If you have never touched a reverse proxy, the passkey requirement makes the first evening harder than the "one command" pitch suggests, so start with the Android APK or the demo. Be honest with yourself about storage, too. Everything lives in JSON files today, and the roadmap schedules a move to database storage in v1.4.0 in January 2027, flagged as the project's one compatibility break. The README is also open that the code is largely drafted with Claude Code under human review, with unit tests on the training logic. And the exercise images are third-party media with disputed ownership, downloaded by your instance rather than covered by the AGPL.

Dates on the roadmap
  • v1.3.10, October 2026. Session queue and rotation, for routines that do not map to fixed weekdays.
  • v1.4.0, January 2027. The switch to database storage. Take a tar backup of ./data before upgrading.
  • v1.4.1 to v1.4.3, early 2027. OIDC login and a trainer role, which would make shared gym instances far easier to run.

Fixing "verification failed", port 8080 clashes and missing exercise images

"verification failed" when signing in. RP_ID or ORIGIN does not match the address bar. Ask the server what it loaded:

docker compose logs api | grep 'gym-api on'

If that disagrees with your .env, the container is running the old environment. docker compose restart does not re-read .env; docker compose up -d does. Also check the shapes: RP_ID is a bare hostname with no scheme, port or trailing slash, while ORIGIN includes https:// and has no trailing slash.

No passkey prompt on the phone. You are on http:// or a LAN IP. Set up the HTTPS hostname from the section above, or switch on password sign-in.

Port 8080 is already in use. Set WEB_PORT=9090 in .env and change ORIGIN to match for local testing.

Exercise images never appeared. Check docker compose logs media, then re-run docker compose up -d or ./scripts/fetch-media.sh.

docker compose pull says "denied" or "unauthorized". Build from source instead with docker compose up -d --build.

Primary sources